dsh-remote-plugin 0.6.12 → 0.6.14

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
  /* ---------------- 皮肤 ---------------- */
@@ -48,6 +50,9 @@ function themeSet(id) { LS.set('dshTheme', id); themeApply() }
48
50
  themeApply()
49
51
 
50
52
  /* ---------------- 状态 ---------------- */
53
+ function emptyDesktopHistory() {
54
+ return { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity, partialReasoning: new Map() }
55
+ }
51
56
  const state = {
52
57
  token: LS.get('token', ''),
53
58
  wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
@@ -58,15 +63,19 @@ const state = {
58
63
  autoSelect: { '默认': true },
59
64
  groupActive: { '默认': '' },
60
65
  serverLatency: {},
66
+ gatewayHealth: {},
61
67
  selectingServer: false,
62
68
  sessions: [],
63
69
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
64
70
  byId: new Map(),
65
71
  current: null,
66
72
  hostInfo: null,
67
- history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
73
+ history: emptyDesktopHistory(),
68
74
  approvals: [],
69
75
  questions: [],
76
+ queues: {},
77
+ queueSteering: {},
78
+ sessionTurnTimes: {},
70
79
  questionModal: null,
71
80
  streamsOk: { mux: false, host: false },
72
81
  errCount: 0,
@@ -80,7 +89,8 @@ const state = {
80
89
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
81
90
  wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
82
91
  archivedIds: [],
83
- view: 'sessions'
92
+ view: 'sessions',
93
+ subagentExpandedSession: ''
84
94
  }
85
95
  const streams = {}
86
96
  let pollTimer = null
@@ -280,16 +290,31 @@ function renderEffortMenu() {
280
290
  const cur = state.models.current
281
291
  const provider = (state.models.groups || []).find(g => g.id === cur?.provider)
282
292
  const model = (provider?.models || []).find(m => m.id === cur?.model)
283
- const efforts = model?.reasoning?.efforts || []
284
- group.classList.toggle('hidden', !efforts.length)
293
+ const { efforts, defaultEffort, custom } = reasoningEffortOptions(model)
294
+ group.classList.toggle('hidden', !cur || !efforts.length)
285
295
  box.innerHTML = efforts.map(e => {
286
- const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === model.reasoning.defaultEffort)
296
+ const isCur = cur?.reasoningEffort === e.id || (!cur?.reasoningEffort && e.id === defaultEffort)
287
297
  return `<button class="ds-model-chip ${isCur ? 'current' : ''}" data-effort="${esc(e.id)}" title="${esc(e.description || '')}">${esc(e.name || e.id)}</button>`
288
- }).join('')
298
+ }).join('') + (custom ? `<span class="ds-effort-hint">${esc(t('models.effortCustomHint'))}</span>` : '')
289
299
  box.querySelectorAll('[data-effort]').forEach(btn =>
290
300
  btn.addEventListener('click', () => selectSessionEffort(btn.dataset.effort)))
291
301
  }
292
302
 
303
+ function reasoningEffortOptions(model) {
304
+ const raw = Array.isArray(model?.reasoning?.efforts) && model.reasoning.efforts.length
305
+ ? model.reasoning.efforts
306
+ : (Array.isArray(model?.reasoningEfforts) && model.reasoningEfforts.length ? model.reasoningEfforts : null)
307
+ const names = { low: t('models.effortLow'), high: t('models.effortHigh'), max: t('models.effortMax'), off: t('models.effortOff') }
308
+ if (raw) {
309
+ return {
310
+ efforts: raw.map(e => typeof e === 'string' ? { id: e, name: names[e] || e } : e),
311
+ defaultEffort: model?.reasoning?.defaultEffort,
312
+ custom: !model?.reasoning?.efforts
313
+ }
314
+ }
315
+ return { efforts: ['low', 'high', 'max'].map(id => ({ id, name: names[id] })), defaultEffort: undefined, custom: true }
316
+ }
317
+
293
318
  async function selectSessionEffort(effortId) {
294
319
  const cur = state.models.current
295
320
  if (!state.current || !cur) return
@@ -523,9 +548,10 @@ async function getWsTicket() {
523
548
  const token = state.token
524
549
  const server = state.server
525
550
  wsTicketPromise = (async () => {
551
+ if (activeGatewayCapability('wsTicket') === false) throw new Error('ws ticket unsupported')
526
552
  const res = await fetch(apiUrl('/api/ws-ticket'), {
527
553
  method: 'POST',
528
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web' }
554
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
529
555
  })
530
556
  if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
531
557
  const data = await res.json()
@@ -538,7 +564,7 @@ async function getWsTicket() {
538
564
  async function rpc(method, payload = {}, timeoutMs = 45000) {
539
565
  const opts = {
540
566
  method: 'POST',
541
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
567
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
542
568
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
543
569
  }
544
570
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -555,7 +581,7 @@ async function rpc(method, payload = {}, timeoutMs = 45000) {
555
581
  async function respond(rpcId, value) {
556
582
  const opts = {
557
583
  method: 'POST',
558
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
584
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
559
585
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
560
586
  }
561
587
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -645,9 +671,18 @@ async function pingServer(base) {
645
671
  const timer = setTimeout(() => ctrl.abort(), 3500)
646
672
  try {
647
673
  const res = await fetch(u + '/health?t=' + Date.now(), { signal: ctrl.signal, cache: 'no-store' })
648
- return res.ok ? Math.round(performance.now() - t0) : Infinity
674
+ if (!res.ok) return Infinity
675
+ const health = await res.json().catch(() => null)
676
+ if (health && typeof health === 'object') state.gatewayHealth[u] = health
677
+ return Math.round(performance.now() - t0)
649
678
  } catch { return Infinity } finally { clearTimeout(timer) }
650
679
  }
680
+ function activeGatewayCapability(name) {
681
+ const key = String(state.server || location.origin || '').replace(/\/+$/, '')
682
+ const capabilities = state.gatewayHealth[key]?.capabilities
683
+ if (!capabilities || !Object.prototype.hasOwnProperty.call(capabilities, name)) return null
684
+ return Number(capabilities[name]) > 0
685
+ }
651
686
  async function selectFastestServer({ silent = false, reconnect = true } = {}) {
652
687
  if (state.selectingServer) return null
653
688
  state.selectingServer = true
@@ -1065,7 +1100,7 @@ async function pollKind(kind) {
1065
1100
  let res
1066
1101
  try {
1067
1102
  const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(5000) : undefined
1068
- const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
1103
+ const headers = { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1069
1104
  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 })
1070
1105
  } catch { return }
1071
1106
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
@@ -1135,6 +1170,7 @@ function onMuxFrame(full) {
1135
1170
  return
1136
1171
  }
1137
1172
  if (f.type === 'question/resolved') { state.questions = state.questions.filter(q => q.rpcId !== f.questionRpcId); renderNotifStack(); return }
1173
+ if (f.type === 'session/queue') { state.queues[f.sessionId] = f.items || []; renderQueue(); return }
1138
1174
  if (f.type === 'session/projection') { applyProjection(f.sessionId, f.key, f.value, f.seq); return }
1139
1175
  if (f.type === 'stream/error') toast(f.error?.message || 'stream error', 'err')
1140
1176
  }
@@ -1144,7 +1180,7 @@ function onHostFrame(full) {
1144
1180
  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()
1145
1181
  if (f.type === 'host/session-status') {
1146
1182
  const s = state.byId.get(f.sessionId)
1147
- if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions(); renderOverviewDesktop() }
1183
+ if (s) { s.running = f.running; if (state.current === f.sessionId) { renderSessions(); renderQueue(); updateComposerStatus() } renderOverviewDesktop() }
1148
1184
  }
1149
1185
  }
1150
1186
  function applyProjection(sessionId, key, value, seq) {
@@ -1163,6 +1199,10 @@ function applyProjection(sessionId, key, value, seq) {
1163
1199
  }
1164
1200
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1165
1201
  function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
1202
+ function isTopLevelSession(session) {
1203
+ return !!session && !session.parentSessionId && session.origin !== 'subagent'
1204
+ }
1205
+ function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1166
1206
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1167
1207
  function isGoalTerminal(goal) {
1168
1208
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1173,10 +1213,24 @@ function goalOf(s) {
1173
1213
  return p.goal && typeof p.goal === 'object' ? p.goal : p
1174
1214
  }
1175
1215
  function onSessionEvent(sessionId, event) {
1216
+ if (event?.type === 'turn/start' || event?.type === 'turn/end') {
1217
+ noteSessionTurnTime(sessionId, event)
1218
+ renderSessions()
1219
+ }
1220
+ const session = state.byId.get(sessionId)
1221
+ if (event?.type === 'agent/status' && session) {
1222
+ session.running = !!event.data?.running
1223
+ if (state.current === sessionId) { renderQueue(); updateComposerStatus() }
1224
+ }
1176
1225
  if (state.current === sessionId && event) {
1177
1226
  const h = state.history
1227
+ const reasoningChanged = applyReasoningStreamEvent(event)
1228
+ if (event.type === 'assistant/chunk' || event.type === 'reasoning-chunks') {
1229
+ if (reasoningChanged) scheduleReasoningRender()
1230
+ return
1231
+ }
1178
1232
  const seq = event.seq
1179
- if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type)) {
1233
+ if (seq != null && !h.seqs.has(seq) && shouldShowEvent(event.type, event)) {
1180
1234
  h.seqs.add(seq)
1181
1235
  h.visible.push({ seq, event })
1182
1236
  h.visible.sort((a, b) => a.seq - b.seq)
@@ -1201,6 +1255,15 @@ function sessionWorkspaceLabel(s) {
1201
1255
  const cwd = sessionCwd(s)
1202
1256
  return cwd || t('ds.workspaceUnknown')
1203
1257
  }
1258
+ function sessionSortTime(s) {
1259
+ return Math.max(Number(state.sessionTurnTimes[s?.sessionId]) || 0, Number(s?.updatedAt) || 0, Number(s?.createdAt) || 0)
1260
+ }
1261
+ function noteSessionTurnTime(sessionId, eventOrTime) {
1262
+ const raw = typeof eventOrTime === 'object' ? eventOrTime?.time : eventOrTime
1263
+ const time = Number(raw) > 0 ? Number(raw) : Date.now()
1264
+ if (!sessionId || !Number.isFinite(time)) return
1265
+ state.sessionTurnTimes[sessionId] = Math.max(Number(state.sessionTurnTimes[sessionId]) || 0, time)
1266
+ }
1204
1267
  function workspaceDisplayName(label) {
1205
1268
  const value = String(label || '').trim()
1206
1269
  if (!value || value === t('ds.workspaceUnknown')) return value || t('ds.workspaceUnknown')
@@ -1209,16 +1272,16 @@ function workspaceDisplayName(label) {
1209
1272
  return parts[parts.length - 1] || value
1210
1273
  }
1211
1274
  function sortedSessions() {
1212
- const items = [...state.sessions]
1275
+ const items = topLevelSessions()
1213
1276
  if (state.sessionSort === 'workspace') {
1214
1277
  return items.sort((a, b) => {
1215
1278
  const aw = sessionCwd(a) || '\uffff'
1216
1279
  const bw = sessionCwd(b) || '\uffff'
1217
1280
  const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1218
- return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1281
+ return byWorkspace || (sessionSortTime(b) - sessionSortTime(a))
1219
1282
  })
1220
1283
  }
1221
- return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1284
+ return items.sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1222
1285
  }
1223
1286
  function renderSessions() {
1224
1287
  const allItems = sortedSessions()
@@ -1248,7 +1311,7 @@ function renderSessions() {
1248
1311
  rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1249
1312
  <span class="ds-session-title">${esc(title)}</span>
1250
1313
  <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1251
- <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1314
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(sessionSortTime(s))}</span>
1252
1315
  </button>`)
1253
1316
  }
1254
1317
  return rows.join('')
@@ -1272,20 +1335,24 @@ function renderSessions() {
1272
1335
 
1273
1336
  async function openSession(id) {
1274
1337
  state.current = id
1275
- state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1338
+ state.history = emptyDesktopHistory()
1276
1339
  state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
1277
1340
  showView('view-chat')
1278
1341
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
1279
1342
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
1280
1343
  renderSessions()
1281
1344
  renderSessionCards()
1345
+ renderQueue()
1346
+ updateComposerStatus()
1282
1347
  await loadHistory()
1283
1348
  }
1284
1349
  function closeSession() {
1285
1350
  state.current = null
1286
- state.history = { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity }
1351
+ state.history = emptyDesktopHistory()
1287
1352
  const cards = $('session-cards')
1288
1353
  if (cards) cards.innerHTML = ''
1354
+ renderQueue()
1355
+ updateComposerStatus()
1289
1356
  showView('view-sessions')
1290
1357
  }
1291
1358
  async function loadHistory() {
@@ -1303,8 +1370,10 @@ async function loadHistory() {
1303
1370
  for (const entry of v.events || []) {
1304
1371
  const ev = entry?.event
1305
1372
  const seq = ev?.seq
1373
+ if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1374
+ applyReasoningStreamEvent(ev)
1306
1375
  if (seq == null || state.history.seqs.has(seq)) continue
1307
- if (!shouldShowEvent(ev.type)) continue
1376
+ if (!shouldShowEvent(ev.type, ev)) continue
1308
1377
  state.history.seqs.add(seq)
1309
1378
  state.history.visible.push({ seq, event: ev })
1310
1379
  }
@@ -1321,12 +1390,78 @@ const INTERESTING_EVENTS = new Set([
1321
1390
  'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
1322
1391
  'approval/asked', 'approval/resolved', 'session/title', 'title'
1323
1392
  ])
1324
- function shouldShowEvent(type) { return INTERESTING_EVENTS.has(type) }
1393
+ function messageSource(data) {
1394
+ const source = data?.source ?? data?.message?.source
1395
+ return source && typeof source === 'object' ? source : null
1396
+ }
1397
+ function isHumanUserMessage(event) {
1398
+ if (event?.type !== 'user/message') return false
1399
+ const source = messageSource(event.data || {})
1400
+ // Older DSH events may not carry source metadata; keep those visible for compatibility.
1401
+ return !source || source.kind === 'user'
1402
+ }
1403
+ function shouldShowEvent(type, event) {
1404
+ if (!INTERESTING_EVENTS.has(type)) return false
1405
+ if (type === 'user/message' && !isHumanUserMessage(event)) {
1406
+ const data = event?.data || {}
1407
+ const blocks = data.message?.content || data.content || []
1408
+ return systemReminderText(blocks).length > 0
1409
+ }
1410
+ return true
1411
+ }
1412
+ function reasoningStreamKey(data, index) { return `${data?.turn ?? '?'}:${data?.step ?? '?'}:${index ?? '?'}` }
1413
+ function applyReasoningStreamEvent(event) {
1414
+ const h = state.history
1415
+ const data = event?.data || {}
1416
+ let changed = false
1417
+ if (event?.type === 'assistant/chunk') {
1418
+ const chunk = data.chunk || {}
1419
+ const key = reasoningStreamKey(data, chunk.index)
1420
+ if (chunk.type === 'block-start' && chunk.blockType === 'reasoning') {
1421
+ h.partialReasoning.set(key, { turn: data.turn, step: data.step, index: chunk.index, text: '' })
1422
+ changed = true
1423
+ } else if (chunk.type === 'reasoning-delta') {
1424
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: chunk.index, text: '' }
1425
+ item.text += String(chunk.text || '')
1426
+ h.partialReasoning.set(key, item)
1427
+ changed = true
1428
+ } else if (chunk.type === 'block-end' && chunk.block?.type === 'reasoning') {
1429
+ h.partialReasoning.set(key, { turn: data.turn, step: data.step, index: chunk.index, text: String(chunk.block.text ?? chunk.block.content ?? '') })
1430
+ changed = true
1431
+ }
1432
+ } else if (event?.type === 'reasoning-chunks') {
1433
+ const key = reasoningStreamKey(data, data.index)
1434
+ const item = h.partialReasoning.get(key) || { turn: data.turn, step: data.step, index: data.index, text: '' }
1435
+ item.text += Array.isArray(data.texts) ? data.texts.join('') : String(data.text || '')
1436
+ h.partialReasoning.set(key, item)
1437
+ changed = true
1438
+ } else if (event?.type === 'assistant/message') {
1439
+ for (const [key, item] of h.partialReasoning) {
1440
+ if (item.turn === data.turn && item.step === data.step) { h.partialReasoning.delete(key); changed = true }
1441
+ }
1442
+ }
1443
+ return changed
1444
+ }
1445
+ let reasoningRenderTimer = null
1446
+ function scheduleReasoningRender() {
1447
+ if (reasoningRenderTimer) return
1448
+ reasoningRenderTimer = setTimeout(() => {
1449
+ reasoningRenderTimer = null
1450
+ if (state.current) renderHistory()
1451
+ }, 80)
1452
+ }
1453
+ function partialReasoningHtml() {
1454
+ return [...state.history.partialReasoning.values()]
1455
+ .filter(item => item.text)
1456
+ .sort((a, b) => (a.turn ?? 0) - (b.turn ?? 0) || (a.step ?? 0) - (b.step ?? 0) || (a.index ?? 0) - (b.index ?? 0))
1457
+ .map(item => `<div class="ds-msg assistant ds-reasoning-live"><div class="role">${esc(t('ds.role.dsh'))}</div><details open><summary>${esc(t('block.thinkingLive'))}</summary><div>${esc(item.text)}</div></details></div>`)
1458
+ .join('')
1459
+ }
1325
1460
  function safeJson(v) { try { return JSON.stringify(v, null, 2) } catch { return String(v) } }
1326
1461
  function blockHtml(b) {
1327
1462
  if (!b) return ''
1328
1463
  if (b.type === 'text') return `<div class="md">${window.mdToHtml ? window.mdToHtml(b.text ?? '') : esc(b.text ?? '')}</div>`
1329
- if (b.type === 'reasoning') return `<span style="opacity:.75">${esc(b.text ?? '')}</span>`
1464
+ if (b.type === 'thinking' || b.type === 'reasoning') return `<details><summary>${esc(t('block.thinking'))}</summary><div style="opacity:.82">${esc(b.text ?? b.content ?? '')}</div></details>`
1330
1465
  if (b.type === 'tool-call') return `<div>🔧 ${esc(b.name || '')}</div>`
1331
1466
  if (b.type === 'tool-result') return `<div>📦</div>`
1332
1467
  if (b.type === 'image') return `<div>🖼</div>`
@@ -1343,7 +1478,7 @@ function eventHtml(entry) {
1343
1478
  const ev = entry.event || {}
1344
1479
  const data = ev.data || {}
1345
1480
  const type = ev.type || 'event'
1346
- if (!shouldShowEvent(type)) return ''
1481
+ if (!shouldShowEvent(type, ev)) return ''
1347
1482
  if (type === 'user/message' || type === 'assistant/message') {
1348
1483
  const msg = data.message || {}
1349
1484
  const role = data.role || msg.role || (type.startsWith('user') ? 'user' : 'assistant')
@@ -1353,6 +1488,7 @@ function eventHtml(entry) {
1353
1488
  const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
1354
1489
  return `<details class="event ds-tool ds-event-detail"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1355
1490
  }
1491
+ if (type === 'user/message' && !isHumanUserMessage(ev)) return ''
1356
1492
  const text = blocks.map(blockHtml).join('')
1357
1493
  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>`
1358
1494
  }
@@ -1373,7 +1509,8 @@ function eventHtml(entry) {
1373
1509
  function renderHistory() {
1374
1510
  const box = $('history')
1375
1511
  const items = state.history.visible
1376
- box.innerHTML = items.map(eventHtml).join('') || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
1512
+ const html = items.map(eventHtml).join('') + partialReasoningHtml()
1513
+ box.innerHTML = html || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
1377
1514
  box.scrollTop = box.scrollHeight
1378
1515
  }
1379
1516
 
@@ -1412,13 +1549,19 @@ async function renderSessionCards() {
1412
1549
  const sub = await safeRpc('subagent.list', { parentSessionId: sessionId }, '')
1413
1550
  if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
1414
1551
  if (sub?.entries?.length) {
1552
+ const expanded = state.subagentExpandedSession === sessionId
1553
+ const toggleLabel = expanded ? t('subagent.collapse') : t('subagent.expand')
1415
1554
  const rows = sub.entries.map(e => {
1416
1555
  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>`
1417
1556
  const label = e.label || short(e.id)
1418
1557
  const running = e.activity === 'running'
1419
1558
  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>`
1420
1559
  }).join('')
1421
- box.insertAdjacentHTML('beforeend', `<div class="ds-card"><div class="ds-card-title">${t('subagent.title')}</div>${rows}</div>`)
1560
+ box.insertAdjacentHTML('beforeend', `<div class="ds-card ds-subagent-card"><button type="button" class="ds-subagent-toggle" data-subagent-toggle aria-expanded="${expanded}" aria-label="${esc(toggleLabel)}" title="${esc(toggleLabel)}"><span class="ds-card-title">${esc(t('subagent.count', { n: sub.entries.length }))}</span><span class="ds-subagent-toggle-icon" aria-hidden="true">${expanded ? '⌃' : '⌄'}</span></button><div class="ds-subagent-list${expanded ? '' : ' hidden'}">${rows}</div></div>`)
1561
+ box.querySelector('[data-subagent-toggle]')?.addEventListener('click', () => {
1562
+ state.subagentExpandedSession = expanded ? '' : sessionId
1563
+ renderSessionCards()
1564
+ })
1422
1565
  box.querySelectorAll('[data-sub-interrupt]').forEach(btn =>
1423
1566
  btn.addEventListener('click', () => interruptSubagent(btn.dataset.subInterrupt)))
1424
1567
  }
@@ -1481,7 +1624,7 @@ async function runSlashCommand(text) {
1481
1624
  : undefined
1482
1625
  const res = await fetch(apiUrl('/remote/api/command'), {
1483
1626
  method: 'POST',
1484
- headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
1627
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
1485
1628
  body: JSON.stringify({ sessionId: state.current, line: clean }),
1486
1629
  ...(signal ? { signal } : {})
1487
1630
  })
@@ -1507,7 +1650,52 @@ async function sendMessage() {
1507
1650
  mode: 'queue',
1508
1651
  content: [{ type: 'text', text }]
1509
1652
  }, '')
1510
- if (v) toast(t('ds.toastSent'), 'ok')
1653
+ if (v) { noteSessionTurnTime(state.current, Date.now()); renderSessions(); toast(t('ds.toastSent'), 'ok') }
1654
+ }
1655
+
1656
+ function updateComposerStatus() {
1657
+ const status = $('composer-status')
1658
+ if (!status) return
1659
+ status.classList.toggle('hidden', !state.byId.get(state.current)?.running)
1660
+ }
1661
+ function queuePreview(item) {
1662
+ const blocks = item?.message?.content || item?.content || []
1663
+ const text = Array.isArray(blocks)
1664
+ ? blocks.filter(block => block?.type === 'text').map(block => String(block.text || '')).join(' ').trim()
1665
+ : ''
1666
+ return text || (Array.isArray(blocks) && blocks.some(block => block?.type === 'image') ? t('queue.image') : '…')
1667
+ }
1668
+ async function steerQueueItem(itemId) {
1669
+ const sessionId = state.current
1670
+ const key = `${sessionId}:${itemId}`
1671
+ const s = state.byId.get(sessionId)
1672
+ if (!sessionId || !s?.running || state.queueSteering[key]) return
1673
+ state.queueSteering[key] = true
1674
+ renderQueue()
1675
+ try {
1676
+ const v = await safeRpc('session.updateQueue', { sessionId, itemId, action: { kind: 'steer' } }, t('queue.steerFailed', { msg: '' }).replace(/:$/, '').replace(/: $/, ''))
1677
+ if (v?.accepted) toast(t('queue.steerSubmitted'), 'ok')
1678
+ } finally {
1679
+ delete state.queueSteering[key]
1680
+ renderQueue()
1681
+ }
1682
+ }
1683
+ function renderQueue() {
1684
+ const box = $('queue-dock')
1685
+ if (!box) return
1686
+ const sessionId = state.current
1687
+ const s = state.byId.get(sessionId)
1688
+ const items = (state.queues[sessionId] || []).filter(item => item?.placement === 'queued')
1689
+ box.classList.toggle('hidden', !items.length)
1690
+ 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 => {
1691
+ const key = `${sessionId}:${item.id}`
1692
+ const busy = !!state.queueSteering[key]
1693
+ 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>`
1694
+ }).join('')}</div>` : ''
1695
+ box.querySelectorAll('[data-queue-steer]').forEach(button => {
1696
+ button.addEventListener('click', () => steerQueueItem(button.dataset.queueSteer))
1697
+ })
1698
+ updateComposerStatus()
1511
1699
  }
1512
1700
 
1513
1701
  /* ---------------- 审批/提问通知卡片栈 ---------------- */
@@ -1621,7 +1809,7 @@ function fsApiUrl(sub, params = {}) {
1621
1809
  return u.href
1622
1810
  }
1623
1811
  function fsHeaders() {
1624
- return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' }
1812
+ return { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() }
1625
1813
  }
1626
1814
  function fsParent(p) {
1627
1815
  if (!p) return null
@@ -1749,7 +1937,7 @@ function wbFsParent(p) {
1749
1937
  return raw.slice(0, index)
1750
1938
  }
1751
1939
  async function wbGateway(method, pathname, body) {
1752
- const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' } }
1940
+ const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() } }
1753
1941
  if (body !== undefined) {
1754
1942
  options.headers['content-type'] = 'application/json'
1755
1943
  options.body = JSON.stringify(body)
@@ -1838,7 +2026,7 @@ function renderWorkbench() {
1838
2026
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1839
2027
  html += projects.length ? projects.map(w => {
1840
2028
  const id = String(w.workspaceId || '')
1841
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
2029
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a))
1842
2030
  const open = state.wb.open === id
1843
2031
  return `<div class="ds-wb-project ${open ? 'open' : ''}">
1844
2032
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
@@ -1964,7 +2152,7 @@ async function loadStats() {
1964
2152
  return
1965
2153
  }
1966
2154
  try {
1967
- const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token } })
2155
+ const res = await fetch(apiUrl('/stats/summary?days=7'), { headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() } })
1968
2156
  if (!res.ok) throw new Error('HTTP ' + res.status)
1969
2157
  const json = await res.json()
1970
2158
  renderStats(json.days || [])
@@ -2060,8 +2248,9 @@ function renderOverviewDesktop() {
2060
2248
  $('ds-overview-attention-list').querySelectorAll('[data-ds-overview-approve]').forEach(btn => btn.addEventListener('click', () => approveApproval(btn.closest('[data-ds-overview-approval]')?.dataset.dsOverviewApproval || '', btn.dataset.dsOverviewApprove === '1')))
2061
2249
  $('ds-overview-attention-list').querySelectorAll('[data-ds-overview-question]').forEach(btn => btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.dsOverviewQuestion))))
2062
2250
 
2063
- const running = state.sessions.filter(s => s.running).length
2064
- const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 6)
2251
+ const topSessions = topLevelSessions()
2252
+ const running = topSessions.filter(s => s.running).length
2253
+ const sessions = topSessions.sort((a, b) => Number(b.running) - Number(a.running) || (sessionSortTime(b) - sessionSortTime(a))).slice(0, 6)
2065
2254
  const primary = $('ds-overview-primary-action')
2066
2255
  if (primary) {
2067
2256
  let action = 'new'
@@ -2091,7 +2280,7 @@ function renderOverviewDesktop() {
2091
2280
  $('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
2092
2281
  $('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
2093
2282
  $('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)}">
2094
- <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>
2283
+ <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>
2095
2284
  </button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
2096
2285
  $('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
2097
2286
  }