dsh-remote-plugin 0.6.13 → 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
@@ -13,14 +13,16 @@ const LS = {
13
13
  }
14
14
  const CLIENT_ID = (() => {
15
15
  try {
16
- let id = sessionStorage.getItem('dshRemoteClientId')
16
+ const key = 'dshRemoteClientIdV2'
17
+ let id = localStorage.getItem(key)
17
18
  if (!id) {
18
19
  id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
19
- sessionStorage.setItem('dshRemoteClientId', id)
20
+ localStorage.setItem(key, id)
20
21
  }
21
22
  return id
22
23
  } catch { return '' }
23
24
  })()
25
+ function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIENT_ID } : {} }
24
26
 
25
27
  /* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
26
28
  const CACHE = {
@@ -62,6 +64,9 @@ const state = {
62
64
  workspaceFilter: LS.get('workspaceFilterV1', ''),
63
65
  byId: new Map(),
64
66
  current: null, // 当前打开的 sessionId
67
+ sessionRecovery: { status: 'idle', error: '' },
68
+ pendingProjections: new Map(),
69
+ lastStreamResyncAt: 0,
65
70
  hostInfo: null,
66
71
  localVersion: '',
67
72
  updateInfo: null,
@@ -71,6 +76,8 @@ const state = {
71
76
  approvals: [], // 待处理审批
72
77
  questions: [], // 待处理提问
73
78
  queues: {}, // sessionId -> queue items
79
+ queueSteering: {}, // sessionId:itemId -> pending steer request
80
+ sessionTurnTimes: {}, // sessionId -> 本轮开始/结束时间,避免中间事件推动排序
74
81
  jobs: {}, // sessionId -> jobs
75
82
  history: emptyHistory(),
76
83
  errCount: 0,
@@ -88,7 +95,8 @@ const state = {
88
95
  wbProjects: [],
89
96
  wbArchived: [],
90
97
  wbOpen: false,
91
- wbOpenProjects: {}
98
+ wbOpenProjects: {},
99
+ subagentExpandedSession: ''
92
100
  }
93
101
 
94
102
  const $ = (id) => document.getElementById(id)
@@ -363,6 +371,7 @@ async function getWsTicket() {
363
371
  headers: {
364
372
  authorization: 'Bearer ' + token,
365
373
  'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
374
+ ...clientIdHeaders(),
366
375
  }
367
376
  })
368
377
  if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
@@ -414,7 +423,7 @@ async function loadStats() {
414
423
  }
415
424
  try {
416
425
  const res = await fetch(apiUrl('/stats/summary?days=7'), {
417
- headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
426
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
418
427
  })
419
428
  if (res.status === 401) { authFailure(); return }
420
429
  if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -480,7 +489,7 @@ function renderStats(days) {
480
489
  async function rpc(method, payload = {}, timeoutMs = 45000) {
481
490
  const opts = {
482
491
  method: 'POST',
483
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
492
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
484
493
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
485
494
  }
486
495
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -501,7 +510,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
501
510
  async function respond(rpcId, value) {
502
511
  const opts = {
503
512
  method: 'POST',
504
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
513
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
505
514
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
506
515
  }
507
516
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -1076,6 +1085,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1076
1085
  renderPending()
1077
1086
  }
1078
1087
  if (refreshOnOpen) refreshAll()
1088
+ if (allStreamsOpen()) resyncAfterStreamOpen()
1079
1089
  }
1080
1090
  ws.onmessage = (msg) => {
1081
1091
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1162,9 +1172,9 @@ async function pollKind(kind) {
1162
1172
  const since = state.pollSeq[kind] || 0
1163
1173
  let res
1164
1174
  try {
1165
- const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1166
- const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1167
- 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 })
1175
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(30000) : undefined
1176
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
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 })
1168
1178
  } catch { return }
1169
1179
  if (res.status === 401) { authFailure(); return }
1170
1180
  if (!res.ok) return
@@ -1182,6 +1192,7 @@ async function pollKind(kind) {
1182
1192
  renderPending()
1183
1193
  }
1184
1194
  scheduleRefresh()
1195
+ if (state.current) void resyncCurrentSession()
1185
1196
  }
1186
1197
  for (const item of data.events) {
1187
1198
  if (item.seq > (state.pollSeq[kind] || 0)) {
@@ -1295,7 +1306,10 @@ function onHostFrame(full) {
1295
1306
  function onSessionEvent(sessionId, event) {
1296
1307
  if (!event) return
1297
1308
  const s = state.byId.get(sessionId)
1298
- if (s) s.updatedAt = Date.now()
1309
+ if (event.type === 'turn/start' || event.type === 'turn/end') {
1310
+ noteSessionTurnTime(sessionId, event)
1311
+ renderSessions()
1312
+ }
1299
1313
  if (event.type === 'agent/status') {
1300
1314
  if (s) { s.running = !!event.data?.running; s.blank = false; if (s.running) s.error = false }
1301
1315
  if (state.current === sessionId) { updateCancelBtn(); renderSessionSub(); updateSessionStatus() }
@@ -1337,24 +1351,73 @@ async function refreshSessions() {
1337
1351
  }
1338
1352
  state.sessions = v.items || []
1339
1353
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1354
+ applyPendingProjections()
1340
1355
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1341
1356
  renderSessions()
1342
1357
  refreshWorkbench()
1343
1358
  }
1344
1359
 
1345
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
+ }
1346
1380
  function applyProjection(sessionId, key, value, seq) {
1347
1381
  const s = state.byId.get(sessionId)
1348
- if (s) {
1349
- s.projections = s.projections || { asOfSeq: 0, values: {} }
1350
- s.projections.values = s.projections.values || {}
1351
- s.projections.values[key] = value
1352
- 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
1353
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)
1354
1395
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
1355
1396
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
1356
1397
  else renderSessions()
1357
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
+ }
1358
1421
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1359
1422
  function short(id) { return '…' + String(id).slice(-8) }
1360
1423
  function isTopLevelSession(session) {
@@ -1406,6 +1469,63 @@ const WORKSPACE_UNGROUPED = '__ungrouped__'
1406
1469
  function workspaceItems() {
1407
1470
  return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1408
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
+ }
1409
1529
  function workspaceById(workspaceId) {
1410
1530
  return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1411
1531
  }
@@ -1469,7 +1589,7 @@ async function refreshWorkbench() {
1469
1589
  if (!state.token) return
1470
1590
  try {
1471
1591
  const res = await fetch(apiUrl('/workbench'), {
1472
- headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1592
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() }
1473
1593
  })
1474
1594
  if (res.ok) {
1475
1595
  const value = await res.json().catch(() => null)
@@ -1532,32 +1652,48 @@ function renderWorkbench() {
1532
1652
  toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
1533
1653
  panel.classList.toggle('hidden', !state.wbOpen)
1534
1654
  if (!state.wbOpen) { panel.innerHTML = ''; return }
1535
- const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
1655
+ const projects = orderedWorkspaceItems(state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot())))
1536
1656
  if (!projects.length) {
1537
1657
  panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1538
1658
  return
1539
1659
  }
1540
1660
  const archivedSet = new Set(state.wbArchived || [])
1541
- panel.innerHTML = projects.map(w => {
1661
+ const projectHtml = projects.map(w => {
1542
1662
  const id = String(w.workspaceId || '')
1543
1663
  const open = !!state.wbOpenProjects[id]
1544
- 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)))
1545
1665
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1546
1666
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1547
- <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>
1548
1669
  <span class="wb-session-title">${esc(titleOf(s))}</span>
1549
- <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1670
+ <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(sessionSortTime(s)))}</span>
1550
1671
  </button>
1551
1672
  <button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>
1552
1673
  </div>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1553
- 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)}">
1554
1675
  <div class="wb-project-head">
1676
+ <span class="wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1555
1677
  <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
1556
1678
  <span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
1557
1679
  <button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
1558
1680
  </div>${body}
1559
1681
  </div>`
1560
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
+ })
1561
1697
  }
1562
1698
 
1563
1699
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
@@ -1576,6 +1712,25 @@ function workspaceDisplayName(label) {
1576
1712
  const parts = clean.split(/[\\/]/).filter(Boolean)
1577
1713
  return parts[parts.length - 1] || value
1578
1714
  }
1715
+ function sessionSortTime(s) {
1716
+ return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
1717
+ }
1718
+ function noteSessionTurnTime(sessionId, eventOrTime) {
1719
+ const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
1720
+ const time = Number(raw) > 0 ? Number(raw) : Date.now()
1721
+ if (!sessionId || !Number.isFinite(time)) return
1722
+ state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
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
+ }
1579
1734
  function sortedSessions() {
1580
1735
  const items = topLevelSessions()
1581
1736
  if (state.sessionSort === 'workspace') {
@@ -1583,10 +1738,10 @@ function sortedSessions() {
1583
1738
  const aw = sessionCwd(a) || '\uffff'
1584
1739
  const bw = sessionCwd(b) || '\uffff'
1585
1740
  const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1586
- return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1741
+ return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
1587
1742
  })
1588
1743
  }
1589
- return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1744
+ return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1590
1745
  }
1591
1746
  function renderSessions() {
1592
1747
  const list = $('session-list')
@@ -1600,16 +1755,9 @@ function renderSessions() {
1600
1755
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1601
1756
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1602
1757
  const showArchived = LS.get('showArchivedV1', '0') === '1'
1603
- const renderItems = (items) => {
1604
- let lastWorkspace = null
1605
- const rows = []
1606
- for (const s of items) {
1758
+ const renderSession = s => {
1607
1759
  const workspace = sessionWorkspaceLabel(s)
1608
1760
  const workspaceTitle = sessionWorkspaceName(s)
1609
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1610
- 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>`)
1611
- lastWorkspace = workspace
1612
- }
1613
1761
  const title = titleOf(s)
1614
1762
  const goal = goalOf(s)
1615
1763
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
@@ -1620,12 +1768,12 @@ function renderSessions() {
1620
1768
  const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1621
1769
  const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1622
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>`
1623
- 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)}">
1624
1772
  <div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
1625
1773
  <div class="sc-title">${esc(title)}</div>
1626
1774
  <div class="sc-meta">
1627
1775
  <span class="sc-dot ${dots.join(' ')}"></span>
1628
- <span>${fmtTime(s.updatedAt)}</span>
1776
+ <span>${fmtTime(sessionSortTime(s))}</span>
1629
1777
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1630
1778
  ${badge}${queueBadge}
1631
1779
  </div>
@@ -1633,13 +1781,42 @@ function renderSessions() {
1633
1781
  <span class="sc-arrow">›</span>
1634
1782
  </div>
1635
1783
  ${archiveButton}
1636
- </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)
1637
1799
  }
1638
- 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('')
1639
1805
  }
1640
1806
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1641
1807
  const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1642
- 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
+ })
1643
1820
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1644
1821
  const sort = $('session-sort')
1645
1822
  if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
@@ -1656,12 +1833,16 @@ function renderSessions() {
1656
1833
  /* ---------------- 会话详情 ---------------- */
1657
1834
  async function openSession(id) {
1658
1835
  state.current = id
1836
+ setSessionRecovery('loading')
1659
1837
  state.history = emptyHistory()
1660
1838
  document.body.classList.add('in-session')
1661
1839
  showView('view-session')
1840
+ $('btn-rename-session').classList.remove('hidden')
1841
+ $('btn-archive-session').classList.toggle('hidden', (state.wbArchived || []).includes(id))
1662
1842
  $('session-cards').innerHTML = ''
1663
1843
  renderSessionTitle(); renderSessionSub(); updateCancelBtn(); updateSessionStatus()
1664
1844
  $('history').innerHTML = '<div class="empty">' + t('history.loading') + '</div>'
1845
+ renderQueue()
1665
1846
  restoreCachedHistory()
1666
1847
  await loadHistory(true)
1667
1848
  renderSessionCards()
@@ -1672,6 +1853,9 @@ function closeSession() {
1672
1853
  setComposerFullscreen(false)
1673
1854
  clearComposerImages()
1674
1855
  state.current = null
1856
+ setSessionRecovery('idle')
1857
+ $('btn-rename-session').classList.add('hidden')
1858
+ $('btn-archive-session').classList.add('hidden')
1675
1859
  state.history = emptyHistory()
1676
1860
  document.body.classList.remove('in-session')
1677
1861
  hideComposerMenu()
@@ -1686,7 +1870,7 @@ function bindNativeBack() {
1686
1870
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1687
1871
  if (customSelectCurrent) { closeCustomSelect(); return }
1688
1872
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1689
- 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 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 } // 先关弹窗
1690
1874
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1691
1875
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1692
1876
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1709,6 +1893,8 @@ function renderSessionSub() {
1709
1893
  if (s.cwd) parts.push(s.cwd)
1710
1894
  if (s.running) parts.push(t('session.running'))
1711
1895
  else if (s.error) parts.push(t('session.interrupted'))
1896
+ const recovery = recoveryLabel()
1897
+ if (recovery) parts.push(recovery)
1712
1898
  $('session-sub').textContent = parts.join(' · ')
1713
1899
  }
1714
1900
 
@@ -1717,6 +1903,8 @@ function updateSessionStatus() {
1717
1903
  const s = state.byId.get(state.current)
1718
1904
  const head = $('session-head')
1719
1905
  if (!head) return
1906
+ const composerStatus = $('composer-status')
1907
+ if (composerStatus) composerStatus.classList.toggle('hidden', !s?.running)
1720
1908
  head.classList.remove('running', 'interrupted')
1721
1909
  const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
1722
1910
  if (s?.running || queued) head.classList.add('running')
@@ -1846,6 +2034,7 @@ function restoreCachedHistory() {
1846
2034
  if (!id) return false
1847
2035
  const cached = readHistoryCache()[id]
1848
2036
  if (!cached?.events?.length) return false
2037
+ if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
1849
2038
  const h = emptyHistory()
1850
2039
  for (const e of cached.events) {
1851
2040
  if (e?.seq == null) continue
@@ -1863,6 +2052,7 @@ async function loadHistory(reset) {
1863
2052
  const id = state.current
1864
2053
  if (!id || state.history.loading) return
1865
2054
  state.history.loading = true
2055
+ if (reset) setSessionRecovery('loading')
1866
2056
  const moreBtn = $('history-more')
1867
2057
  if (moreBtn) moreBtn.classList.add('hidden')
1868
2058
  const payload = { sessionId: id, maxMessages: 60 }
@@ -1875,10 +2065,12 @@ async function loadHistory(reset) {
1875
2065
  state.history.loading = false
1876
2066
  if (e.message === 'AUTH') { authFailure(); return }
1877
2067
  if (restoreCachedHistory()) {
2068
+ setSessionRecovery('cached', e.message)
1878
2069
  toast(t('history.cacheFallback'), 'ok')
1879
2070
  return
1880
2071
  }
1881
2072
  const msg = e.message || t('err.dshError')
2073
+ setSessionRecovery('error', msg)
1882
2074
  const box = $('history')
1883
2075
  if (box && (reset || !state.history.visible.length)) {
1884
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>`
@@ -1890,15 +2082,17 @@ async function loadHistory(reset) {
1890
2082
  return
1891
2083
  }
1892
2084
 
2085
+ hydrateSessionProjections(id, v.projections)
1893
2086
  const incoming = v.events || []
1894
2087
  let added = 0
1895
2088
  if (reset) state.history.partialReasoning.clear()
1896
2089
  for (const entry of incoming) {
1897
2090
  const ev = entry?.event
1898
2091
  const seq = ev?.seq
2092
+ if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1899
2093
  applyReasoningStreamEvent(ev)
1900
2094
  if (seq == null || state.history.seqs.has(seq)) continue
1901
- if (!shouldShowEvent(ev.type)) continue // chunk 等内部事件不保留
2095
+ if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
1902
2096
  state.history.seqs.add(seq)
1903
2097
  state.history.visible.push({ seq, event: ev, view: entry.view })
1904
2098
  added++
@@ -1910,6 +2104,8 @@ async function loadHistory(reset) {
1910
2104
  trimVisible()
1911
2105
  state.history.hasMore = !!v.hasMore
1912
2106
  state.history.loading = false
2107
+ setSessionRecovery('ready')
2108
+ renderSessionTitle(); renderSessionSub(); renderSessionCards()
1913
2109
  try {
1914
2110
  if (reset) renderHistory(true)
1915
2111
  else if (added) renderHistory(false, 'keep')
@@ -1929,7 +2125,7 @@ function insertLiveEvent(event) {
1929
2125
  return
1930
2126
  }
1931
2127
  const seq = event?.seq
1932
- if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type)) {
2128
+ if (seq == null || h.seqs.has(seq) || !shouldShowEvent(event.type, event)) {
1933
2129
  if (reasoningChanged) scheduleReasoningRender()
1934
2130
  return
1935
2131
  }
@@ -2069,9 +2265,24 @@ const INTERESTING_EVENTS = new Set([
2069
2265
  'approval/asked', 'approval/resolved',
2070
2266
  'session/title', 'title'
2071
2267
  ])
2072
- function shouldShowEvent(type) {
2073
- if (INTERESTING_EVENTS.has(type)) return true
2074
- return false
2268
+ function messageSource(data) {
2269
+ const source = data?.source ?? data?.message?.source
2270
+ return source && typeof source === 'object' ? source : null
2271
+ }
2272
+ function isHumanUserMessage(event) {
2273
+ if (event?.type !== 'user/message') return false
2274
+ const source = messageSource(event.data || {})
2275
+ // Older DSH events may not carry source metadata; keep those visible for compatibility.
2276
+ return !source || source.kind === 'user'
2277
+ }
2278
+ function shouldShowEvent(type, event) {
2279
+ if (!INTERESTING_EVENTS.has(type)) return false
2280
+ if (type === 'user/message' && !isHumanUserMessage(event)) {
2281
+ const data = event?.data || {}
2282
+ const blocks = data.message?.content || data.content || []
2283
+ return systemReminderText(blocks).length > 0
2284
+ }
2285
+ return true
2075
2286
  }
2076
2287
  function systemReminderText(blocks) {
2077
2288
  if (!Array.isArray(blocks)) return ''
@@ -2085,7 +2296,7 @@ function eventHtml(entry, ctx = {}) {
2085
2296
  const ev = entry.event || {}
2086
2297
  const data = ev.data || {}
2087
2298
  const type = ev.type || 'event'
2088
- if (!shouldShowEvent(type)) return ''
2299
+ if (!shouldShowEvent(type, ev)) return ''
2089
2300
  let inner = ''
2090
2301
 
2091
2302
  if (type === 'user/message' || type === 'assistant/message') {
@@ -2095,6 +2306,8 @@ function eventHtml(entry, ctx = {}) {
2095
2306
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
2096
2307
  if (sysText) {
2097
2308
  inner = `<details class="event event-detail" data-seq="${seq}"><summary>${esc(t('event.systemReminder'))}</summary><pre>${esc(truncate(sysText, 4000))}</pre></details>`
2309
+ } else if (type === 'user/message' && !isHumanUserMessage(ev)) {
2310
+ return ''
2098
2311
  } else {
2099
2312
  inner = `<div class="msg ${esc(role)}" data-seq="${seq}"><div class="role">${esc(role === 'user' ? t('role.me') : t('role.dsh'))}</div>${blocks.map(blockHtml).join('')}</div>`
2100
2313
  }
@@ -2202,13 +2415,24 @@ async function renderSessionCards() {
2202
2415
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
2203
2416
  if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
2204
2417
  if (sub?.entries?.length) {
2418
+ const expanded = state.subagentExpandedSession === sessionId
2419
+ const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
2205
2420
  const rows = sub.entries.map(e => {
2206
2421
  if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
2207
2422
  const label = e.label || short(e.id)
2208
2423
  const running = e.activity === 'running'
2209
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>`
2210
2425
  }).join('')
2211
- box.insertAdjacentHTML('beforeend', `<div class="card"><div class="card-title">${t('subagent.title')}</div>${rows}</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>`)
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')
2433
+ state.subagentExpandedSession = expanded ? '' : sessionId
2434
+ setTimeout(() => renderSessionCards(), 240)
2435
+ })
2212
2436
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
2213
2437
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
2214
2438
  }
@@ -2261,7 +2485,7 @@ async function runSlashCommand(text) {
2261
2485
  : undefined
2262
2486
  const res = await fetch(apiUrl('/remote/api/command'), {
2263
2487
  method: 'POST',
2264
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
2488
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web', ...clientIdHeaders() },
2265
2489
  body: JSON.stringify({ sessionId: state.current, line: clean }),
2266
2490
  ...(signal ? { signal } : {})
2267
2491
  })
@@ -2383,15 +2607,24 @@ async function sendSessionContent(text, images) {
2383
2607
  try {
2384
2608
  const content = [...await encodeComposerImagesFor(images)]
2385
2609
  if (clean) content.push({ type: 'text', text: clean })
2610
+ setSessionRecovery('resuming')
2386
2611
  const v = await safeRpc('session.prompt', {
2387
2612
  sessionId: state.current,
2388
2613
  mode: 'queue',
2389
2614
  content
2390
2615
  }, t('send.failed'))
2391
- if (v?.accepted) { toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok'); return true }
2616
+ if (v?.accepted) {
2617
+ setSessionRecovery('ready')
2618
+ noteSessionTurnTime(state.current, Date.now())
2619
+ renderSessions()
2620
+ toast(images.length ? t('send.imageSent') : (clean.startsWith('/') ? t('send.commandSent') : t('send.sent')), 'ok')
2621
+ return true
2622
+ }
2392
2623
  if (v?.command?.text) { toast(t('send.commandExecuted'), 'ok'); return true }
2624
+ setSessionRecovery('error')
2393
2625
  return false
2394
2626
  } catch (e) {
2627
+ setSessionRecovery('error', e?.message)
2395
2628
  toast(t('composer.imageReadFailed', { msg: e?.message || e }), 'err')
2396
2629
  return false
2397
2630
  } finally {
@@ -2553,7 +2786,41 @@ async function cancelSession() {
2553
2786
  if (!state.current) return
2554
2787
  if (!confirm(t('session.confirmStop'))) return
2555
2788
  const v = await safeRpc('session.cancel', { sessionId: state.current }, t('session.stopFailed'))
2556
- 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
+ }
2557
2824
  }
2558
2825
 
2559
2826
  async function newSession() {
@@ -2623,6 +2890,7 @@ async function confirmArchiveSession() {
2623
2890
  closeArchiveConfirm()
2624
2891
  toast(t('session.archived'), 'ok')
2625
2892
  await refreshSessions()
2893
+ if (state.current === sessionId) closeSession()
2626
2894
  } finally {
2627
2895
  button.disabled = false
2628
2896
  }
@@ -2735,7 +3003,7 @@ function renderOverview() {
2735
3003
 
2736
3004
  const topSessions = topLevelSessions()
2737
3005
  const running = topSessions.filter(s => s.running).length
2738
- const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 4)
3006
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 4)
2739
3007
  const primary = $('overview-primary-action')
2740
3008
  if (primary) {
2741
3009
  let action = 'new'
@@ -2766,7 +3034,7 @@ function renderOverview() {
2766
3034
  $('overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'overview.poll' : 'overview.ws') : '—'
2767
3035
  $('overview-active-count').textContent = running ? t('overview.activeCount', { n: running }) : ''
2768
3036
  $('overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="overview-session-item ${s.running ? 'running' : ''}" data-overview-session="${esc(s.sessionId)}">
2769
- <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="overview-item-arrow">›</span>
3037
+ <span class="overview-item-mark">${s.running ? '●' : '○'}</span><span class="overview-item-copy"><span class="overview-item-title">${esc(titleOf(s))}</span><span class="overview-item-desc">${s.running ? esc(t('sessions.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="overview-item-arrow">›</span>
2770
3038
  </button>`).join('') : `<div class="overview-empty">${t('overview.noSession')}</div>`
2771
3039
  $('overview-session-list').querySelectorAll('[data-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.overviewSession)))
2772
3040
  }
@@ -2862,11 +3130,46 @@ async function submitQuestion() {
2862
3130
  }
2863
3131
 
2864
3132
  /* ---------------- 后台任务 ---------------- */
3133
+ function queuePreview(item) {
3134
+ const blocks = item?.message?.content || item?.content || []
3135
+ const text = Array.isArray(blocks)
3136
+ ? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
3137
+ : ''
3138
+ return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
3139
+ }
3140
+ async function steerQueueItem(itemId) {
3141
+ const sessionId = state.current
3142
+ const key = `${sessionId}:${itemId}`
3143
+ const s = state.byId.get(sessionId)
3144
+ if (!sessionId || !s?.running || state.queueSteering[key]) return
3145
+ state.queueSteering[key] = true
3146
+ renderQueue()
3147
+ try {
3148
+ const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
3149
+ if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
3150
+ } finally {
3151
+ delete state.queueSteering[key]
3152
+ renderQueue()
3153
+ }
3154
+ }
2865
3155
  function renderQueue() {
2866
3156
  const s = state.byId.get(state.current)
2867
3157
  if (!s) return
2868
- const items = state.queues[state.current] || []
3158
+ const items = (state.queues[state.current] || []).filter(item => item?.placement === 'queued')
3159
+ const box = $('queue-dock')
3160
+ if (box) {
3161
+ box.classList.toggle('hidden', !items.length)
3162
+ box.innerHTML = items.length ? `<div class="queue-dock-head"><span>⌁</span><span>${esc(t('queue.title'))} · ${items.length}</span></div><div class="queue-dock-list">${items.map(item => {
3163
+ const key = `${state.current}:${item.id}`
3164
+ const busy = !!state.queueSteering[key]
3165
+ return `<div class="queue-dock-item"><span class="queue-dock-preview" title="${esc(queuePreview(item))}">${esc(queuePreview(item))}</span><button type="button" class="mini-btn queue-dock-action" data-queue-steer="${esc(item.id)}" title="${esc(s.running ? t('queue.steer') : t('queue.steerUnavailable'))}" ${s.running && !busy ? '' : 'disabled'}>${busy ? '…' : esc(t('queue.steer'))}</button></div>`
3166
+ }).join('')}</div>` : ''
3167
+ box.querySelectorAll('[data-queue-steer]').forEach(button => {
3168
+ button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
3169
+ })
3170
+ }
2869
3171
  updateCancelBtn()
3172
+ updateSessionStatus()
2870
3173
  // 队列数量在会话列表已显示; 详情页不重复大 UI
2871
3174
  $('history-hint').textContent = items.length ? t('history.queueAndCount', { q: items.length, n: state.history.visible.length }) : t('history.countOnly', { n: state.history.visible.length })
2872
3175
  renderSessions()
@@ -2889,7 +3192,8 @@ function renderJobs() {
2889
3192
  function fsHeaders() {
2890
3193
  return {
2891
3194
  authorization: 'Bearer ' + state.token,
2892
- 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web'
3195
+ 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web',
3196
+ ...clientIdHeaders()
2893
3197
  }
2894
3198
  }
2895
3199
 
@@ -3259,6 +3563,9 @@ async function runFsUpload(up) {
3259
3563
  xhr.open('POST', fsApiUrl('/upload', params))
3260
3564
  xhr.setRequestHeader('authorization', 'Bearer ' + state.token)
3261
3565
  xhr.setRequestHeader('x-dsh-remote-client', CAP?.isNativePlatform?.() ? 'app' : 'web')
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))
3262
3569
  xhr.upload.onprogress = (e) => {
3263
3570
  if (e.lengthComputable) {
3264
3571
  const loaded = up.offset + Math.min(e.loaded, e.total)
@@ -3283,7 +3590,7 @@ async function runFsUpload(up) {
3283
3590
  })
3284
3591
 
3285
3592
  const probe = async () => {
3286
- 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() })
3287
3594
  if (res.status === 401) { fsAuthError(401); return null }
3288
3595
  const json = await res.json().catch(() => ({}))
3289
3596
  if (json.ok) up.offset = json.partialSize || 0
@@ -3315,7 +3622,7 @@ async function runFsUpload(up) {
3315
3622
  }
3316
3623
  hasher.update(chunkBytes)
3317
3624
  const isLast = end >= up.size
3318
- 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) }
3319
3626
  if (isLast) { params.finish = '1'; params.sha256 = hasher.hex() }
3320
3627
  if (overwrite) params.overwrite = '1'
3321
3628
  const r = await uploadChunk(params, blob)
@@ -3357,7 +3664,7 @@ async function runFsUpload(up) {
3357
3664
  // 发一个空 finish 块完成收尾, 同时带上全量 SHA-256 校验
3358
3665
  if (up.offset >= up.size) {
3359
3666
  const expected = hasher.hex()
3360
- 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 }
3361
3668
  if (overwrite) params.overwrite = '1'
3362
3669
  const r = await uploadChunk(params, new Blob([]))
3363
3670
  if (r.status === 401) { fsAuthError(401); return }
@@ -4177,9 +4484,9 @@ function saveBgConfig(enabled) {
4177
4484
  const b = bgBridge()
4178
4485
  if (!b?.saveBackgroundConfig) return false
4179
4486
  const base = bgBase()
4180
- const intervalMin = parseFloat($('bg-interval')?.value || '1') || 1
4487
+ const intervalMin = parseFloat($('bg-interval')?.value || '0.5') || 0.5
4181
4488
  const notifyTaskDone = $('opt-task-done')?.checked !== false
4182
- b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', notifyTaskDone }))
4489
+ b.saveBackgroundConfig(JSON.stringify({ enabled, intervalMin, base, token: state.token || '', clientId: CLIENT_ID || '', notifyTaskDone }))
4183
4490
  if (enabled) $('bg-auth-status')?.classList.add('hidden')
4184
4491
  return true
4185
4492
  }
@@ -4310,6 +4617,7 @@ function showView(id) {
4310
4617
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
4311
4618
  document.body.classList.toggle('in-session', id === 'view-session')
4312
4619
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
4620
+ window.DshMotion?.view($(id))
4313
4621
  window.scrollTo(0, 0)
4314
4622
  if (id === 'view-files' && !state.fs.loaded) {
4315
4623
  const workspace = workspaceById(state.fs.workspaceId)
@@ -4398,8 +4706,7 @@ function updateComposerFullscreenButton() {
4398
4706
  const shouldShow = active || input.scrollHeight > 120
4399
4707
  button.classList.toggle('hidden', !shouldShow)
4400
4708
  $('composer-input-wrap')?.classList.toggle('has-fs-btn', shouldShow)
4401
- $('fs-ico-expand')?.classList.toggle('hidden', active)
4402
- $('fs-ico-collapse')?.classList.toggle('hidden', !active)
4709
+ $('fs-ico')?.setAttribute('data-morph-state', active ? 'open' : 'closed')
4403
4710
  button.title = t(active ? 'composer.exitFullscreen' : 'composer.fullscreen')
4404
4711
  button.setAttribute('aria-label', t(active ? 'composer.exitFullscreen' : 'composer.fullscreen'))
4405
4712
  }
@@ -4505,14 +4812,207 @@ async function decodeQrDataUrl(dataUrl) {
4505
4812
  return code?.data || ''
4506
4813
  }
4507
4814
 
4508
- /** App 内扫码: 官方 Camera 拍照/相册 + jsQR 本地解码(无 Google ML Kit/GMS 依赖, 国内可用)。
4509
- * 冗余路径 1: 系统相机扫 dshremote:// 二维码直接唤起 App(见 bindNativeLinks);
4510
- * 冗余路径 2: 设置页手动粘贴令牌。 */
4815
+ let liveScanStream = null
4816
+ let liveScanTimer = null
4817
+ let liveScanResolve = null
4818
+ let liveScanCanvas = null
4819
+ let liveScanContext = null
4820
+ let liveScanCancelled = false
4821
+ let liveScanDetector = null
4822
+ let liveScanBusy = false
4823
+ let liveScanWorker = null
4824
+ let liveScanWorkerUrl = ''
4825
+ let liveScanWorkerResolve = null
4826
+
4827
+ function stopLiveScanWorker() {
4828
+ liveScanWorkerResolve?.('')
4829
+ liveScanWorkerResolve = null
4830
+ liveScanWorker?.terminate()
4831
+ liveScanWorker = null
4832
+ if (liveScanWorkerUrl) URL.revokeObjectURL(liveScanWorkerUrl)
4833
+ liveScanWorkerUrl = ''
4834
+ }
4835
+
4836
+ function startLiveScanWorker() {
4837
+ if (!window.Worker || !window.Blob || !window.URL?.createObjectURL) return
4838
+ try {
4839
+ const sourceUrl = new URL('jsqr.min.js', document.baseURI).href
4840
+ const workerSource = `
4841
+ let loaded = false
4842
+ self.onmessage = event => {
4843
+ try {
4844
+ if (!loaded) {
4845
+ self.importScripts(${JSON.stringify(sourceUrl)})
4846
+ loaded = true
4847
+ }
4848
+ const { data, width, height } = event.data || {}
4849
+ const code = self.jsQR?.(data, width, height, { inversionAttempts: 'attemptBoth' })
4850
+ self.postMessage({ raw: code?.data || '' })
4851
+ } catch (error) {
4852
+ self.postMessage({ raw: '', error: String(error?.message || error) })
4853
+ }
4854
+ }
4855
+ `
4856
+ liveScanWorkerUrl = URL.createObjectURL(new Blob([workerSource], { type: 'application/javascript' }))
4857
+ liveScanWorker = new Worker(liveScanWorkerUrl)
4858
+ liveScanWorker.onmessage = event => {
4859
+ const resolve = liveScanWorkerResolve
4860
+ liveScanWorkerResolve = null
4861
+ resolve?.(event.data?.raw || '')
4862
+ }
4863
+ liveScanWorker.onerror = () => {
4864
+ const resolve = liveScanWorkerResolve
4865
+ liveScanWorkerResolve = null
4866
+ resolve?.('')
4867
+ stopLiveScanWorker()
4868
+ }
4869
+ } catch {
4870
+ stopLiveScanWorker()
4871
+ }
4872
+ }
4873
+
4874
+ function closeLiveScan(result = '') {
4875
+ liveScanCancelled = true
4876
+ if (liveScanTimer) clearTimeout(liveScanTimer)
4877
+ liveScanTimer = null
4878
+ if (liveScanStream) liveScanStream.getTracks().forEach(track => track.stop())
4879
+ liveScanStream = null
4880
+ liveScanDetector = null
4881
+ stopLiveScanWorker()
4882
+ const video = $('scan-live-video')
4883
+ if (video) video.srcObject = null
4884
+ $('modal-scan-live')?.classList.add('hidden')
4885
+ const resolve = liveScanResolve
4886
+ liveScanResolve = null
4887
+ resolve?.(result)
4888
+ }
4889
+
4890
+ async function scanLiveFrame() {
4891
+ if (!liveScanResolve) return
4892
+ const video = $('scan-live-video')
4893
+ if (!video || video.readyState < 2 || !video.videoWidth || !liveScanContext) {
4894
+ liveScanTimer = setTimeout(scanLiveFrame, 180)
4895
+ return
4896
+ }
4897
+ if (liveScanBusy) return
4898
+ liveScanBusy = true
4899
+ try {
4900
+ let raw = ''
4901
+ if (liveScanDetector) {
4902
+ const codes = await liveScanDetector.detect(video)
4903
+ raw = codes?.[0]?.rawValue || ''
4904
+ } else {
4905
+ // 只解码取景框中央区域,避免在低端手机上对整幅高分辨率画面反复二值化。
4906
+ const side = Math.floor(Math.min(video.videoWidth, video.videoHeight) * 0.64)
4907
+ const sx = Math.floor((video.videoWidth - side) / 2)
4908
+ const sy = Math.floor((video.videoHeight - side) / 2)
4909
+ const maxSide = 480
4910
+ const scale = Math.min(1, maxSide / Math.max(1, side))
4911
+ const w = Math.max(1, Math.round(side * scale))
4912
+ const h = w
4913
+ if (liveScanCanvas.width !== w || liveScanCanvas.height !== h) {
4914
+ liveScanCanvas.width = w
4915
+ liveScanCanvas.height = h
4916
+ }
4917
+ liveScanContext.drawImage(video, sx, sy, side, side, 0, 0, w, h)
4918
+ const imageData = liveScanContext.getImageData(0, 0, w, h)
4919
+ if (liveScanWorker) {
4920
+ raw = await new Promise(resolve => {
4921
+ liveScanWorkerResolve = resolve
4922
+ liveScanWorker.postMessage({ data: imageData.data, width: w, height: h }, [imageData.data.buffer])
4923
+ })
4924
+ } else {
4925
+ raw = window.jsQR?.(imageData.data, w, h, { inversionAttempts: 'attemptBoth' })?.data || ''
4926
+ }
4927
+ }
4928
+ if (raw && liveScanResolve) return closeLiveScan(raw)
4929
+ } catch {
4930
+ // 摄像头帧在切后台或权限切换时可能暂时不可读,下一帧继续即可。
4931
+ } finally {
4932
+ liveScanBusy = false
4933
+ }
4934
+ if (liveScanResolve) liveScanTimer = setTimeout(scanLiveFrame, 180)
4935
+ }
4936
+
4937
+ /** 打开持续取帧的本地摄像头扫码;返回 undefined 表示当前 WebView 不支持实时摄像头。 */
4938
+ async function scanPairLive() {
4939
+ if (!navigator.mediaDevices?.getUserMedia) return undefined
4940
+ const video = $('scan-live-video')
4941
+ const modal = $('modal-scan-live')
4942
+ if (!video || !modal || !window.jsQR) return undefined
4943
+ const camera = CAP.Plugins?.Camera
4944
+ const perm = await camera?.requestPermissions?.({ permissions: ['camera'] })
4945
+ if (perm && perm.camera !== 'granted') throw new Error(t('scan.permissionDenied'))
4946
+ liveScanCancelled = false
4947
+ modal.classList.remove('hidden')
4948
+ $('scan-live-status').textContent = t('scan.liveStarting')
4949
+ try {
4950
+ liveScanStream = await navigator.mediaDevices.getUserMedia({
4951
+ audio: false,
4952
+ video: {
4953
+ facingMode: { ideal: 'environment' },
4954
+ width: { ideal: 960, max: 1280 },
4955
+ height: { ideal: 720, max: 1280 },
4956
+ frameRate: { ideal: 24, max: 30 }
4957
+ }
4958
+ })
4959
+ if (liveScanCancelled) {
4960
+ liveScanStream.getTracks().forEach(track => track.stop())
4961
+ liveScanStream = null
4962
+ return ''
4963
+ }
4964
+ video.srcObject = liveScanStream
4965
+ await video.play()
4966
+ $('scan-live-status').textContent = t('scan.liveHint')
4967
+ try {
4968
+ if (window.BarcodeDetector) {
4969
+ const formats = await window.BarcodeDetector.getSupportedFormats?.()
4970
+ if (!formats || formats.includes('qr_code')) liveScanDetector = new window.BarcodeDetector({ formats: ['qr_code'] })
4971
+ }
4972
+ } catch { liveScanDetector = null }
4973
+ liveScanCanvas = document.createElement('canvas')
4974
+ liveScanContext = liveScanCanvas.getContext('2d', { willReadFrequently: true })
4975
+ if (!liveScanContext) throw new Error(t('scan.decodeUnsupported'))
4976
+ startLiveScanWorker()
4977
+ return await new Promise(resolve => {
4978
+ liveScanResolve = resolve
4979
+ scanLiveFrame()
4980
+ })
4981
+ } catch (e) {
4982
+ closeLiveScan('')
4983
+ throw e
4984
+ } finally {
4985
+ liveScanCanvas = null
4986
+ liveScanContext = null
4987
+ liveScanDetector = null
4988
+ liveScanBusy = false
4989
+ }
4990
+ }
4991
+
4992
+ /** App 内扫码优先使用实时摄像头取帧;不支持时回退到官方 Camera 拍照/相册 + jsQR。 */
4511
4993
  async function scanPair(source) {
4512
4994
  if (!CAP?.isNativePlatform?.()) {
4513
4995
  toast(t('scan.browserHint'), 'err')
4514
4996
  return
4515
4997
  }
4998
+ if (source === 'CAMERA') {
4999
+ try {
5000
+ const liveRaw = await scanPairLive()
5001
+ if (liveRaw !== undefined) {
5002
+ if (!liveRaw) return toast(t('scan.cancelled'), 'ok')
5003
+ if (applyPairUrl(liveRaw)) {
5004
+ toast(t('scan.paired'), 'ok')
5005
+ openStreams()
5006
+ refreshAll()
5007
+ } else toast(t('scan.notPair'), 'err')
5008
+ return
5009
+ }
5010
+ } catch (e) {
5011
+ const msg = String(e?.message || e || '')
5012
+ toast(/cancel/i.test(msg) ? t('scan.cancelled') : t('scan.failed', { msg }), 'err')
5013
+ return
5014
+ }
5015
+ }
4516
5016
  const camera = CAP.Plugins?.Camera
4517
5017
  if (!camera?.getPhoto) { toast(t('scan.unsupported'), 'err'); return }
4518
5018
  try {
@@ -4995,6 +5495,12 @@ function bindUi() {
4995
5495
  })
4996
5496
  $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
4997
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() })
4998
5504
  $('btn-send').addEventListener('click', sendMessage)
4999
5505
  $('btn-fs-send').addEventListener('click', sendMessage)
5000
5506
  $('btn-plus').addEventListener('click', toggleComposerMenu)
@@ -5121,6 +5627,8 @@ function bindUi() {
5121
5627
  })
5122
5628
  $('btn-scan-camera').addEventListener('click', () => scanPair('CAMERA'))
5123
5629
  $('btn-scan-gallery').addEventListener('click', () => scanPair('PHOTOS'))
5630
+ $('scan-live-cancel')?.addEventListener('click', () => closeLiveScan(''))
5631
+ $('modal-scan-live')?.addEventListener('click', e => { if (e.target === $('modal-scan-live')) closeLiveScan('') })
5124
5632
  $('btn-change-token').addEventListener('click', () => {
5125
5633
  const input = prompt(t('token.prompt'), state.token)
5126
5634
  if (input && input.trim()) { state.token = input.trim(); LS.set('token', input.trim()); $('token-desc').textContent = t('token.saved'); toast(t('token.savedReconnect'), 'ok'); openStreams(); refreshAll(); syncBgConfig() }