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.
package/public/app.js CHANGED
@@ -64,6 +64,9 @@ const state = {
64
64
  workspaceFilter: LS.get('workspaceFilterV1', ''),
65
65
  byId: new Map(),
66
66
  current: null, // 当前打开的 sessionId
67
+ sessionRecovery: { status: 'idle', error: '' },
68
+ pendingProjections: new Map(),
69
+ lastStreamResyncAt: 0,
67
70
  hostInfo: null,
68
71
  localVersion: '',
69
72
  updateInfo: null,
@@ -1082,6 +1085,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1082
1085
  renderPending()
1083
1086
  }
1084
1087
  if (refreshOnOpen) refreshAll()
1088
+ if (allStreamsOpen()) resyncAfterStreamOpen()
1085
1089
  }
1086
1090
  ws.onmessage = (msg) => {
1087
1091
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1168,9 +1172,9 @@ async function pollKind(kind) {
1168
1172
  const since = state.pollSeq[kind] || 0
1169
1173
  let res
1170
1174
  try {
1171
- const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1175
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30000) : undefined
1172
1176
  const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
1173
- res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}`), { headers })
1177
+ res = signal ? await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { signal, headers }) : await fetch(apiUrl(`/api/events.poll?kind=${kind}&since=${since}&wait=25000`), { headers })
1174
1178
  } catch { return }
1175
1179
  if (res.status === 401) { authFailure(); return }
1176
1180
  if (!res.ok) return
@@ -1188,6 +1192,7 @@ async function pollKind(kind) {
1188
1192
  renderPending()
1189
1193
  }
1190
1194
  scheduleRefresh()
1195
+ if (state.current) void resyncCurrentSession()
1191
1196
  }
1192
1197
  for (const item of data.events) {
1193
1198
  if (item.seq > (state.pollSeq[kind] || 0)) {
@@ -1346,24 +1351,73 @@ async function refreshSessions() {
1346
1351
  }
1347
1352
  state.sessions = v.items || []
1348
1353
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1354
+ applyPendingProjections()
1349
1355
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1350
1356
  renderSessions()
1351
1357
  refreshWorkbench()
1352
1358
  }
1353
1359
 
1354
1360
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1361
+ function hydrateSessionProjections(sessionId, projections) {
1362
+ const s = state.byId.get(sessionId)
1363
+ if (!s || !projections || typeof projections !== 'object') return
1364
+ const incomingSeq = Number(projections.asOfSeq) || 0
1365
+ const current = s.projections || { asOfSeq: 0, values: {} }
1366
+ const currentSeq = Number(current.asOfSeq) || 0
1367
+ if (incomingSeq < currentSeq) return
1368
+ s.projections = {
1369
+ asOfSeq: Math.max(currentSeq, incomingSeq),
1370
+ values: { ...(current.values || {}), ...(projections.values || {}) }
1371
+ }
1372
+ }
1373
+ function applyPendingProjections() {
1374
+ for (const [sessionId, projections] of state.pendingProjections) {
1375
+ if (!state.byId.has(sessionId)) continue
1376
+ hydrateSessionProjections(sessionId, projections)
1377
+ state.pendingProjections.delete(sessionId)
1378
+ }
1379
+ }
1355
1380
  function applyProjection(sessionId, key, value, seq) {
1356
1381
  const s = state.byId.get(sessionId)
1357
- if (s) {
1358
- s.projections = s.projections || { asOfSeq: 0, values: {} }
1359
- s.projections.values = s.projections.values || {}
1360
- s.projections.values[key] = value
1361
- s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
1382
+ if (!s) {
1383
+ const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
1384
+ pending.values[key] = value
1385
+ pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
1386
+ state.pendingProjections.set(sessionId, pending)
1387
+ return
1362
1388
  }
1389
+ const currentSeq = Number(s.projections?.asOfSeq) || 0
1390
+ if (seq && seq < currentSeq) return
1391
+ s.projections = s.projections || { asOfSeq: 0, values: {} }
1392
+ s.projections.values = s.projections.values || {}
1393
+ s.projections.values[key] = value
1394
+ s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1363
1395
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
1364
1396
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
1365
1397
  else renderSessions()
1366
1398
  }
1399
+ function setSessionRecovery(status, error = '') {
1400
+ state.sessionRecovery = { status, error: String(error || '') }
1401
+ if (state.current) { renderSessionSub(); updateSessionStatus() }
1402
+ }
1403
+ function recoveryLabel() {
1404
+ const status = state.sessionRecovery.status
1405
+ if (status === 'loading' || status === 'resuming') return t('session.recovering')
1406
+ if (status === 'cached') return t('session.recoveryCached')
1407
+ if (status === 'error') return t('session.recoveryFailed')
1408
+ return ''
1409
+ }
1410
+ function resyncCurrentSession() {
1411
+ if (!state.current) return Promise.resolve()
1412
+ return loadHistory(true).then(() => {
1413
+ if (state.current) { renderSessionCards(); renderSessionSub(); updateCancelBtn(); updateSessionStatus() }
1414
+ })
1415
+ }
1416
+ function resyncAfterStreamOpen() {
1417
+ if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
1418
+ state.lastStreamResyncAt = Date.now()
1419
+ void refreshAll().then(() => resyncCurrentSession())
1420
+ }
1367
1421
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1368
1422
  function short(id) { return '…' + String(id).slice(-8) }
1369
1423
  function isTopLevelSession(session) {
@@ -1415,6 +1469,63 @@ const WORKSPACE_UNGROUPED = '__ungrouped__'
1415
1469
  function workspaceItems() {
1416
1470
  return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1417
1471
  }
1472
+ const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
1473
+ function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
1474
+ function workbenchOrderStore() {
1475
+ const value = cacheRead(WORKBENCH_ORDER_CACHE_KEY, {})
1476
+ if (!value || typeof value !== 'object') return { scopes: {} }
1477
+ if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
1478
+ return value
1479
+ }
1480
+ function workbenchOrderScopeValue() {
1481
+ const store = workbenchOrderStore()
1482
+ const key = workbenchOrderScope()
1483
+ if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
1484
+ return { store, value: store.scopes[key] }
1485
+ }
1486
+ function orderedItems(items, ids, getId) {
1487
+ const source = Array.isArray(items) ? items : []
1488
+ const byId = new Map(source.map(item => [String(getId(item)), item]))
1489
+ const result = []
1490
+ const used = new Set()
1491
+ for (const id of Array.isArray(ids) ? ids : []) {
1492
+ const key = String(id)
1493
+ const item = byId.get(key)
1494
+ if (item && !used.has(key)) { result.push(item); used.add(key) }
1495
+ }
1496
+ for (const item of source) {
1497
+ const key = String(getId(item))
1498
+ if (!used.has(key)) { result.push(item); used.add(key) }
1499
+ }
1500
+ return result
1501
+ }
1502
+ function orderedWorkspaceItems(items) {
1503
+ const { value } = workbenchOrderScopeValue()
1504
+ return orderedItems(items, value.workspaceIds, item => item.workspaceId)
1505
+ }
1506
+ function orderedWorkspaceSessions(workspaceId, items) {
1507
+ const { value } = workbenchOrderScopeValue()
1508
+ return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
1509
+ }
1510
+ function saveWorkbenchOrder(mutator) {
1511
+ const { store, value } = workbenchOrderScopeValue()
1512
+ mutator(value)
1513
+ cacheWrite(WORKBENCH_ORDER_CACHE_KEY, store)
1514
+ }
1515
+ function commitWorkspaceOrder(order) {
1516
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1517
+ renderWorkbench()
1518
+ toast(t('wb.orderSaved'), 'ok')
1519
+ }
1520
+ function commitWorkspaceSessionOrder(workspaceId, order) {
1521
+ if (!workspaceId) return
1522
+ saveWorkbenchOrder(value => {
1523
+ value.sessionIds ||= {}
1524
+ value.sessionIds[String(workspaceId)] = order.map(String)
1525
+ })
1526
+ renderWorkbench()
1527
+ toast(t('wb.orderSaved'), 'ok')
1528
+ }
1418
1529
  function workspaceById(workspaceId) {
1419
1530
  return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1420
1531
  }
@@ -1541,32 +1652,48 @@ function renderWorkbench() {
1541
1652
  toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
1542
1653
  panel.classList.toggle('hidden', !state.wbOpen)
1543
1654
  if (!state.wbOpen) { panel.innerHTML = ''; return }
1544
- const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
1655
+ const projects = orderedWorkspaceItems(state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot())))
1545
1656
  if (!projects.length) {
1546
1657
  panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1547
1658
  return
1548
1659
  }
1549
1660
  const archivedSet = new Set(state.wbArchived || [])
1550
- panel.innerHTML = projects.map(w => {
1661
+ const projectHtml = projects.map(w => {
1551
1662
  const id = String(w.workspaceId || '')
1552
1663
  const open = !!state.wbOpenProjects[id]
1553
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId))
1664
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
1554
1665
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1555
1666
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1556
- <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1667
+ <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
1668
+ <span class="wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1557
1669
  <span class="wb-session-title">${esc(titleOf(s))}</span>
1558
1670
  <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
1559
1671
  </button>
1560
1672
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1561
1673
  </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1562
- return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1674
+ return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
1563
1675
  <div class="wb-project-head">
1676
+ <span class="wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1564
1677
  <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
1565
1678
  <span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
1566
1679
  <button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
1567
1680
  </div>${body}
1568
1681
  </div>`
1569
1682
  }).join('')
1683
+ if (window.DshMotion?.relayout) {
1684
+ window.DshMotion.relayout(panel, '.wb-project', () => { panel.innerHTML = projectHtml })
1685
+ } else panel.innerHTML = projectHtml
1686
+ window.DshMotion?.list(panel, '.wb-session')
1687
+ window.DshMotion?.bindLongPressReorder(panel, '.wb-project', {
1688
+ handleSelector: '.wb-project-head',
1689
+ excludeSelector: '[data-wb-new]',
1690
+ onCommit: ({ order }) => commitWorkspaceOrder(order)
1691
+ })
1692
+ window.DshMotion?.bindLongPressReorder(panel, '.session-swipe', {
1693
+ groupSelector: '.wb-project',
1694
+ handleSelector: '.wb-session',
1695
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
1696
+ })
1570
1697
  }
1571
1698
 
1572
1699
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
@@ -1594,6 +1721,16 @@ function noteSessionTurnTime(sessionId, eventOrTime) {
1594
1721
  if (!sessionId || !Number.isFinite(time)) return
1595
1722
  state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
1596
1723
  }
1724
+ function sessionWorkspaceOrderKey(s) {
1725
+ const workspace = workspaceForSession(s)
1726
+ return String(workspace?.workspaceId || 'path:' + (sessionWorkspaceLabel(s) || WORKSPACE_UNGROUPED))
1727
+ }
1728
+ function commitWorkspaceGroupOrder(order) {
1729
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1730
+ renderSessions()
1731
+ renderWorkbench()
1732
+ toast(t('wb.orderSaved'), 'ok')
1733
+ }
1597
1734
  function sortedSessions() {
1598
1735
  const items = topLevelSessions()
1599
1736
  if (state.sessionSort === 'workspace') {
@@ -1618,16 +1755,9 @@ function renderSessions() {
1618
1755
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1619
1756
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1620
1757
  const showArchived = LS.get('showArchivedV1', '0') === '1'
1621
- const renderItems = (items) => {
1622
- let lastWorkspace = null
1623
- const rows = []
1624
- for (const s of items) {
1758
+ const renderSession = s => {
1625
1759
  const workspace = sessionWorkspaceLabel(s)
1626
1760
  const workspaceTitle = sessionWorkspaceName(s)
1627
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1628
- rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceTitle)}</span></div>`)
1629
- lastWorkspace = workspace
1630
- }
1631
1761
  const title = titleOf(s)
1632
1762
  const goal = goalOf(s)
1633
1763
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
@@ -1638,7 +1768,7 @@ function renderSessions() {
1638
1768
  const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1639
1769
  const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1640
1770
  const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
1641
- rows.push(`<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1771
+ return `<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1642
1772
  <div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
1643
1773
  <div class="sc-title">${esc(title)}</div>
1644
1774
  <div class="sc-meta">
@@ -1651,13 +1781,42 @@ function renderSessions() {
1651
1781
  <span class="sc-arrow">›</span>
1652
1782
  </div>
1653
1783
  ${archiveButton}
1654
- </div>`)
1784
+ </div>`
1785
+ }
1786
+ const renderItems = (items) => {
1787
+ if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
1788
+ const groups = []
1789
+ const byKey = new Map()
1790
+ for (const session of items) {
1791
+ const key = sessionWorkspaceOrderKey(session)
1792
+ let group = byKey.get(key)
1793
+ if (!group) {
1794
+ group = { key, label: sessionWorkspaceName(session), path: sessionWorkspaceLabel(session), items: [] }
1795
+ byKey.set(key, group)
1796
+ groups.push(group)
1797
+ }
1798
+ group.items.push(session)
1655
1799
  }
1656
- return rows.join('')
1800
+ const { value } = workbenchOrderScopeValue()
1801
+ return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
1802
+ <div class="session-group-label" data-reorder-handle title="${esc(group.path)}"><span class="session-group-drag-handle" aria-hidden="true">⠿</span><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(group.label)}</span></div>
1803
+ ${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
1804
+ </div>`).join('')
1657
1805
  }
1658
1806
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1659
1807
  const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1660
- list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
1808
+ const renderList = () => { list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>` }
1809
+ if (window.DshMotion?.relayout) window.DshMotion.relayout(list, '.session-swipe', renderList)
1810
+ else renderList()
1811
+ window.DshMotion?.bindLongPressReorder(list, '.session-workspace-group', {
1812
+ handleSelector: '.session-group-label',
1813
+ onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
1814
+ })
1815
+ window.DshMotion?.bindLongPressReorder(list, '.session-swipe', {
1816
+ groupSelector: '.session-workspace-group',
1817
+ handleSelector: '.session-card',
1818
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
1819
+ })
1661
1820
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1662
1821
  const sort = $('session-sort')
1663
1822
  if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
@@ -1674,9 +1833,12 @@ function renderSessions() {
1674
1833
  /* ---------------- 会话详情 ---------------- */
1675
1834
  async function openSession(id) {
1676
1835
  state.current = id
1836
+ setSessionRecovery('loading')
1677
1837
  state.history = emptyHistory()
1678
1838
  document.body.classList.add('in-session')
1679
1839
  showView('view-session')
1840
+ $('btn-rename-session').classList.remove('hidden')
1841
+ $('btn-archive-session').classList.toggle('hidden', (state.wbArchived || []).includes(id))
1680
1842
  $('session-cards').innerHTML = ''
1681
1843
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1682
1844
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
@@ -1691,6 +1853,9 @@ function closeSession() {
1691
1853
  setComposerFullscreen(false)
1692
1854
  clearComposerImages()
1693
1855
  state.current = null
1856
+ setSessionRecovery('idle')
1857
+ $('btn-rename-session').classList.add('hidden')
1858
+ $('btn-archive-session').classList.add('hidden')
1694
1859
  state.history = emptyHistory()
1695
1860
  document.body.classList.remove('in-session')
1696
1861
  hideComposerMenu()
@@ -1705,7 +1870,7 @@ function bindNativeBack() {
1705
1870
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1706
1871
  if (customSelectCurrent) { closeCustomSelect(); return }
1707
1872
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1708
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
1873
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else if (openModal.id === 'modal-rename') closeRenameSession(); else if (openModal.id === 'modal-app-version-warning') closeAppVersionWarning(false); else if (openModal.id === 'modal-scan-live') closeLiveScan(''); else openModal.classList.add('hidden'); return } // 先关弹窗
1709
1874
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1710
1875
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1711
1876
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1728,6 +1893,8 @@ function renderSessionSub() {
1728
1893
  if (s.cwd) parts.push(s.cwd)
1729
1894
  if (s.running) parts.push(t('session.running'))
1730
1895
  else if (s.error) parts.push(t('session.interrupted'))
1896
+ const recovery = recoveryLabel()
1897
+ if (recovery) parts.push(recovery)
1731
1898
  $('session-sub').textContent = parts.join(' · ')
1732
1899
  }
1733
1900
 
@@ -1867,6 +2034,7 @@ function restoreCachedHistory() {
1867
2034
  if (!id) return false
1868
2035
  const cached = readHistoryCache()[id]
1869
2036
  if (!cached?.events?.length) return false
2037
+ if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
1870
2038
  const h = emptyHistory()
1871
2039
  for (const e of cached.events) {
1872
2040
  if (e?.seq == null) continue
@@ -1884,6 +2052,7 @@ async function loadHistory(reset) {
1884
2052
  const id = state.current
1885
2053
  if (!id || state.history.loading) return
1886
2054
  state.history.loading = true
2055
+ if (reset) setSessionRecovery('loading')
1887
2056
  const moreBtn = $('history-more')
1888
2057
  if (moreBtn) moreBtn.classList.add('hidden')
1889
2058
  const payload = { sessionId: id, maxMessages: 60 }
@@ -1896,10 +2065,12 @@ async function loadHistory(reset) {
1896
2065
  state.history.loading = false
1897
2066
  if (e.message === 'AUTH') { authFailure(); return }
1898
2067
  if (restoreCachedHistory()) {
2068
+ setSessionRecovery('cached', e.message)
1899
2069
  toast(t('history.cacheFallback'), 'ok')
1900
2070
  return
1901
2071
  }
1902
2072
  const msg = e.message || t('err.dshError')
2073
+ setSessionRecovery('error', msg)
1903
2074
  const box = $('history')
1904
2075
  if (box && (reset || !state.history.visible.length)) {
1905
2076
  box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
@@ -1911,6 +2082,7 @@ async function loadHistory(reset) {
1911
2082
  return
1912
2083
  }
1913
2084
 
2085
+ hydrateSessionProjections(id, v.projections)
1914
2086
  const incoming = v.events || []
1915
2087
  let added = 0
1916
2088
  if (reset) state.history.partialReasoning.clear()
@@ -1932,6 +2104,8 @@ async function loadHistory(reset) {
1932
2104
  trimVisible()
1933
2105
  state.history.hasMore = !!v.hasMore
1934
2106
  state.history.loading = false
2107
+ setSessionRecovery('ready')
2108
+ renderSessionTitle(); renderSessionSub(); renderSessionCards()
1935
2109
  try {
1936
2110
  if (reset) renderHistory(true)
1937
2111
  else if (added) renderHistory(false, 'keep')
@@ -2249,10 +2423,15 @@ async function renderSessionCards() {
2249
2423
  const running = e.activity === 'running'
2250
2424
  return `<div class="card-row"><span class="k">${running ? '▶ ' : ''}${esc(label)}</span><span class="v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
2251
2425
  }).join('')
2252
- box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="subagent-toggle-icon" aria-hidden="true">${expanded ? '⌃' : '⌄'}</span></button><div class="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
2426
+ const subagentClosedIcon = 'M7 10l5 5 5-5'
2427
+ const subagentOpenIcon = 'M7 14l5-5 5 5'
2428
+ const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
2429
+ box.insertAdjacentHTML('beforeend', `<div class="card subagent-card"><button type="button" class="subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="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="subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
2253
2430
  box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
2431
+ const icon = box.querySelector('[data-subagent-toggle] morph-icon')
2432
+ if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
2254
2433
  state.subagentExpandedSession = expanded ? '' : sessionId
2255
- renderSessionCards()
2434
+ setTimeout(() => renderSessionCards(), 240)
2256
2435
  })
2257
2436
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
2258
2437
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
@@ -2428,20 +2607,24 @@ async function sendSessionContent(text, images) {
2428
2607
  try {
2429
2608
  const content = [...await encodeComposerImagesFor(images)]
2430
2609
  if (clean) content.push({ type: 'text', text: clean })
2610
+ setSessionRecovery('resuming')
2431
2611
  const v = await safeRpc('session.prompt', {
2432
2612
  sessionId: state.current,
2433
2613
  mode: 'queue',
2434
2614
  content
2435
2615
  }, t('send.failed'))
2436
2616
  if (v?.accepted) {
2617
+ setSessionRecovery('ready')
2437
2618
  noteSessionTurnTime(state.current, Date.now())
2438
2619
  renderSessions()
2439
2620
  toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
2440
2621
  return true
2441
2622
  }
2442
2623
  if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2624
+ setSessionRecovery('error')
2443
2625
  return false
2444
2626
  } catch (e) {
2627
+ setSessionRecovery('error', e?.message)
2445
2628
  toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2446
2629
  return false
2447
2630
  } finally {
@@ -2603,7 +2786,41 @@ async function cancelSession() {
2603
2786
  if (!state.current) return
2604
2787
  if (!confirm(t('session.confirmStop'))) return
2605
2788
  const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
2606
- if (v?.accepted) toast(t('session.stopRequested'), 'ok')
2789
+ if (v?.accepted) { setSessionRecovery('ready'); toast(t('session.stopRequested'), 'ok') }
2790
+ }
2791
+
2792
+ let renamePendingSessionId = null
2793
+ function renameSession(sessionId = state.current) {
2794
+ const session = state.byId.get(sessionId)
2795
+ if (!session) return
2796
+ renamePendingSessionId = sessionId
2797
+ $('rename-session-input').value = titleOf(session)
2798
+ $('modal-rename').classList.remove('hidden')
2799
+ setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
2800
+ }
2801
+ function closeRenameSession() {
2802
+ renamePendingSessionId = null
2803
+ $('modal-rename').classList.add('hidden')
2804
+ }
2805
+ async function confirmRenameSession() {
2806
+ const sessionId = renamePendingSessionId
2807
+ if (!sessionId) return
2808
+ const title = $('rename-session-input').value.trim()
2809
+ if (!title) return toast(t('session.renameEmpty'), 'err')
2810
+ const button = $('rename-confirm')
2811
+ button.disabled = true
2812
+ setSessionRecovery('resuming')
2813
+ try {
2814
+ const value = await safeRpc('session.rename', { sessionId, title }, t('session.renameFailed'))
2815
+ if (value == null) { setSessionRecovery('error'); return }
2816
+ if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
2817
+ closeRenameSession()
2818
+ setSessionRecovery('ready')
2819
+ toast(t('session.renamed'), 'ok')
2820
+ await refreshSessions()
2821
+ } finally {
2822
+ button.disabled = false
2823
+ }
2607
2824
  }
2608
2825
 
2609
2826
  async function newSession() {
@@ -2673,6 +2890,7 @@ async function confirmArchiveSession() {
2673
2890
  closeArchiveConfirm()
2674
2891
  toast(t('session.archived'), 'ok')
2675
2892
  await refreshSessions()
2893
+ if (state.current === sessionId) closeSession()
2676
2894
  } finally {
2677
2895
  button.disabled = false
2678
2896
  }
@@ -3346,6 +3564,8 @@ async function runFsUpload(up) {
3346
3564
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
3347
3565
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
3348
3566
  if (CLIENT_ID) xhr.setRequestHeader('x-dsh-remote-client-id', CLIENT_ID)
3567
+ if (params.offset != null) xhr.setRequestHeader('Upload-Offset', String(params.offset))
3568
+ if (params.size != null) xhr.setRequestHeader('Upload-Length', String(params.size))
3349
3569
  xhr.upload.onprogress = (e) => {
3350
3570
  if (e.lengthComputable) {
3351
3571
  const loaded = up.offset + Math.min(e.loaded, e.total)
@@ -3370,7 +3590,7 @@ async function runFsUpload(up) {
3370
3590
  })
3371
3591
 
3372
3592
  const probe = async () => {
3373
- const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session }), { headers: fsHeaders() })
3593
+ const res = await fetch(fsApiUrl('/upload-probe', { path: up.path, name: up.name, session: up.session, size: String(up.size) }), { headers: fsHeaders() })
3374
3594
  if (res.status === 401) { fsAuthError(401); return null }
3375
3595
  const json = await res.json().catch(() => ({}))
3376
3596
  if (json.ok) up.offset = json.partialSize || 0
@@ -3402,7 +3622,7 @@ async function runFsUpload(up) {
3402
3622
  }
3403
3623
  hasher.update(chunkBytes)
3404
3624
  const isLast = end >= up.size
3405
- const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset) }
3625
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size) }
3406
3626
  if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
3407
3627
  if (overwrite) params.overwrite = '1'
3408
3628
  const r = await uploadChunk(params, blob)
@@ -3444,7 +3664,7 @@ async function runFsUpload(up) {
3444
3664
  // 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
3445
3665
  if (up.offset >= up.size) {
3446
3666
  const expected = hasher.hex()
3447
- const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), finish: '1', sha256: expected }
3667
+ const params = { path: up.path, name: up.name, session: up.session, offset: String(up.offset), size: String(up.size), finish: '1', sha256: expected }
3448
3668
  if (overwrite) params.overwrite = '1'
3449
3669
  const r = await uploadChunk(params, new Blob([]))
3450
3670
  if (r.status === 401) { fsAuthError(401); return }
@@ -4264,7 +4484,7 @@ function saveBgConfig(enabled) {
4264
4484
  const b = bgBridge()
4265
4485
  if (!b?.saveBackgroundConfig) return false
4266
4486
  const base = bgBase()
4267
- const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
4487
+ const intervalMin = parseFloat($('bg-interval')?.value || '0.5') || 0.5
4268
4488
  const notifyTaskDone = $('opt-task-done')?.checked !== false
4269
4489
  b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
4270
4490
  if (enabled) $('bg-auth-status')?.classList.add('hidden')
@@ -4397,6 +4617,7 @@ function showView(id) {
4397
4617
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
4398
4618
  document.body.classList.toggle('in-session', id === 'view-session')
4399
4619
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
4620
+ window.DshMotion?.view($(id))
4400
4621
  window.scrollTo(0, 0)
4401
4622
  if (id === 'view-files' && !state.fs.loaded) {
4402
4623
  const workspace = workspaceById(state.fs.workspaceId)
@@ -4485,8 +4706,7 @@ function updateComposerFullscreenButton() {
4485
4706
  const shouldShow = active || input.scrollHeight > 120
4486
4707
  button.classList.toggle('hidden', !shouldShow)
4487
4708
  $('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
4488
- $('fs-ico-expand')?.classList.toggle('hidden', active)
4489
- $('fs-ico-collapse')?.classList.toggle('hidden', !active)
4709
+ $('fs-ico')?.setAttribute('data-morph-state', active ? 'open' : 'closed')
4490
4710
  button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
4491
4711
  button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
4492
4712
  }
@@ -5275,6 +5495,12 @@ function bindUi() {
5275
5495
  })
5276
5496
  $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
5277
5497
  $('btn-cancel').addEventListener('click', cancelSession)
5498
+ $('btn-rename-session').addEventListener('click', () => renameSession())
5499
+ $('btn-archive-session').addEventListener('click', () => archiveSession(state.current))
5500
+ $('rename-cancel').addEventListener('click', closeRenameSession)
5501
+ $('rename-confirm').addEventListener('click', confirmRenameSession)
5502
+ $('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
5503
+ $('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
5278
5504
  $('btn-send').addEventListener('click', sendMessage)
5279
5505
  $('btn-fs-send').addEventListener('click', sendMessage)
5280
5506
  $('btn-plus').addEventListener('click', toggleComposerMenu)