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.
@@ -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
  const CAP = window.Capacitor || null
25
27
 
26
28
  /* ---------------- 皮肤 ---------------- */
@@ -67,10 +69,16 @@ const state = {
67
69
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
68
70
  byId: new Map(),
69
71
  current: null,
72
+ sessionRecovery: { status: 'idle', error: '' },
73
+ pendingProjections: new Map(),
74
+ lastStreamResyncAt: 0,
70
75
  hostInfo: null,
71
76
  history: emptyDesktopHistory(),
72
77
  approvals: [],
73
78
  questions: [],
79
+ queues: {},
80
+ queueSteering: {},
81
+ sessionTurnTimes: {},
74
82
  questionModal: null,
75
83
  streamsOk: { mux: false, host: false },
76
84
  errCount: 0,
@@ -84,7 +92,8 @@ const state = {
84
92
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
85
93
  wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
86
94
  archivedIds: [],
87
- view: 'sessions'
95
+ view: 'sessions',
96
+ subagentExpandedSession: ''
88
97
  }
89
98
  const streams = {}
90
99
  let pollTimer = null
@@ -545,7 +554,7 @@ async function getWsTicket() {
545
554
  if (activeGatewayCapability('wsTicket') === false) throw new Error('ws ticket unsupported')
546
555
  const res = await fetch(apiUrl('/api/ws-ticket'), {
547
556
  method: 'POST',
548
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web' }
557
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
549
558
  })
550
559
  if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
551
560
  const data = await res.json()
@@ -558,7 +567,7 @@ async function getWsTicket() {
558
567
  async function rpc(method, payload = {}, timeoutMs = 45000) {
559
568
  const opts = {
560
569
  method: 'POST',
561
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
570
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
562
571
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
563
572
  }
564
573
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -575,7 +584,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
575
584
  async function respond(rpcId, value) {
576
585
  const opts = {
577
586
  method: 'POST',
578
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
587
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
579
588
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
580
589
  }
581
590
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -1011,6 +1020,7 @@ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1011
1020
  updateConn()
1012
1021
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
1013
1022
  if (refreshOnOpen) refreshSessions()
1023
+ if (allStreamsOpen()) resyncAfterStreamOpen()
1014
1024
  }
1015
1025
  ws.onmessage = (msg) => {
1016
1026
  if (!streamIsCurrent(kind, ws, generation)) return
@@ -1094,7 +1104,7 @@ async function pollKind(kind) {
1094
1104
  let res
1095
1105
  try {
1096
1106
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1097
- const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
1107
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1098
1108
  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 })
1099
1109
  } catch { return }
1100
1110
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
@@ -1107,6 +1117,7 @@ async function pollKind(kind) {
1107
1117
  state.pollSeq[kind] = 0
1108
1118
  if (kind === 'mux') renderNotifStack()
1109
1119
  refreshSessions()
1120
+ if (state.current) void resyncCurrentSession()
1110
1121
  }
1111
1122
  for (const item of data.events) {
1112
1123
  if (item.seq > (state.pollSeq[kind] || 0)) {
@@ -1164,6 +1175,7 @@ function onMuxFrame(full) {
1164
1175
  return
1165
1176
  }
1166
1177
  if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderNotifStack(); return }
1178
+ if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
1167
1179
  if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
1168
1180
  if (f.type === 'stream/error') toast(f.error?.message || 'stream error', 'err')
1169
1181
  }
@@ -1173,23 +1185,70 @@ function onHostFrame(full) {
1173
1185
  if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) refreshSessions()
1174
1186
  if (f.type === 'host/session-status') {
1175
1187
  const s = state.byId.get(f.sessionId)
1176
- if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions(); renderOverviewDesktop() }
1188
+ if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessions(); renderQueue(); updateComposerStatus() } renderOverviewDesktop() }
1189
+ }
1190
+ }
1191
+ function hydrateSessionProjections(sessionId, projections) {
1192
+ const s = state.byId.get(sessionId)
1193
+ if (!s || !projections || typeof projections !== 'object') return
1194
+ const incomingSeq = Number(projections.asOfSeq) || 0
1195
+ const current = s.projections || { asOfSeq: 0, values: {} }
1196
+ const currentSeq = Number(current.asOfSeq) || 0
1197
+ if (incomingSeq < currentSeq) return
1198
+ s.projections = {
1199
+ asOfSeq: Math.max(currentSeq, incomingSeq),
1200
+ values: { ...(current.values || {}), ...(projections.values || {}) }
1201
+ }
1202
+ }
1203
+ function applyPendingProjections() {
1204
+ for (const [sessionId, projections] of state.pendingProjections) {
1205
+ if (!state.byId.has(sessionId)) continue
1206
+ hydrateSessionProjections(sessionId, projections)
1207
+ state.pendingProjections.delete(sessionId)
1177
1208
  }
1178
1209
  }
1179
1210
  function applyProjection(sessionId, key, value, seq) {
1180
1211
  const s = state.byId.get(sessionId)
1181
- if (s) {
1182
- s.projections = s.projections || { asOfSeq: 0, values: {} }
1183
- s.projections.values = s.projections.values || {}
1184
- s.projections.values[key] = value
1185
- s.projections.asOfSeq = Math.max(s.projections.asOfSeq || 0, seq || 0)
1212
+ if (!s) {
1213
+ const pending = state.pendingProjections.get(sessionId) || { asOfSeq: 0, values: {} }
1214
+ pending.values[key] = value
1215
+ pending.asOfSeq = Math.max(pending.asOfSeq || 0, seq || 0)
1216
+ state.pendingProjections.set(sessionId, pending)
1217
+ return
1186
1218
  }
1219
+ const currentSeq = Number(s.projections?.asOfSeq) || 0
1220
+ if (seq && seq < currentSeq) return
1221
+ s.projections = s.projections || { asOfSeq: 0, values: {} }
1222
+ s.projections.values = s.projections.values || {}
1223
+ s.projections.values[key] = value
1224
+ s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1187
1225
  if (state.current === sessionId) {
1188
1226
  renderSessions()
1189
1227
  if (['goal', 'todos'].includes(key)) renderSessionCards()
1190
1228
  }
1191
1229
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
1192
1230
  }
1231
+ function setSessionRecovery(status, error = '') {
1232
+ state.sessionRecovery = { status, error: String(error || '') }
1233
+ updateSessionActions()
1234
+ }
1235
+ function recoveryLabel() {
1236
+ const status = state.sessionRecovery.status
1237
+ if (status === 'loading' || status === 'resuming') return t('ds.sessionRecovering')
1238
+ if (status === 'error') return t('ds.sessionRecoveryFailed')
1239
+ return ''
1240
+ }
1241
+ function resyncCurrentSession() {
1242
+ if (!state.current) return Promise.resolve()
1243
+ return loadHistory().then(() => {
1244
+ if (state.current) { renderSessionCards(); updateComposerStatus(); updateSessionActions() }
1245
+ })
1246
+ }
1247
+ function resyncAfterStreamOpen() {
1248
+ if (!state.current || Date.now() - state.lastStreamResyncAt < 1200) return
1249
+ state.lastStreamResyncAt = Date.now()
1250
+ void refreshSessions().then(() => resyncCurrentSession())
1251
+ }
1193
1252
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1194
1253
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
1195
1254
  function isTopLevelSession(session) {
@@ -1206,6 +1265,15 @@ function goalOf(s) {
1206
1265
  return p.goal && typeof p.goal === 'object' ? p.goal : p
1207
1266
  }
1208
1267
  function onSessionEvent(sessionId, event) {
1268
+ if (event?.type === 'turn/start' || event?.type === 'turn/end') {
1269
+ noteSessionTurnTime(sessionId, event)
1270
+ renderSessions()
1271
+ }
1272
+ const session = state.byId.get(sessionId)
1273
+ if (event?.type === 'agent/status' && session) {
1274
+ session.running = !!event.data?.running
1275
+ if (state.current === sessionId) { renderQueue(); updateComposerStatus() }
1276
+ }
1209
1277
  if (state.current === sessionId && event) {
1210
1278
  const h = state.history
1211
1279
  const reasoningChanged = applyReasoningStreamEvent(event)
@@ -1214,7 +1282,7 @@ function onSessionEvent(sessionId, event) {
1214
1282
  return
1215
1283
  }
1216
1284
  const seq = event.seq
1217
- if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type)) {
1285
+ if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type, event)) {
1218
1286
  h.seqs.add(seq)
1219
1287
  h.visible.push({ seq, event })
1220
1288
  h.visible.sort((a, b) => a.seq - b.seq)
@@ -1230,6 +1298,7 @@ async function refreshSessions() {
1230
1298
  if (!v) { renderSessions(); renderOverviewDesktop(); return }
1231
1299
  state.sessions = v.items || []
1232
1300
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1301
+ applyPendingProjections()
1233
1302
  renderSessions()
1234
1303
  scheduleWorkbenchRefresh()
1235
1304
  renderOverviewDesktop()
@@ -1239,6 +1308,23 @@ function sessionWorkspaceLabel(s) {
1239
1308
  const cwd = sessionCwd(s)
1240
1309
  return cwd || t('ds.workspaceUnknown')
1241
1310
  }
1311
+ function sessionSortTime(s) {
1312
+ return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
1313
+ }
1314
+ function sessionWorkspaceOrderKey(s) {
1315
+ return 'path:' + (sessionWorkspaceLabel(s) || 'workspace-unknown')
1316
+ }
1317
+ function commitWorkspaceGroupOrder(order) {
1318
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
1319
+ renderSessions()
1320
+ toast(t('ds.wbOrderSaved'), 'ok')
1321
+ }
1322
+ function noteSessionTurnTime(sessionId, eventOrTime) {
1323
+ const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
1324
+ const time = Number(raw) > 0 ? Number(raw) : Date.now()
1325
+ if (!sessionId || !Number.isFinite(time)) return
1326
+ state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
1327
+ }
1242
1328
  function workspaceDisplayName(label) {
1243
1329
  const value = String(label || '').trim()
1244
1330
  if (!value || value === t('ds.workspaceUnknown')) return value || t('ds.workspaceUnknown')
@@ -1253,10 +1339,10 @@ function sortedSessions() {
1253
1339
  const aw = sessionCwd(a) || '\uffff'
1254
1340
  const bw = sessionCwd(b) || '\uffff'
1255
1341
  const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1256
- return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1342
+ return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
1257
1343
  })
1258
1344
  }
1259
- return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1345
+ return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1260
1346
  }
1261
1347
  function renderSessions() {
1262
1348
  const allItems = sortedSessions()
@@ -1272,30 +1358,54 @@ function renderSessions() {
1272
1358
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1273
1359
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1274
1360
  const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
1275
- const renderItems = (items) => {
1276
- let lastWorkspace = null
1277
- const rows = []
1278
- for (const s of items) {
1361
+ const renderSession = s => {
1279
1362
  const workspace = sessionWorkspaceLabel(s)
1280
1363
  const workspaceName = workspaceDisplayName(workspace)
1281
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1282
- rows.push(`<div class="ds-session-group" title="${esc(workspace)}"><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(workspaceName)}</span></div>`)
1283
- lastWorkspace = workspace
1284
- }
1285
1364
  const title = titleOf(s)
1286
- rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1365
+ return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1287
1366
  <span class="ds-session-title">${esc(title)}</span>
1288
1367
  <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1289
- <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1290
- </button>`)
1368
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(sessionSortTime(s))}</span>
1369
+ </button>`
1370
+ }
1371
+ const renderItems = (items) => {
1372
+ if (state.sessionSort !== 'workspace') return items.map(renderSession).join('')
1373
+ const groups = []
1374
+ const byKey = new Map()
1375
+ for (const session of items) {
1376
+ const key = sessionWorkspaceOrderKey(session)
1377
+ let group = byKey.get(key)
1378
+ if (!group) {
1379
+ group = { key, label: workspaceDisplayName(sessionWorkspaceLabel(session)), path: sessionWorkspaceLabel(session), items: [] }
1380
+ byKey.set(key, group)
1381
+ groups.push(group)
1382
+ }
1383
+ group.items.push(session)
1291
1384
  }
1292
- return rows.join('')
1385
+ const { value } = workbenchOrderScopeValue()
1386
+ return orderedItems(groups, value.workspaceIds, group => group.key).map(group => `<div class="ds-session-workspace-group" data-workspace-group="${esc(group.key)}" data-motion-key="${esc(group.key)}">
1387
+ <div class="ds-session-group" data-reorder-handle title="${esc(group.path)}"><span class="ds-session-group-drag-handle" aria-hidden="true">⠿</span><span class="ds-session-group-icon" aria-hidden="true">⌂</span><span class="ds-session-group-name">${esc(group.label)}</span></div>
1388
+ ${orderedWorkspaceSessions(group.key, group.items).map(renderSession).join('')}
1389
+ </div>`).join('')
1293
1390
  }
1294
1391
  const divider = archived.length ? `<button class="ds-archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1295
1392
  const hiddenByWorkbench = allItems.length - visible.length
1296
1393
  const html = renderItems(main) + divider + (showArchived ? renderItems(archived) : '') || `<div class="ds-empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('ds.sessionsEmpty'))}</div>`
1297
1394
  $('session-list').innerHTML = html
1298
1395
  $('mobile-session-list').innerHTML = html
1396
+ window.DshMotion?.list($('session-list'), '.ds-session-item')
1397
+ window.DshMotion?.list($('mobile-session-list'), '.ds-session-item')
1398
+ for (const list of [$('session-list'), $('mobile-session-list')].filter(Boolean)) {
1399
+ window.DshMotion?.bindLongPressReorder(list, '.ds-session-workspace-group', {
1400
+ handleSelector: '.ds-session-group',
1401
+ onCommit: ({ order }) => commitWorkspaceGroupOrder(order)
1402
+ })
1403
+ window.DshMotion?.bindLongPressReorder(list, '.ds-session-item', {
1404
+ groupSelector: '.ds-session-workspace-group',
1405
+ handleSelector: '.ds-session-item',
1406
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-workspace-group]')?.dataset.workspaceGroup, order)
1407
+ })
1408
+ }
1299
1409
  $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1300
1410
  $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1301
1411
  const sort = $('session-sort')
@@ -1310,46 +1420,60 @@ function renderSessions() {
1310
1420
 
1311
1421
  async function openSession(id) {
1312
1422
  state.current = id
1423
+ setSessionRecovery('loading')
1313
1424
  state.history = emptyDesktopHistory()
1314
1425
  state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
1315
1426
  showView('view-chat')
1316
1427
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
1428
+ updateSessionActions()
1317
1429
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
1318
1430
  renderSessions()
1319
1431
  renderSessionCards()
1432
+ renderQueue()
1433
+ updateComposerStatus()
1320
1434
  await loadHistory()
1321
1435
  }
1322
1436
  function closeSession() {
1323
1437
  state.current = null
1438
+ setSessionRecovery('idle')
1324
1439
  state.history = emptyDesktopHistory()
1325
1440
  const cards = $('session-cards')
1326
1441
  if (cards) cards.innerHTML = ''
1442
+ renderQueue()
1443
+ updateComposerStatus()
1444
+ updateSessionActions()
1327
1445
  showView('view-sessions')
1328
1446
  }
1329
1447
  async function loadHistory() {
1330
1448
  const id = state.current
1331
1449
  if (!id || state.history.loading) return
1332
1450
  state.history.loading = true
1451
+ setSessionRecovery('loading')
1333
1452
  let v
1334
1453
  try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1335
1454
  catch (e) {
1336
1455
  state.history.loading = false
1337
1456
  if (e.message === 'AUTH') return
1457
+ setSessionRecovery('error', e.message)
1338
1458
  $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
1339
1459
  return
1340
1460
  }
1461
+ hydrateSessionProjections(id, v.projections)
1341
1462
  for (const entry of v.events || []) {
1342
1463
  const ev = entry?.event
1343
1464
  const seq = ev?.seq
1465
+ if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1344
1466
  applyReasoningStreamEvent(ev)
1345
1467
  if (seq == null || state.history.seqs.has(seq)) continue
1346
- if (!shouldShowEvent(ev.type)) continue
1468
+ if (!shouldShowEvent(ev.type, ev)) continue
1347
1469
  state.history.seqs.add(seq)
1348
1470
  state.history.visible.push({ seq, event: ev })
1349
1471
  }
1350
1472
  state.history.visible.sort((a, b) => a.seq - b.seq)
1351
1473
  state.history.hasMore = !!v.hasMore
1352
1474
  state.history.loading = false
1475
+ setSessionRecovery('ready')
1476
+ updateSessionActions()
1353
1477
  renderHistory()
1354
1478
  }
1355
1479
 
@@ -1360,7 +1484,25 @@ const INTERESTING_EVENTS = new Set([
1360
1484
  'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
1361
1485
  'approval/asked', 'approval/resolved', 'session/title', 'title'
1362
1486
  ])
1363
- function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
1487
+ function messageSource(data) {
1488
+ const source = data?.source ?? data?.message?.source
1489
+ return source && typeof source === 'object' ? source : null
1490
+ }
1491
+ function isHumanUserMessage(event) {
1492
+ if (event?.type !== 'user/message') return false
1493
+ const source = messageSource(event.data || {})
1494
+ // Older DSH events may not carry source metadata; keep those visible for compatibility.
1495
+ return !source || source.kind === 'user'
1496
+ }
1497
+ function shouldShowEvent(type, event) {
1498
+ if (!INTERESTING_EVENTS.has(type)) return false
1499
+ if (type === 'user/message' && !isHumanUserMessage(event)) {
1500
+ const data = event?.data || {}
1501
+ const blocks = data.message?.content || data.content || []
1502
+ return systemReminderText(blocks).length > 0
1503
+ }
1504
+ return true
1505
+ }
1364
1506
  function reasoningStreamKey(data, index) { return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}` }
1365
1507
  function applyReasoningStreamEvent(event) {
1366
1508
  const h = state.history
@@ -1430,7 +1572,7 @@ function eventHtml(entry) {
1430
1572
  const ev = entry.event || {}
1431
1573
  const data = ev.data || {}
1432
1574
  const type = ev.type || 'event'
1433
- if (!shouldShowEvent(type)) return ''
1575
+ if (!shouldShowEvent(type, ev)) return ''
1434
1576
  if (type === 'user/message' || type === 'assistant/message') {
1435
1577
  const msg = data.message || {}
1436
1578
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
@@ -1440,6 +1582,7 @@ function eventHtml(entry) {
1440
1582
  const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
1441
1583
  return `<details class="event ds-tool ds-event-detail"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1442
1584
  }
1585
+ if (type === 'user/message' && !isHumanUserMessage(ev)) return ''
1443
1586
  const text = blocks.map(blockHtml).join('')
1444
1587
  return `<div class="ds-msg ${esc(role)}"><div class="role">${esc(role === 'user' ? t('ds.role.me') : t('ds.role.dsh'))}</div>${text || '<span style="opacity:.6">…</span>'}</div>`
1445
1588
  }
@@ -1500,13 +1643,24 @@ async function renderSessionCards() {
1500
1643
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId }, '')
1501
1644
  if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
1502
1645
  if (sub?.entries?.length) {
1646
+ const expanded = state.subagentExpandedSession === sessionId
1647
+ const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
1503
1648
  const rows = sub.entries.map(e => {
1504
1649
  if (e.kind === 'diagnostic') return `<div class="ds-card-row"><span class="ds-card-k">${t('subagent.diagnostic')}</span><span class="ds-card-v">${esc(e.reason)}</span></div>`
1505
1650
  const label = e.label || short(e.id)
1506
1651
  const running = e.activity === 'running'
1507
1652
  return `<div class="ds-card-row"><span class="ds-card-k">${running ? '▶ ' : ''}${esc(label)}</span><span class="ds-card-v">${esc(e.mode)} ${running ? t('subagent.running') : ''}${e.mode === 'continuable' && running ? ` <button class="ds-mini-btn" data-sub-interrupt="${esc(e.id)}">${t('subagent.interrupt')}</button>` : ''}</span></div>`
1508
1653
  }).join('')
1509
- box.insertAdjacentHTML('beforeend', `<div class="ds-card"><div class="ds-card-title">${t('subagent.title')}</div>${rows}</div>`)
1654
+ const subagentClosedIcon = 'M7 10l5 5 5-5'
1655
+ const subagentOpenIcon = 'M7 14l5-5 5 5'
1656
+ const subagentIcon = expanded ? subagentOpenIcon : subagentClosedIcon
1657
+ box.insertAdjacentHTML('beforeend', `<div class="ds-card ds-subagent-card"><button type="button" class="ds-subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="ds-card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="ds-subagent-toggle-icon" aria-hidden="true"><morph-icon data-morph-state="${expanded ? 'open' : 'closed'}" data-morph-closed="${subagentClosedIcon}" data-morph-open="${subagentOpenIcon}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="${subagentIcon}"/></svg></morph-icon></span></button><div class="ds-subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
1658
+ box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
1659
+ const icon = box.querySelector('[data-subagent-toggle] morph-icon')
1660
+ if (icon) icon.setAttribute('data-morph-state', expanded ? 'closed' : 'open')
1661
+ state.subagentExpandedSession = expanded ? '' : sessionId
1662
+ setTimeout(() => renderSessionCards(), 240)
1663
+ })
1510
1664
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
1511
1665
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
1512
1666
  }
@@ -1569,7 +1723,7 @@ async function runSlashCommand(text) {
1569
1723
  : undefined
1570
1724
  const res = await fetch(apiUrl('/remote/api/command'), {
1571
1725
  method: 'POST',
1572
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
1726
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
1573
1727
  body: JSON.stringify({ sessionId: state.current, line: clean }),
1574
1728
  ...(signal ? { signal } : {})
1575
1729
  })
@@ -1590,12 +1744,112 @@ async function sendMessage() {
1590
1744
  if (!text || !state.current) return
1591
1745
  if (await runSlashCommand(text)) { input.value = ''; return }
1592
1746
  input.value = ''
1747
+ setSessionRecovery('resuming')
1593
1748
  const v = await safeRpc('session.prompt', {
1594
1749
  sessionId: state.current,
1595
1750
  mode: 'queue',
1596
1751
  content: [{ type: 'text', text }]
1597
1752
  }, '')
1598
- if (v) toast(t('ds.toastSent'), 'ok')
1753
+ if (v) { setSessionRecovery('ready'); noteSessionTurnTime(state.current, Date.now()); renderSessions(); toast(t('ds.toastSent'), 'ok') }
1754
+ else setSessionRecovery('error')
1755
+ }
1756
+
1757
+ async function cancelSession() {
1758
+ if (!state.current) return
1759
+ if (!confirm(t('ds.sessionStopConfirm'))) return
1760
+ const v = await safeRpc('session.cancel', { sessionId: state.current }, t('ds.sessionStopFailed'))
1761
+ if (v?.accepted) { setSessionRecovery('ready'); toast(t('ds.sessionStopRequested'), 'ok') }
1762
+ }
1763
+
1764
+ let renamePendingSessionId = null
1765
+ function renameSession(sessionId = state.current) {
1766
+ const session = state.byId.get(sessionId)
1767
+ if (!session) return
1768
+ renamePendingSessionId = sessionId
1769
+ $('rename-session-input').value = titleOf(session)
1770
+ $('modal-rename').classList.remove('hidden')
1771
+ setTimeout(() => { $('rename-session-input').focus(); $('rename-session-input').select() }, 40)
1772
+ }
1773
+ function closeRenameSession() {
1774
+ renamePendingSessionId = null
1775
+ $('modal-rename').classList.add('hidden')
1776
+ }
1777
+ async function confirmRenameSession() {
1778
+ const sessionId = renamePendingSessionId
1779
+ if (!sessionId) return
1780
+ const title = $('rename-session-input').value.trim()
1781
+ if (!title) return toast(t('ds.sessionRenameEmpty'), 'err')
1782
+ const button = $('rename-confirm')
1783
+ button.disabled = true
1784
+ setSessionRecovery('resuming')
1785
+ try {
1786
+ const value = await safeRpc('session.rename', { sessionId, title }, t('ds.sessionRenameFailed'))
1787
+ if (value == null) { setSessionRecovery('error'); return }
1788
+ if (value.title) applyProjection(sessionId, 'title', value.title, value.seq)
1789
+ closeRenameSession()
1790
+ setSessionRecovery('ready')
1791
+ toast(t('ds.sessionRenamed'), 'ok')
1792
+ await refreshSessions()
1793
+ } finally {
1794
+ button.disabled = false
1795
+ }
1796
+ }
1797
+
1798
+ async function archiveCurrentSession() {
1799
+ const sessionId = state.current
1800
+ if (!sessionId || !confirm(t('ds.sessionArchiveConfirm'))) return
1801
+ const value = await safeRpc('workspace.archiveSession', { sessionId }, t('ds.toastOpFailed'))
1802
+ if (!value) return
1803
+ if (Array.isArray(value.archivedSessionIds)) state.archivedIds = value.archivedSessionIds
1804
+ toast(t('ds.sessionArchived'), 'ok')
1805
+ closeSession()
1806
+ await refreshSessions()
1807
+ }
1808
+
1809
+ function updateComposerStatus() {
1810
+ const status = $('composer-status')
1811
+ if (!status) return
1812
+ status.classList.toggle('hidden', !state.byId.get(state.current)?.running)
1813
+ updateSessionActions()
1814
+ }
1815
+ function queuePreview(item) {
1816
+ const blocks = item?.message?.content || item?.content || []
1817
+ const text = Array.isArray(blocks)
1818
+ ? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
1819
+ : ''
1820
+ return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
1821
+ }
1822
+ async function steerQueueItem(itemId) {
1823
+ const sessionId = state.current
1824
+ const key = `${sessionId}:${itemId}`
1825
+ const s = state.byId.get(sessionId)
1826
+ if (!sessionId || !s?.running || state.queueSteering[key]) return
1827
+ state.queueSteering[key] = true
1828
+ renderQueue()
1829
+ try {
1830
+ const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
1831
+ if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
1832
+ } finally {
1833
+ delete state.queueSteering[key]
1834
+ renderQueue()
1835
+ }
1836
+ }
1837
+ function renderQueue() {
1838
+ const box = $('queue-dock')
1839
+ if (!box) return
1840
+ const sessionId = state.current
1841
+ const s = state.byId.get(sessionId)
1842
+ const items = (state.queues[sessionId] || []).filter(item => item?.placement === 'queued')
1843
+ box.classList.toggle('hidden', !items.length)
1844
+ box.innerHTML = items.length ? `<div class="ds-queue-dock-head"><span>⌁</span><span>${esc(t('queue.title'))} · ${items.length}</span></div><div class="ds-queue-dock-list">${items.map(item => {
1845
+ const key = `${sessionId}:${item.id}`
1846
+ const busy = !!state.queueSteering[key]
1847
+ return `<div class="ds-queue-dock-item"><span class="ds-queue-dock-preview" title="${esc(queuePreview(item))}">${esc(queuePreview(item))}</span><button type="button" class="ds-mini-btn ds-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>`
1848
+ }).join('')}</div>` : ''
1849
+ box.querySelectorAll('[data-queue-steer]').forEach(button => {
1850
+ button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
1851
+ })
1852
+ updateComposerStatus()
1599
1853
  }
1600
1854
 
1601
1855
  /* ---------------- 审批/提问通知卡片栈 ---------------- */
@@ -1709,7 +1963,7 @@ function fsApiUrl(sub, params = {}) {
1709
1963
  return u.href
1710
1964
  }
1711
1965
  function fsHeaders() {
1712
- return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
1966
+ return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1713
1967
  }
1714
1968
  function fsParent(p) {
1715
1969
  if (!p) return null
@@ -1837,7 +2091,7 @@ function wbFsParent(p) {
1837
2091
  return raw.slice(0, index)
1838
2092
  }
1839
2093
  async function wbGateway(method, pathname, body) {
1840
- const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' } }
2094
+ const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() } }
1841
2095
  if (body !== undefined) {
1842
2096
  options.headers['content-type'] = 'application/json'
1843
2097
  options.body = JSON.stringify(body)
@@ -1848,6 +2102,64 @@ async function wbGateway(method, pathname, body) {
1848
2102
  if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status))
1849
2103
  return data
1850
2104
  }
2105
+ const WORKBENCH_ORDER_CACHE_KEY = 'workbenchOrderV1'
2106
+ function workbenchOrderScope() { return String(state.server || location.origin || 'default') }
2107
+ function workbenchOrderStore() {
2108
+ let value = null
2109
+ try { value = JSON.parse(LS.get(WORKBENCH_ORDER_CACHE_KEY, '{}')) } catch {}
2110
+ if (!value || typeof value !== 'object') value = {}
2111
+ if (!value.scopes || typeof value.scopes !== 'object') value.scopes = {}
2112
+ return value
2113
+ }
2114
+ function workbenchOrderScopeValue() {
2115
+ const store = workbenchOrderStore()
2116
+ const key = workbenchOrderScope()
2117
+ if (!store.scopes[key] || typeof store.scopes[key] !== 'object') store.scopes[key] = {}
2118
+ return { store, value: store.scopes[key] }
2119
+ }
2120
+ function orderedItems(items, ids, getId) {
2121
+ const source = Array.isArray(items) ? items : []
2122
+ const byId = new Map(source.map(item => [String(getId(item)), item]))
2123
+ const result = []
2124
+ const used = new Set()
2125
+ for (const id of Array.isArray(ids) ? ids : []) {
2126
+ const key = String(id)
2127
+ const item = byId.get(key)
2128
+ if (item && !used.has(key)) { result.push(item); used.add(key) }
2129
+ }
2130
+ for (const item of source) {
2131
+ const key = String(getId(item))
2132
+ if (!used.has(key)) { result.push(item); used.add(key) }
2133
+ }
2134
+ return result
2135
+ }
2136
+ function orderedWorkspaceItems(items) {
2137
+ const { value } = workbenchOrderScopeValue()
2138
+ return orderedItems(items, value.workspaceIds, item => item.workspaceId)
2139
+ }
2140
+ function orderedWorkspaceSessions(workspaceId, items) {
2141
+ const { value } = workbenchOrderScopeValue()
2142
+ return orderedItems(items, value.sessionIds?.[String(workspaceId)], item => item.sessionId)
2143
+ }
2144
+ function saveWorkbenchOrder(mutator) {
2145
+ const { store, value } = workbenchOrderScopeValue()
2146
+ mutator(value)
2147
+ LS.set(WORKBENCH_ORDER_CACHE_KEY, JSON.stringify(store))
2148
+ }
2149
+ function commitWorkspaceOrder(order) {
2150
+ saveWorkbenchOrder(value => { value.workspaceIds = order.map(String) })
2151
+ renderWorkbench()
2152
+ toast(t('ds.wbOrderSaved'), 'ok')
2153
+ }
2154
+ function commitWorkspaceSessionOrder(workspaceId, order) {
2155
+ if (!workspaceId) return
2156
+ saveWorkbenchOrder(value => {
2157
+ value.sessionIds ||= {}
2158
+ value.sessionIds[String(workspaceId)] = order.map(String)
2159
+ })
2160
+ renderWorkbench()
2161
+ toast(t('ds.wbOrderSaved'), 'ok')
2162
+ }
1851
2163
  async function refreshWorkbench({ silent = false } = {}) {
1852
2164
  if (!state.token) { renderWorkbench(); return }
1853
2165
  let wb = null
@@ -1895,9 +2207,9 @@ async function refreshWorkbench({ silent = false } = {}) {
1895
2207
  }
1896
2208
  }
1897
2209
  } catch {}
1898
- state.wb.projects = items
2210
+ state.wb.projects = orderedWorkspaceItems(items
1899
2211
  .filter(w => wbStrictInside(w.path, state.wb.path))
1900
- .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true }))
2212
+ .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true })))
1901
2213
  renderWorkbench()
1902
2214
  renderSessions()
1903
2215
  }
@@ -1921,27 +2233,41 @@ function renderWorkbench() {
1921
2233
  const panel = $('wb-panel')
1922
2234
  panel.classList.toggle('hidden', !state.wb.expanded)
1923
2235
  if (!state.wb.expanded) return
1924
- const projects = state.wb.projects || []
2236
+ const projects = orderedWorkspaceItems(state.wb.projects || [])
1925
2237
  const archivedSet = new Set(state.archivedIds || [])
1926
2238
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1927
2239
  html += projects.length ? projects.map(w => {
1928
2240
  const id = String(w.workspaceId || '')
1929
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
2241
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a)))
1930
2242
  const open = state.wb.open === id
1931
- return `<div class="ds-wb-project ${open ? 'open' : ''}">
2243
+ return `<div class="ds-wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
1932
2244
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
2245
+ <span class="ds-wb-drag-handle" data-reorder-handle aria-hidden="true">⠿</span>
1933
2246
  <span class="ds-wb-caret" aria-hidden="true">${open ? '▾' : '▸'}</span>
1934
2247
  <span class="ds-wb-project-title" title="${esc(w.path)}">${esc(w.title || wbBaseName(w.path) || short(id))}</span>
1935
2248
  <span class="ds-wb-project-count">${sessions.length}</span>
1936
2249
  </button>
1937
2250
  <div class="ds-wb-project-body ${open ? '' : 'hidden'}">
1938
2251
  <button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
1939
- ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}"><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
2252
+ ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}"><span class="ds-wb-session-drag-handle" data-reorder-handle aria-hidden="true">⠿</span><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
1940
2253
  </div>
1941
2254
  </div>`
1942
2255
  }).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
1943
2256
  html += `<button type="button" class="ds-mini-btn ds-wb-unbind-panel" data-wb-unbind-panel>${esc(t('wb.unbind'))}</button>`
1944
- panel.innerHTML = html
2257
+ if (window.DshMotion?.relayout) {
2258
+ window.DshMotion.relayout(panel, '.ds-wb-project', () => { panel.innerHTML = html })
2259
+ } else panel.innerHTML = html
2260
+ window.DshMotion?.list(panel, '.ds-wb-session')
2261
+ window.DshMotion?.bindLongPressReorder(panel, '.ds-wb-project', {
2262
+ handleSelector: '.ds-wb-project-head',
2263
+ excludeSelector: '[data-wb-new]',
2264
+ onCommit: ({ order }) => commitWorkspaceOrder(order)
2265
+ })
2266
+ window.DshMotion?.bindLongPressReorder(panel, '.ds-wb-session', {
2267
+ groupSelector: '.ds-wb-project',
2268
+ handleSelector: '.ds-wb-session',
2269
+ onCommit: ({ item, order }) => commitWorkspaceSessionOrder(item.closest('[data-wb-project]')?.dataset.wbProject, order)
2270
+ })
1945
2271
  panel.querySelectorAll('[data-wb-head]').forEach(button => button.addEventListener('click', () => {
1946
2272
  state.wb.open = state.wb.open === button.dataset.wbHead ? null : button.dataset.wbHead
1947
2273
  renderWorkbench()
@@ -2052,7 +2378,7 @@ async function loadStats() {
2052
2378
  return
2053
2379
  }
2054
2380
  try {
2055
- const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token } })
2381
+ const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() } })
2056
2382
  if (!res.ok) throw new Error('HTTP ' + res.status)
2057
2383
  const json = await res.json()
2058
2384
  renderStats(json.days || [])
@@ -2150,7 +2476,7 @@ function renderOverviewDesktop() {
2150
2476
 
2151
2477
  const topSessions = topLevelSessions()
2152
2478
  const running = topSessions.filter(s => s.running).length
2153
- const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 6)
2479
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 6)
2154
2480
  const primary = $('ds-overview-primary-action')
2155
2481
  if (primary) {
2156
2482
  let action = 'new'
@@ -2180,7 +2506,7 @@ function renderOverviewDesktop() {
2180
2506
  $('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
2181
2507
  $('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
2182
2508
  $('ds-overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="ds-overview-session-item ${s.running ? 'running' : ''}" data-ds-overview-session="${esc(s.sessionId)}">
2183
- <span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(titleOf(s))}</span><span class="ds-overview-item-desc">${s.running ? esc(t('ds.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="ds-overview-arrow">›</span>
2509
+ <span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(titleOf(s))}</span><span class="ds-overview-item-desc">${s.running ? esc(t('ds.running')) + ' · ' : ''}${esc(fmtTime(sessionSortTime(s)))}</span></span><span class="ds-overview-arrow">›</span>
2184
2510
  </button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
2185
2511
  $('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
2186
2512
  }
@@ -2189,12 +2515,28 @@ function showView(id) {
2189
2515
  state.view = id
2190
2516
  for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
2191
2517
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
2518
+ window.DshMotion?.view($(id))
2192
2519
  const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
2193
2520
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
2194
2521
  else $('ds-title').textContent = t(titles[id])
2195
2522
  if (id === 'view-overview') renderOverviewDesktop()
2196
2523
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
2197
2524
  if (id === 'view-settings') showSettingsHome()
2525
+ updateSessionActions()
2526
+ }
2527
+
2528
+ function updateSessionActions() {
2529
+ const active = state.view === 'view-chat' && !!state.current
2530
+ $('btn-rename-session')?.classList.toggle('hidden', !active)
2531
+ $('btn-archive-session')?.classList.toggle('hidden', !active || state.archivedIds.includes(state.current))
2532
+ $('ds-session-status')?.classList.toggle('hidden', !active || !recoveryLabel())
2533
+ if (active) {
2534
+ const s = state.byId.get(state.current)
2535
+ $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions')
2536
+ $('ds-session-status').textContent = recoveryLabel()
2537
+ }
2538
+ const running = !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
2539
+ $('btn-cancel')?.classList.toggle('hidden', !active || !running)
2198
2540
  }
2199
2541
 
2200
2542
  const SETTINGS_GROUPS = ['general', 'servers', 'theme', 'about']
@@ -2328,6 +2670,13 @@ function bindUi() {
2328
2670
  $('btn-wb-path').addEventListener('click', () => { if (state.wb.path) toast(t('wb.boundPath', { path: state.wb.path }), 'ok') })
2329
2671
  $('btn-wb-unbind').addEventListener('click', unbindWorkbench)
2330
2672
  $('btn-send').addEventListener('click', sendMessage)
2673
+ $('btn-cancel').addEventListener('click', cancelSession)
2674
+ $('btn-rename-session').addEventListener('click', () => renameSession())
2675
+ $('btn-archive-session').addEventListener('click', archiveCurrentSession)
2676
+ $('rename-cancel').addEventListener('click', closeRenameSession)
2677
+ $('rename-confirm').addEventListener('click', confirmRenameSession)
2678
+ $('rename-session-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.isComposing) confirmRenameSession() })
2679
+ $('modal-rename').addEventListener('click', e => { if (e.target === $('modal-rename')) closeRenameSession() })
2331
2680
  $('composer').addEventListener('keydown', (e) => {
2332
2681
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
2333
2682
  })