dsh-remote-plugin 0.6.14 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -69,6 +69,9 @@ const state = {
69
69
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
70
70
  byId: new Map(),
71
71
  current: null,
72
+ sessionRecovery: { status: 'idle', error: '' },
73
+ pendingProjections: new Map(),
74
+ lastStreamResyncAt: 0,
72
75
  hostInfo: null,
73
76
  history: emptyDesktopHistory(),
74
77
  approvals: [],
@@ -1017,6 +1020,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1017
1020
  updateConn()
1018
1021
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
1019
1022
  if (refreshOnOpen) refreshSessions()
1023
+ if (allStreamsOpen()) resyncAfterStreamOpen()
1020
1024
  }
1021
1025
  ws.onmessage = (msg) => {
1022
1026
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1113,6 +1117,7 @@ async function pollKind(kind) {
1113
1117
  state.pollSeq[kind] = 0
1114
1118
  if (kind === 'mux') renderNotifStack()
1115
1119
  refreshSessions()
1120
+ if (state.current) void resyncCurrentSession()
1116
1121
  }
1117
1122
  for (const item of data.events) {
1118
1123
  if (item.seq > (state.pollSeq[kind] || 0)) {
@@ -1183,20 +1188,67 @@ function onHostFrame(full) {
1183
1188
  if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessions(); renderQueue(); updateComposerStatus() } renderOverviewDesktop() }
1184
1189
  }
1185
1190
  }
1191
+ function hydrateSessionProjections(sessionId, projections) {
1192
+ const s = state.byId.get(sessionId)
1193
+ if (!s || !projections || typeof projections !== 'object') return
1194
+ const incomingSeq = Number(projections.asOfSeq) || 0
1195
+ const current = s.projections || { asOfSeq: 0, values: {} }
1196
+ const currentSeq = Number(current.asOfSeq) || 0
1197
+ if (incomingSeq < currentSeq) return
1198
+ s.projections = {
1199
+ asOfSeq: Math.max(currentSeq, incomingSeq),
1200
+ values: { ...(current.values || {}), ...(projections.values || {}) }
1201
+ }
1202
+ }
1203
+ function applyPendingProjections() {
1204
+ for (const [sessionId, projections] of state.pendingProjections) {
1205
+ if (!state.byId.has(sessionId)) continue
1206
+ hydrateSessionProjections(sessionId, projections)
1207
+ state.pendingProjections.delete(sessionId)
1208
+ }
1209
+ }
1186
1210
  function applyProjection(sessionId, key, value, seq) {
1187
1211
  const s = state.byId.get(sessionId)
1188
- if (s) {
1189
- s.projections = s.projections || { asOfSeq: 0, values: {} }
1190
- s.projections.values = s.projections.values || {}
1191
- s.projections.values[key] = value
1192
- s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
1212
+ if (!s) {
1213
+ const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
1214
+ pending.values[key] = value
1215
+ pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
1216
+ state.pendingProjections.set(sessionId, pending)
1217
+ return
1193
1218
  }
1219
+ const currentSeq = Number(s.projections?.asOfSeq) || 0
1220
+ if (seq && seq < currentSeq) return
1221
+ s.projections = s.projections || { asOfSeq: 0, values: {} }
1222
+ s.projections.values = s.projections.values || {}
1223
+ s.projections.values[key] = value
1224
+ s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1194
1225
  if (state.current === sessionId) {
1195
1226
  renderSessions()
1196
1227
  if (['goal', 'todos'].includes(key)) renderSessionCards()
1197
1228
  }
1198
1229
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
1199
1230
  }
1231
+ function setSessionRecovery(status, error = '') {
1232
+ state.sessionRecovery = { status, error: String(error || '') }
1233
+ updateSessionActions()
1234
+ }
1235
+ function recoveryLabel() {
1236
+ const status = state.sessionRecovery.status
1237
+ if (status === 'loading' || status === 'resuming') return t('ds.sessionRecovering')
1238
+ if (status === 'error') return t('ds.sessionRecoveryFailed')
1239
+ return ''
1240
+ }
1241
+ function resyncCurrentSession() {
1242
+ if (!state.current) return Promise.resolve()
1243
+ return loadHistory().then(() => {
1244
+ if (state.current) { renderSessionCards(); updateComposerStatus(); updateSessionActions() }
1245
+ })
1246
+ }
1247
+ function resyncAfterStreamOpen() {
1248
+ if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
1249
+ state.lastStreamResyncAt = Date.now()
1250
+ void refreshSessions().then(() => resyncCurrentSession())
1251
+ }
1200
1252
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1201
1253
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
1202
1254
  function isTopLevelSession(session) {
@@ -1246,6 +1298,7 @@ async function refreshSessions() {
1246
1298
  if (!v) { renderSessions(); renderOverviewDesktop(); return }
1247
1299
  state.sessions = v.items || []
1248
1300
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1301
+ applyPendingProjections()
1249
1302
  renderSessions()
1250
1303
  scheduleWorkbenchRefresh()
1251
1304
  renderOverviewDesktop()
@@ -1258,6 +1311,14 @@ function sessionWorkspaceLabel(s) {
1258
1311
  function sessionSortTime(s) {
1259
1312
  return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
1260
1313
  }
1314
+ function sessionWorkspaceOrderKey(s) {
1315
+ return 'path:' + (sessionWorkspaceLabel(s) || 'workspace-unknown')
1316
+ }
1317
+ function commitWorkspaceGroupOrder(order) {
1318
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1319
+ renderSessions()
1320
+ toast(t('ds.wbOrderSaved'), 'ok')
1321
+ }
1261
1322
  function noteSessionTurnTime(sessionId, eventOrTime) {
1262
1323
  const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
1263
1324
  const time = Number(raw) > 0 ? Number(raw) : Date.now()
@@ -1297,30 +1358,54 @@ function renderSessions() {
1297
1358
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1298
1359
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1299
1360
  const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
1300
- const renderItems = (items) => {
1301
- let lastWorkspace = null
1302
- const rows = []
1303
- for (const s of items) {
1361
+ const renderSession = s => {
1304
1362
  const workspace = sessionWorkspaceLabel(s)
1305
1363
  const workspaceName = workspaceDisplayName(workspace)
1306
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1307
- rows.push(`<div class="ds-session-group" title="${esc(workspace)}"><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(workspaceName)}</span></div>`)
1308
- lastWorkspace = workspace
1309
- }
1310
1364
  const title = titleOf(s)
1311
- rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1365
+ return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1312
1366
  <span class="ds-session-title">${esc(title)}</span>
1313
1367
  <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1314
1368
  <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(sessionSortTime(s))}</span>
1315
- </button>`)
1369
+ </button>`
1370
+ }
1371
+ const renderItems = (items) => {
1372
+ if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
1373
+ const groups = []
1374
+ const byKey = new Map()
1375
+ for (const session of items) {
1376
+ const key = sessionWorkspaceOrderKey(session)
1377
+ let group = byKey.get(key)
1378
+ if (!group) {
1379
+ group = { key, label: workspaceDisplayName(sessionWorkspaceLabel(session)), path: sessionWorkspaceLabel(session), items: [] }
1380
+ byKey.set(key, group)
1381
+ groups.push(group)
1382
+ }
1383
+ group.items.push(session)
1316
1384
  }
1317
- return rows.join('')
1385
+ const { value } = workbenchOrderScopeValue()
1386
+ return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="ds-session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
1387
+ <div class="ds-session-group" data-reorder-handle title="${esc(group.path)}"><span class="ds-session-group-drag-handle" aria-hidden="true">⠿</span><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(group.label)}</span></div>
1388
+ ${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
1389
+ </div>`).join('')
1318
1390
  }
1319
1391
  const divider = archived.length ? `<button class="ds-archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1320
1392
  const hiddenByWorkbench = allItems.length - visible.length
1321
1393
  const html = renderItems(main) + divider + (showArchived ? renderItems(archived) : '') || `<div class="ds-empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('ds.sessionsEmpty'))}</div>`
1322
1394
  $('session-list').innerHTML = html
1323
1395
  $('mobile-session-list').innerHTML = html
1396
+ window.DshMotion?.list($('session-list'), '.ds-session-item')
1397
+ window.DshMotion?.list($('mobile-session-list'), '.ds-session-item')
1398
+ for (const list of [$('session-list'), $('mobile-session-list')].filter(Boolean)) {
1399
+ window.DshMotion?.bindLongPressReorder(list, '.ds-session-workspace-group', {
1400
+ handleSelector: '.ds-session-group',
1401
+ onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
1402
+ })
1403
+ window.DshMotion?.bindLongPressReorder(list, '.ds-session-item', {
1404
+ groupSelector: '.ds-session-workspace-group',
1405
+ handleSelector: '.ds-session-item',
1406
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
1407
+ })
1408
+ }
1324
1409
  $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1325
1410
  $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1326
1411
  const sort = $('session-sort')
@@ -1335,10 +1420,12 @@ function renderSessions() {
1335
1420
 
1336
1421
  async function openSession(id) {
1337
1422
  state.current = id
1423
+ setSessionRecovery('loading')
1338
1424
  state.history = emptyDesktopHistory()
1339
1425
  state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
1340
1426
  showView('view-chat')
1341
1427
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
1428
+ updateSessionActions()
1342
1429
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
1343
1430
  renderSessions()
1344
1431
  renderSessionCards()
@@ -1348,25 +1435,30 @@ async function openSession(id) {
1348
1435
  }
1349
1436
  function closeSession() {
1350
1437
  state.current = null
1438
+ setSessionRecovery('idle')
1351
1439
  state.history = emptyDesktopHistory()
1352
1440
  const cards = $('session-cards')
1353
1441
  if (cards) cards.innerHTML = ''
1354
1442
  renderQueue()
1355
1443
  updateComposerStatus()
1444
+ updateSessionActions()
1356
1445
  showView('view-sessions')
1357
1446
  }
1358
1447
  async function loadHistory() {
1359
1448
  const id = state.current
1360
1449
  if (!id || state.history.loading) return
1361
1450
  state.history.loading = true
1451
+ setSessionRecovery('loading')
1362
1452
  let v
1363
1453
  try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1364
1454
  catch (e) {
1365
1455
  state.history.loading = false
1366
1456
  if (e.message === 'AUTH') return
1457
+ setSessionRecovery('error', e.message)
1367
1458
  $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
1368
1459
  return
1369
1460
  }
1461
+ hydrateSessionProjections(id, v.projections)
1370
1462
  for (const entry of v.events || []) {
1371
1463
  const ev = entry?.event
1372
1464
  const seq = ev?.seq
@@ -1380,6 +1472,8 @@ async function loadHistory() {
1380
1472
  state.history.visible.sort((a, b) => a.seq - b.seq)
1381
1473
  state.history.hasMore = !!v.hasMore
1382
1474
  state.history.loading = false
1475
+ setSessionRecovery('ready')
1476
+ updateSessionActions()
1383
1477
  renderHistory()
1384
1478
  }
1385
1479
 
@@ -1557,10 +1651,15 @@ async function renderSessionCards() {
1557
1651
  const running = e.activity === 'running'
1558
1652
  return `<div class="ds-card-row"><span class="ds-card-k">${running ? '▶ ' : ''}${esc(label)}</span><span class="ds-card-v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="ds-mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
1559
1653
  }).join('')
1560
- box.insertAdjacentHTML('beforeend', `<div class="ds-card ds-subagent-card"><button type="button" class="ds-subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="ds-card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="ds-subagent-toggle-icon" aria-hidden="true">${expanded ? '⌃' : '⌄'}</span></button><div class="ds-subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
1654
+ const subagentClosedIcon = 'M7 10l5 5 5-5'
1655
+ const subagentOpenIcon = 'M7 14l5-5 5 5'
1656
+ const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
1657
+ box.insertAdjacentHTML('beforeend', `<div class="ds-card ds-subagent-card"><button type="button" class="ds-subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="ds-card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="ds-subagent-toggle-icon" aria-hidden="true"><morph-icon data-morph-state="${expanded ? 'open' : 'closed'}" data-morph-closed="${subagentClosedIcon}" data-morph-open="${subagentOpenIcon}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${subagentIcon}"/></svg></morph-icon></span></button><div class="ds-subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
1561
1658
  box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
1659
+ const icon = box.querySelector('[data-subagent-toggle] morph-icon')
1660
+ if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
1562
1661
  state.subagentExpandedSession = expanded ? '' : sessionId
1563
- renderSessionCards()
1662
+ setTimeout(() => renderSessionCards(), 240)
1564
1663
  })
1565
1664
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
1566
1665
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
@@ -1645,18 +1744,73 @@ async function sendMessage() {
1645
1744
  if (!text || !state.current) return
1646
1745
  if (await runSlashCommand(text)) { input.value = ''; return }
1647
1746
  input.value = ''
1747
+ setSessionRecovery('resuming')
1648
1748
  const v = await safeRpc('session.prompt', {
1649
1749
  sessionId: state.current,
1650
1750
  mode: 'queue',
1651
1751
  content: [{ type: 'text', text }]
1652
1752
  }, '')
1653
- if (v) { noteSessionTurnTime(state.current, Date.now()); renderSessions(); toast(t('ds.toastSent'), 'ok') }
1753
+ if (v) { setSessionRecovery('ready'); noteSessionTurnTime(state.current, Date.now()); renderSessions(); toast(t('ds.toastSent'), 'ok') }
1754
+ else setSessionRecovery('error')
1755
+ }
1756
+
1757
+ async function cancelSession() {
1758
+ if (!state.current) return
1759
+ if (!confirm(t('ds.sessionStopConfirm'))) return
1760
+ const v = await safeRpc('session.cancel', { sessionId: state.current }, t('ds.sessionStopFailed'))
1761
+ if (v?.accepted) { setSessionRecovery('ready'); toast(t('ds.sessionStopRequested'), 'ok') }
1762
+ }
1763
+
1764
+ let renamePendingSessionId = null
1765
+ function renameSession(sessionId = state.current) {
1766
+ const session = state.byId.get(sessionId)
1767
+ if (!session) return
1768
+ renamePendingSessionId = sessionId
1769
+ $('rename-session-input').value = titleOf(session)
1770
+ $('modal-rename').classList.remove('hidden')
1771
+ setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
1772
+ }
1773
+ function closeRenameSession() {
1774
+ renamePendingSessionId = null
1775
+ $('modal-rename').classList.add('hidden')
1776
+ }
1777
+ async function confirmRenameSession() {
1778
+ const sessionId = renamePendingSessionId
1779
+ if (!sessionId) return
1780
+ const title = $('rename-session-input').value.trim()
1781
+ if (!title) return toast(t('ds.sessionRenameEmpty'), 'err')
1782
+ const button = $('rename-confirm')
1783
+ button.disabled = true
1784
+ setSessionRecovery('resuming')
1785
+ try {
1786
+ const value = await safeRpc('session.rename', { sessionId, title }, t('ds.sessionRenameFailed'))
1787
+ if (value == null) { setSessionRecovery('error'); return }
1788
+ if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
1789
+ closeRenameSession()
1790
+ setSessionRecovery('ready')
1791
+ toast(t('ds.sessionRenamed'), 'ok')
1792
+ await refreshSessions()
1793
+ } finally {
1794
+ button.disabled = false
1795
+ }
1796
+ }
1797
+
1798
+ async function archiveCurrentSession() {
1799
+ const sessionId = state.current
1800
+ if (!sessionId || !confirm(t('ds.sessionArchiveConfirm'))) return
1801
+ const value = await safeRpc('workspace.archiveSession', { sessionId }, t('ds.toastOpFailed'))
1802
+ if (!value) return
1803
+ if (Array.isArray(value.archivedSessionIds)) state.archivedIds = value.archivedSessionIds
1804
+ toast(t('ds.sessionArchived'), 'ok')
1805
+ closeSession()
1806
+ await refreshSessions()
1654
1807
  }
1655
1808
 
1656
1809
  function updateComposerStatus() {
1657
1810
  const status = $('composer-status')
1658
1811
  if (!status) return
1659
1812
  status.classList.toggle('hidden', !state.byId.get(state.current)?.running)
1813
+ updateSessionActions()
1660
1814
  }
1661
1815
  function queuePreview(item) {
1662
1816
  const blocks = item?.message?.content || item?.content || []
@@ -1948,6 +2102,64 @@ async function wbGateway(method, pathname, body) {
1948
2102
  if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status))
1949
2103
  return data
1950
2104
  }
2105
+ const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
2106
+ function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
2107
+ function workbenchOrderStore() {
2108
+ let value = null
2109
+ try { value = JSON.parse(LS.get(WORKBENCH_ORDER_CACHE_KEY, '{}')) } catch {}
2110
+ if (!value || typeof value !== 'object') value = {}
2111
+ if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
2112
+ return value
2113
+ }
2114
+ function workbenchOrderScopeValue() {
2115
+ const store = workbenchOrderStore()
2116
+ const key = workbenchOrderScope()
2117
+ if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
2118
+ return { store, value: store.scopes[key] }
2119
+ }
2120
+ function orderedItems(items, ids, getId) {
2121
+ const source = Array.isArray(items) ? items : []
2122
+ const byId = new Map(source.map(item => [String(getId(item)), item]))
2123
+ const result = []
2124
+ const used = new Set()
2125
+ for (const id of Array.isArray(ids) ? ids : []) {
2126
+ const key = String(id)
2127
+ const item = byId.get(key)
2128
+ if (item && !used.has(key)) { result.push(item); used.add(key) }
2129
+ }
2130
+ for (const item of source) {
2131
+ const key = String(getId(item))
2132
+ if (!used.has(key)) { result.push(item); used.add(key) }
2133
+ }
2134
+ return result
2135
+ }
2136
+ function orderedWorkspaceItems(items) {
2137
+ const { value } = workbenchOrderScopeValue()
2138
+ return orderedItems(items, value.workspaceIds, item => item.workspaceId)
2139
+ }
2140
+ function orderedWorkspaceSessions(workspaceId, items) {
2141
+ const { value } = workbenchOrderScopeValue()
2142
+ return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
2143
+ }
2144
+ function saveWorkbenchOrder(mutator) {
2145
+ const { store, value } = workbenchOrderScopeValue()
2146
+ mutator(value)
2147
+ LS.set(WORKBENCH_ORDER_CACHE_KEY, JSON.stringify(store))
2148
+ }
2149
+ function commitWorkspaceOrder(order) {
2150
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
2151
+ renderWorkbench()
2152
+ toast(t('ds.wbOrderSaved'), 'ok')
2153
+ }
2154
+ function commitWorkspaceSessionOrder(workspaceId, order) {
2155
+ if (!workspaceId) return
2156
+ saveWorkbenchOrder(value => {
2157
+ value.sessionIds ||= {}
2158
+ value.sessionIds[String(workspaceId)] = order.map(String)
2159
+ })
2160
+ renderWorkbench()
2161
+ toast(t('ds.wbOrderSaved'), 'ok')
2162
+ }
1951
2163
  async function refreshWorkbench({ silent = false } = {}) {
1952
2164
  if (!state.token) { renderWorkbench(); return }
1953
2165
  let wb = null
@@ -1995,9 +2207,9 @@ async function refreshWorkbench({ silent = false } = {}) {
1995
2207
  }
1996
2208
  }
1997
2209
  } catch {}
1998
- state.wb.projects = items
2210
+ state.wb.projects = orderedWorkspaceItems(items
1999
2211
  .filter(w => wbStrictInside(w.path, state.wb.path))
2000
- .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true }))
2212
+ .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true })))
2001
2213
  renderWorkbench()
2002
2214
  renderSessions()
2003
2215
  }
@@ -2021,27 +2233,41 @@ function renderWorkbench() {
2021
2233
  const panel = $('wb-panel')
2022
2234
  panel.classList.toggle('hidden', !state.wb.expanded)
2023
2235
  if (!state.wb.expanded) return
2024
- const projects = state.wb.projects || []
2236
+ const projects = orderedWorkspaceItems(state.wb.projects || [])
2025
2237
  const archivedSet = new Set(state.archivedIds || [])
2026
2238
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
2027
2239
  html += projects.length ? projects.map(w => {
2028
2240
  const id = String(w.workspaceId || '')
2029
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
2241
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a)))
2030
2242
  const open = state.wb.open === id
2031
- return `<div class="ds-wb-project ${open ? 'open' : ''}">
2243
+ return `<div class="ds-wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
2032
2244
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
2245
+ <span class="ds-wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
2033
2246
  <span class="ds-wb-caret" aria-hidden="true">${open ? '▾' : '▸'}</span>
2034
2247
  <span class="ds-wb-project-title" title="${esc(w.path)}">${esc(w.title || wbBaseName(w.path) || short(id))}</span>
2035
2248
  <span class="ds-wb-project-count">${sessions.length}</span>
2036
2249
  </button>
2037
2250
  <div class="ds-wb-project-body ${open ? '' : 'hidden'}">
2038
2251
  <button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
2039
- ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}"><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
2252
+ ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}"><span class="ds-wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
2040
2253
  </div>
2041
2254
  </div>`
2042
2255
  }).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
2043
2256
  html += `<button type="button" class="ds-mini-btn ds-wb-unbind-panel" data-wb-unbind-panel>${esc(t('wb.unbind'))}</button>`
2044
- panel.innerHTML = html
2257
+ if (window.DshMotion?.relayout) {
2258
+ window.DshMotion.relayout(panel, '.ds-wb-project', () => { panel.innerHTML = html })
2259
+ } else panel.innerHTML = html
2260
+ window.DshMotion?.list(panel, '.ds-wb-session')
2261
+ window.DshMotion?.bindLongPressReorder(panel, '.ds-wb-project', {
2262
+ handleSelector: '.ds-wb-project-head',
2263
+ excludeSelector: '[data-wb-new]',
2264
+ onCommit: ({ order }) => commitWorkspaceOrder(order)
2265
+ })
2266
+ window.DshMotion?.bindLongPressReorder(panel, '.ds-wb-session', {
2267
+ groupSelector: '.ds-wb-project',
2268
+ handleSelector: '.ds-wb-session',
2269
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
2270
+ })
2045
2271
  panel.querySelectorAll('[data-wb-head]').forEach(button => button.addEventListener('click', () => {
2046
2272
  state.wb.open = state.wb.open === button.dataset.wbHead ? null : button.dataset.wbHead
2047
2273
  renderWorkbench()
@@ -2289,12 +2515,28 @@ function showView(id) {
2289
2515
  state.view = id
2290
2516
  for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
2291
2517
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
2518
+ window.DshMotion?.view($(id))
2292
2519
  const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
2293
2520
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
2294
2521
  else $('ds-title').textContent = t(titles[id])
2295
2522
  if (id === 'view-overview') renderOverviewDesktop()
2296
2523
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
2297
2524
  if (id === 'view-settings') showSettingsHome()
2525
+ updateSessionActions()
2526
+ }
2527
+
2528
+ function updateSessionActions() {
2529
+ const active = state.view === 'view-chat' && !!state.current
2530
+ $('btn-rename-session')?.classList.toggle('hidden', !active)
2531
+ $('btn-archive-session')?.classList.toggle('hidden', !active || state.archivedIds.includes(state.current))
2532
+ $('ds-session-status')?.classList.toggle('hidden', !active || !recoveryLabel())
2533
+ if (active) {
2534
+ const s = state.byId.get(state.current)
2535
+ $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions')
2536
+ $('ds-session-status').textContent = recoveryLabel()
2537
+ }
2538
+ const running = !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
2539
+ $('btn-cancel')?.classList.toggle('hidden', !active || !running)
2298
2540
  }
2299
2541
 
2300
2542
  const SETTINGS_GROUPS = ['general', 'servers', 'theme', 'about']
@@ -2428,6 +2670,13 @@ function bindUi() {
2428
2670
  $('btn-wb-path').addEventListener('click', () => { if (state.wb.path) toast(t('wb.boundPath', { path: state.wb.path }), 'ok') })
2429
2671
  $('btn-wb-unbind').addEventListener('click', unbindWorkbench)
2430
2672
  $('btn-send').addEventListener('click', sendMessage)
2673
+ $('btn-cancel').addEventListener('click', cancelSession)
2674
+ $('btn-rename-session').addEventListener('click', () => renameSession())
2675
+ $('btn-archive-session').addEventListener('click', archiveCurrentSession)
2676
+ $('rename-cancel').addEventListener('click', closeRenameSession)
2677
+ $('rename-confirm').addEventListener('click', confirmRenameSession)
2678
+ $('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
2679
+ $('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
2431
2680
  $('composer').addEventListener('keydown', (e) => {
2432
2681
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
2433
2682
  })
package/public/index.html CHANGED
@@ -93,6 +93,8 @@
93
93
  <div id="session-title" class="session-title" data-i18n="session.loading">加载中…</div>
94
94
  <div id="session-sub" class="session-sub"></div>
95
95
  </div>
96
+ <button id="btn-rename-session" class="icon-btn hidden" data-i18n-title="session.rename" data-i18n-aria="session.rename">✎</button>
97
+ <button id="btn-archive-session" class="mini-btn hidden" data-i18n="session.archive">归档</button>
96
98
  <button id="btn-stats" class="icon-btn" data-i18n-title="stats.title" data-i18n-aria="stats.title"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 19V9M12 19V5M19 19v-7M3 19h18"/></svg></button>
97
99
  <button id="btn-fs-send" class="session-send-btn hidden" type="button" data-i18n="composer.send" data-i18n-title="composer.send" data-i18n-aria="composer.send">发送</button>
98
100
  <button id="btn-cancel" class="danger-btn hidden" data-i18n="session.stop">停止</button>
@@ -158,8 +160,7 @@
158
160
  <div id="composer-input-wrap" class="composer-input-wrap">
159
161
  <textarea id="composer-input" rows="1" data-i18n-placeholder="composer.placeholder" placeholder="给 DSH 发消息…"></textarea>
160
162
  <button id="btn-fs-toggle" class="composer-fs-btn hidden" type="button" data-i18n-title="composer.fullscreen" data-i18n-aria="composer.fullscreen">
161
- <svg id="fs-ico-expand" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M21 16v5h-5"/></svg>
162
- <svg id="fs-ico-collapse" class="hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M9 3v6H3M15 3v6h6M9 21v-6H3M21 15h-6v6"/></svg>
163
+ <morph-icon id="fs-ico" data-morph-state="closed" data-morph-closed="M8 3H3v5M16 3h5v5M8 21H3v-5M21 16v5h-5" data-morph-open="M9 3v6H3M15 3v6h6M9 21v-6H3M21 15h-6v6" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M8 21H3v-5M21 16v5h-5"/></svg></morph-icon>
163
164
  </button>
164
165
  </div>
165
166
  <button id="btn-image" class="composer-image-btn" type="button" data-i18n-title="composer.addImage" data-i18n-aria="composer.addImage"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3.5" y="4" width="17" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.2"/><path d="m5 17 4.5-4.5 3 3 2-2L19 17"/></svg></button>
@@ -653,6 +654,19 @@
653
654
  </div>
654
655
  </div>
655
656
 
657
+ <div id="modal-rename" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="rename-session-title">
658
+ <div class="modal-card">
659
+ <div id="rename-session-title" class="modal-title" data-i18n="session.renameTitle">重命名会话</div>
660
+ <div class="modal-body">
661
+ <input id="rename-session-input" class="text-input" maxlength="160" autocomplete="off" data-i18n-placeholder="session.renamePlaceholder" placeholder="输入新的会话名称">
662
+ </div>
663
+ <div class="modal-actions">
664
+ <button id="rename-cancel" class="btn subtle" data-i18n="session.renameCancel">取消</button>
665
+ <button id="rename-confirm" class="btn primary" data-i18n="session.renameConfirm">保存</button>
666
+ </div>
667
+ </div>
668
+ </div>
669
+
656
670
  <!-- goal 模态 -->
657
671
  <div id="modal-goal" class="modal hidden" role="dialog" aria-modal="true">
658
672
  <div class="modal-card">
@@ -812,7 +826,7 @@
812
826
  'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
813
827
  'statsPage.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
814
828
  'home.newSession': '+ 新会话', 'home.newWorkspace': '+ 工作区', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
815
- 'wb.unbound': '未绑定工作台', 'wb.bound': '绑定 {title}', 'wb.noProjects': '暂无项目', 'wb.noSessions': '暂无会话', 'wb.newSession': '新会话', 'wb.flatHidden': '工作台会话已收起({n} 个)', 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------',
829
+ 'wb.unbound': '未绑定工作台', 'wb.bound': '绑定 {title}', 'wb.noProjects': '暂无项目', 'wb.noSessions': '暂无会话', 'wb.newSession': '新会话', 'wb.orderSaved': '顺序已保存到当前设备', 'wb.flatHidden': '工作台会话已收起({n} 个)', 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------',
816
830
  'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.exists': '该目录已存在', 'workspace.invalidName': '名称不能包含路径分隔符', 'workspace.createFailed': '创建工作区失败:{msg}', 'workspace.created': '工作区已创建', 'workspace.createdNoSession': '工作区已创建,但新会话未能打开',
817
831
  'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.created': '工作区已创建', 'workspace.sessionFailed': '目录已创建,但新会话打开失败:{msg}',
818
832
  'workspace.current': '当前工作区', 'workspace.select': '选择工作区', 'workspace.all': '全部工作区', 'workspace.ungrouped': '未分组会话', 'workspace.files': '查看文件', 'workspace.allPath': '显示全部 DSH 工作区中的会话',
@@ -849,8 +863,8 @@
849
863
  'groups.confirmDelete': '删除组「{group}」?组内服务器将归入默认组。', 'groups.deleted': '组已删除',
850
864
  'stream.error': '事件流错误:{msg}',
851
865
  'session.loading': '加载中…', 'session.unknown': '未知会话', 'session.running': '运行中', 'session.interrupted': '已中断',
852
- 'session.stop': '停止', 'session.history': '对话', 'session.errorMsg': '会话出错:{msg}',
853
- 'session.confirmStop': '停止当前会话正在运行的任务?', 'session.stopFailed': '停止失败', 'session.stopRequested': '已请求停止', 'session.archive': '归档', 'session.archiveConfirm': '归档这个会话?归档后可在“显示已归档会话”中打开。', 'session.archiveFailed': '归档失败:{msg}', 'session.archived': '会话已归档', 'session.archiveTitle': '确认归档会话', 'session.archiveDesc': '归档后会话将从普通列表隐藏,但不会删除对话记录。', 'session.archiveConversation': '对话', 'session.archiveWorkspace': '工作区', 'session.archiveCancel': '取消', 'session.archiveConfirmAction': '确认归档',
866
+ 'session.stop': '停止本轮', 'session.history': '对话', 'session.errorMsg': '会话出错:{msg}', 'session.recovering': '恢复会话中…', 'session.recoveryReady': '会话已恢复', 'session.recoveryFailed': '会话恢复失败', 'session.recoveryCached': '正在查看缓存历史',
867
+ 'session.confirmStop': '停止当前回合?排队中的消息不会被删除。', 'session.stopFailed': '停止失败', 'session.stopRequested': '已请求停止本轮', 'session.archive': '归档', 'session.archiveConfirm': '归档这个会话?归档后可在“显示已归档会话”中打开。', 'session.archiveFailed': '归档失败:{msg}', 'session.archived': '会话已归档', 'session.archiveTitle': '确认归档会话', 'session.archiveDesc': '归档后会话将从普通列表隐藏,但不会删除对话记录。', 'session.archiveConversation': '对话', 'session.archiveWorkspace': '工作区', 'session.archiveCancel': '取消', 'session.archiveConfirmAction': '确认归档', 'session.rename': '重命名会话', 'session.renameTitle': '重命名会话', 'session.renamePlaceholder': '输入新的会话名称', 'session.renameCancel': '取消', 'session.renameConfirm': '保存', 'session.renameEmpty': '会话名称不能为空', 'session.renameFailed': '重命名失败', 'session.renamed': '会话名称已更新',
854
868
  'notify.approvalTitle': '工具审批', 'notify.approvalBody': '{tool} 需要批准', 'notify.questionTitle': 'DSH 提问', 'notify.questionBody': '需要你回答',
855
869
  'notify.permissionFailed': '通知权限申请失败:{msg}',
856
870
  'history.loading': '加载历史…', 'history.cacheFallback': '网络不可用:显示本地缓存的历史', 'history.loadFailed': '加载历史失败:{msg}', 'history.retry': '重试',
@@ -1024,7 +1038,7 @@
1024
1038
  'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
1025
1039
  'statsPage.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
1026
1040
  'home.newSession': '+ New session', 'home.newWorkspace': '+ Workspace', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
1027
- 'wb.unbound': 'No workbench bound', 'wb.bound': 'Bound {title}', 'wb.noProjects': 'No projects', 'wb.noSessions': 'No sessions', 'wb.newSession': 'New session', 'wb.flatHidden': 'Workbench sessions folded ({n})', 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------',
1041
+ 'wb.unbound': 'No workbench bound', 'wb.bound': 'Bound {title}', 'wb.noProjects': 'No projects', 'wb.noSessions': 'No sessions', 'wb.newSession': 'New session', 'wb.orderSaved': 'Order saved on this device', 'wb.flatHidden': 'Workbench sessions folded ({n})', 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------',
1028
1042
  'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.exists': 'That folder already exists', 'workspace.invalidName': 'The name cannot contain path separators', 'workspace.createFailed': 'Could not create workspace: {msg}', 'workspace.created': 'Workspace created', 'workspace.createdNoSession': 'Workspace created, but the new session could not be opened',
1029
1043
  'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.created': 'Workspace created', 'workspace.sessionFailed': 'Folder created, but the new session could not be opened: {msg}',
1030
1044
  'workspace.current': 'Current workspace', 'workspace.select': 'Select workspace', 'workspace.all': 'All workspaces', 'workspace.ungrouped': 'Ungrouped sessions', 'workspace.files': 'View files', 'workspace.allPath': 'Showing sessions from every DSH workspace',
@@ -1061,8 +1075,8 @@
1061
1075
  'groups.confirmDelete': 'Delete group "{group}"? Its servers move to the default group.', 'groups.deleted': 'Group deleted',
1062
1076
  'stream.error': 'Event stream error: {msg}',
1063
1077
  'session.loading': 'Loading…', 'session.unknown': 'Unknown session', 'session.running': 'Running', 'session.interrupted': 'Interrupted',
1064
- 'session.stop': 'Stop', 'session.history': 'Conversation', 'session.errorMsg': 'Session error: {msg}',
1065
- 'session.confirmStop': 'Stop the currently running task in this session?', 'session.stopFailed': 'Stop failed', 'session.stopRequested': 'Stop requested', 'session.archive': 'Archive', 'session.archiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'session.archiveFailed': 'Archive failed: {msg}', 'session.archived': 'Session archived', 'session.archiveTitle': 'Confirm archive', 'session.archiveDesc': 'The session will be hidden from the normal list, but its conversation will not be deleted.', 'session.archiveConversation': 'Conversation', 'session.archiveWorkspace': 'Workspace', 'session.archiveCancel': 'Cancel', 'session.archiveConfirmAction': 'Archive session',
1078
+ 'session.stop': 'Stop turn', 'session.history': 'Conversation', 'session.errorMsg': 'Session error: {msg}', 'session.recovering': 'Restoring session…', 'session.recoveryReady': 'Session ready', 'session.recoveryFailed': 'Session restore failed', 'session.recoveryCached': 'Viewing cached history',
1079
+ 'session.confirmStop': 'Stop the current turn? Queued messages will be kept.', 'session.stopFailed': 'Stop failed', 'session.stopRequested': 'Stop requested', 'session.archive': 'Archive', 'session.archiveConfirm': 'Archive this session? You can open it from “Show archived”.', 'session.archiveFailed': 'Archive failed: {msg}', 'session.archived': 'Session archived', 'session.archiveTitle': 'Confirm archive', 'session.archiveDesc': 'The session will be hidden from the normal list, but its conversation will not be deleted.', 'session.archiveConversation': 'Conversation', 'session.archiveWorkspace': 'Workspace', 'session.archiveCancel': 'Cancel', 'session.archiveConfirmAction': 'Archive session', 'session.rename': 'Rename session', 'session.renameTitle': 'Rename session', 'session.renamePlaceholder': 'Enter a new session name', 'session.renameCancel': 'Cancel', 'session.renameConfirm': 'Save', 'session.renameEmpty': 'Session name cannot be empty', 'session.renameFailed': 'Rename failed', 'session.renamed': 'Session name updated',
1066
1080
  'notify.approvalTitle': 'Tool approval', 'notify.approvalBody': '{tool} needs approval', 'notify.questionTitle': 'DSH question', 'notify.questionBody': 'Needs your answer',
1067
1081
  'notify.permissionFailed': 'Notification permission failed: {msg}',
1068
1082
  'history.loading': 'Loading history…', 'history.cacheFallback': 'Network unavailable: showing cached history', 'history.loadFailed': 'Failed to load history: {msg}', 'history.retry': 'Retry',
@@ -1219,6 +1233,9 @@
1219
1233
  <script src="i18n.js"></script>
1220
1234
  <script src="theme.js"></script>
1221
1235
  <script src="md.js"></script>
1236
+ <script src="vendor/gsap/gsap.min.js"></script>
1237
+ <script src="motion.js"></script>
1238
+ <script type="module" src="morphicons-init.js"></script>
1222
1239
  <script src="app.js"></script>
1223
1240
  </body>
1224
1241
  </html>