dsh-remote-plugin 0.6.7 → 0.6.9

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.
@@ -11,6 +11,16 @@ const LS = {
11
11
  set(k, v) { try { localStorage.setItem(k, v) } catch {} },
12
12
  del(k) { try { localStorage.removeItem(k) } catch {} }
13
13
  }
14
+ const CLIENT_ID = (() => {
15
+ try {
16
+ let id = sessionStorage.getItem('dshRemoteClientId')
17
+ if (!id) {
18
+ id = (globalThis.crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`)
19
+ sessionStorage.setItem('dshRemoteClientId', id)
20
+ }
21
+ return id
22
+ } catch { return '' }
23
+ })()
14
24
  const CAP = window.Capacitor || null
15
25
 
16
26
  /* ---------------- 皮肤 ---------------- */
@@ -40,6 +50,7 @@ themeApply()
40
50
  /* ---------------- 状态 ---------------- */
41
51
  const state = {
42
52
  token: LS.get('token', ''),
53
+ wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
43
54
  server: '',
44
55
  servers: [],
45
56
  groups: ['默认'],
@@ -52,12 +63,17 @@ const state = {
52
63
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
53
64
  byId: new Map(),
54
65
  current: null,
66
+ hostInfo: null,
55
67
  history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
56
68
  approvals: [],
57
69
  questions: [],
58
70
  questionModal: null,
59
71
  streamsOk: { mux: false, host: false },
60
72
  errCount: 0,
73
+ streamInfo: {
74
+ mux: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
75
+ host: { status: 'idle', lastOpenAt: 0, lastCloseAt: 0, lastCloseCode: 0, lastCloseReason: '' },
76
+ },
61
77
  streamMode: 'ws', // 'ws' | 'poll'
62
78
  pollSeq: { mux: 0, host: 0 },
63
79
  fs: { path: null, initial: null, loaded: false },
@@ -498,6 +514,27 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
498
514
 
499
515
  /* ---------------- API ---------------- */
500
516
  function apiUrl(path) { return (state.server || '') + path }
517
+ let wsTicketPromise = null
518
+ async function getWsTicket() {
519
+ const now = Date.now()
520
+ if (state.wsTicket.token === state.token && state.wsTicket.server === state.server &&
521
+ state.wsTicket.value && state.wsTicket.expiresAt > now + 15000) return state.wsTicket.value
522
+ if (wsTicketPromise) return wsTicketPromise
523
+ const token = state.token
524
+ const server = state.server
525
+ wsTicketPromise = (async () => {
526
+ const res = await fetch(apiUrl('/api/ws-ticket'), {
527
+ method: 'POST',
528
+ headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web' }
529
+ })
530
+ if (!res.ok) throw new Error('ws ticket HTTP ' + res.status)
531
+ const data = await res.json()
532
+ if (!data?.ticket || !Number(data.expiresAt)) throw new Error('invalid ws ticket')
533
+ state.wsTicket = { token, server, value: data.ticket, expiresAt: Number(data.expiresAt) }
534
+ return data.ticket
535
+ })()
536
+ try { return await wsTicketPromise } finally { wsTicketPromise = null }
537
+ }
501
538
  async function rpc(method, payload = {}, timeoutMs = 45000) {
502
539
  const opts = {
503
540
  method: 'POST',
@@ -817,12 +854,47 @@ function deleteGroup(name) {
817
854
  }
818
855
 
819
856
  /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
857
+ const streamMeta = {
858
+ mux: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
859
+ host: { generation: 0, attempt: 0, failures: 0, retryTimer: null },
860
+ }
861
+
820
862
  function clearStreamTimers(ws) {
821
863
  if (!ws) return
822
- clearInterval(ws._hbTimer)
823
- clearInterval(ws._staleTimer)
824
- ws._hbTimer = null
825
- ws._staleTimer = null
864
+ if (ws._retryTimer) clearTimeout(ws._retryTimer)
865
+ ws._retryTimer = null
866
+ }
867
+
868
+ function streamIsCurrent(kind, ws, generation) {
869
+ return streams[kind] === ws && streamMeta[kind].generation === generation
870
+ }
871
+
872
+ function aggregateStreamFailures() {
873
+ state.errCount = Math.max(streamMeta.mux.failures, streamMeta.host.failures)
874
+ }
875
+
876
+ function markStreamInfo(kind, patch) {
877
+ state.streamInfo[kind] = { ...state.streamInfo[kind], ...patch }
878
+ }
879
+
880
+ function allStreamsOpen() {
881
+ return streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN
882
+ }
883
+
884
+ function clearStreamRetry(kind) {
885
+ const meta = streamMeta[kind]
886
+ if (meta.retryTimer) clearTimeout(meta.retryTimer)
887
+ meta.retryTimer = null
888
+ }
889
+
890
+ function closeStream(kind) {
891
+ const meta = streamMeta[kind]
892
+ clearStreamRetry(kind)
893
+ meta.generation++
894
+ const ws = streams[kind]
895
+ streams[kind] = null
896
+ state.streamsOk[kind] = false
897
+ try { ws?.close() } catch {}
826
898
  }
827
899
 
828
900
  function clearConnTick() {
@@ -854,78 +926,97 @@ function clearReconnect() {
854
926
 
855
927
  function openStreams() {
856
928
  if (!state.token) return
857
- if (state.streamMode === 'poll') stopPolling()
858
- state.streamMode = 'ws'
859
- clearReconnect()
929
+ if (state.streamMode !== 'poll') state.streamMode = 'ws'
860
930
  openStream('mux', onMuxFrame, true)
861
931
  openStream('host', onHostFrame, false)
862
932
  }
863
- function openStream(kind, handler, refreshOnOpen, isRestore) {
933
+ function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
864
934
  if (!state.token) return
935
+ if (ticket === null) {
936
+ const token = state.token
937
+ void getWsTicket().then((value) => {
938
+ if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
939
+ }).catch(() => {
940
+ // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
941
+ if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
942
+ })
943
+ return
944
+ }
865
945
  let base
866
946
  if (state.server) base = state.server.replace(/^http/, 'ws')
867
947
  else { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; base = `${proto}//${location.host}` }
868
- const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
869
- try { streams[kind]?.close() } catch {}
948
+ const auth = ticket ? `ticket=${encodeURIComponent(ticket)}` : `token=${encodeURIComponent(state.token)}`
949
+ const clientId = CLIENT_ID ? `&clientId=${encodeURIComponent(CLIENT_ID)}` : ''
950
+ const streamUrl = `${base}/api/events.${kind}?${auth}&client=web${clientId}`
951
+ const current = streams[kind]
952
+ if (current?._streamUrl === streamUrl &&
953
+ (current.readyState === WebSocket.OPEN || current.readyState === WebSocket.CONNECTING)) return
954
+ if (current && current._streamUrl !== streamUrl) {
955
+ streamMeta[kind].attempt = 0
956
+ streamMeta[kind].failures = 0
957
+ aggregateStreamFailures()
958
+ }
959
+ closeStream(kind)
960
+ const meta = streamMeta[kind]
961
+ const generation = meta.generation
962
+ const ws = new WebSocket(streamUrl)
870
963
  streams[kind] = ws
871
- ws._attempt = 0
872
- ws._lastMsgAt = 0
964
+ ws._streamUrl = streamUrl
965
+ ws._generation = generation
873
966
  ws._isRestore = !!isRestore
874
967
  ws.onopen = () => {
968
+ if (!streamIsCurrent(kind, ws, generation)) return
875
969
  state.streamsOk[kind] = true
876
- state.errCount = 0
877
- ws._attempt = 0
878
- ws._lastMsgAt = Date.now()
970
+ markStreamInfo(kind, { status: 'open', lastOpenAt: Date.now(), lastCloseCode: 0, lastCloseReason: '' })
971
+ meta.attempt = 0
972
+ meta.failures = 0
973
+ aggregateStreamFailures()
879
974
  clearStreamTimers(ws)
880
- // 应用层心跳: 25s 发纯文本 ping, NAT/WiFi 切换后的 WS 半开假活
881
- ws._hbTimer = setInterval(() => {
882
- try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
883
- }, 25000)
884
- // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
885
- ws._staleTimer = setInterval(() => {
886
- if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
887
- try { ws.close() } catch {}
888
- }
889
- }, 10000)
890
- if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
891
- if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
975
+ // DSH mux/host 是只下行 WebSocket, 浏览器不能发送应用层 ping。
976
+ // 网关负责 RFC6455 Ping/Pong, 前端只监听业务帧和 close 事件。
977
+ if (state.streamMode === 'poll' && allStreamsOpen()) {
978
+ stopPolling()
979
+ state.streamMode = 'ws'
980
+ }
981
+ if (allStreamsOpen()) clearReconnect()
892
982
  updateConn()
893
983
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
894
984
  if (refreshOnOpen) refreshSessions()
895
985
  }
896
986
  ws.onmessage = (msg) => {
897
- ws._lastMsgAt = Date.now()
987
+ if (!streamIsCurrent(kind, ws, generation)) return
898
988
  state.streamsOk[kind] = true
899
- state.errCount = 0
900
989
  updateConn()
901
990
  try { handler(JSON.parse(msg.data)) } catch {}
902
991
  }
903
992
  ws.onclose = () => {
904
993
  clearStreamTimers(ws)
994
+ if (!streamIsCurrent(kind, ws, generation)) return
995
+ streams[kind] = null
905
996
  state.streamsOk[kind] = false
906
- state.errCount++
997
+ markStreamInfo(kind, {
998
+ status: 'closed',
999
+ lastCloseAt: Date.now(),
1000
+ lastCloseCode: Number(ws.code) || 0,
1001
+ lastCloseReason: String(ws.reason || ''),
1002
+ })
1003
+ meta.failures++
1004
+ aggregateStreamFailures()
907
1005
  updateConn()
908
1006
  if (!navigator.onLine) { clearReconnect(); return }
909
- if (state.streamMode === 'poll') {
910
- // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
911
- if (ws._isRestore && streams[kind] === ws) {
912
- const attempt = ws._attempt || 0
913
- ws._attempt = attempt + 1
914
- const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
915
- const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
916
- setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
917
- }
918
- return
919
- }
920
- if (state.errCount >= 3) { enterPollMode(); return }
921
- if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
922
- // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
923
- const attempt = ws._attempt || 0
924
- ws._attempt = attempt + 1
925
- const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
1007
+ // 任一通道连续失败 3 次就降级轮询;另一个通道不会清零它的失败计数。
1008
+ if (state.streamMode !== 'poll' && meta.failures >= 3) { enterPollMode(); return }
1009
+ if (state.servers.length && meta.failures % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
1010
+ // VPN/跨地域链路使用更宽松的指数退避: 1.5s 起步, 最大 60s, 带 20% 抖动。
1011
+ const attempt = meta.attempt++
1012
+ const baseDelay = Math.min(1500 * Math.pow(2, attempt), 60000)
926
1013
  const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
927
1014
  setReconnect(delay)
928
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
1015
+ clearStreamRetry(kind)
1016
+ meta.retryTimer = setTimeout(() => {
1017
+ meta.retryTimer = null
1018
+ if (state.token && navigator.onLine) openStream(kind, handler, refreshOnOpen, state.streamMode === 'poll')
1019
+ }, delay)
929
1020
  }
930
1021
  ws.onerror = () => { try { ws.close() } catch {} }
931
1022
  }
@@ -936,10 +1027,8 @@ function enterPollMode() {
936
1027
  state.streamMode = 'poll'
937
1028
  state.pollSeq = { mux: 0, host: 0 }
938
1029
  state.streamsOk = { mux: false, host: false }
939
- try { streams.mux?.close() } catch {}
940
- try { streams.host?.close() } catch {}
941
- streams.mux = null
942
- streams.host = null
1030
+ closeStream('mux')
1031
+ closeStream('host')
943
1032
  refreshSessions()
944
1033
  startPolling()
945
1034
  updateConn()
@@ -1002,15 +1091,15 @@ async function pollKind(kind) {
1002
1091
  function tryRestoreWs() {
1003
1092
  if (state.streamMode !== 'poll' || !state.token) return
1004
1093
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
1005
- openStream('mux', onMuxFrame, true, true)
1006
- openStream('host', onHostFrame, false, true)
1094
+ if (!streams.mux && !streamMeta.mux.retryTimer) openStream('mux', onMuxFrame, true, true)
1095
+ if (!streams.host && !streamMeta.host.retryTimer) openStream('host', onHostFrame, false, true)
1007
1096
  }
1008
1097
 
1009
1098
  /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
1010
1099
  window.addEventListener('offline', () => {
1011
1100
  clearReconnect()
1012
- try { streams.mux?.close() } catch {}
1013
- try { streams.host?.close() } catch {}
1101
+ closeStream('mux')
1102
+ closeStream('host')
1014
1103
  if (state.streamMode === 'poll') stopPolling()
1015
1104
  updateConn()
1016
1105
  })
@@ -1055,7 +1144,7 @@ function onHostFrame(full) {
1055
1144
  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()
1056
1145
  if (f.type === 'host/session-status') {
1057
1146
  const s = state.byId.get(f.sessionId)
1058
- if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions() }
1147
+ if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions(); renderOverviewDesktop() }
1059
1148
  }
1060
1149
  }
1061
1150
  function applyProjection(sessionId, key, value, seq) {
@@ -1100,11 +1189,12 @@ function onSessionEvent(sessionId, event) {
1100
1189
  /* ---------------- 会话 ---------------- */
1101
1190
  async function refreshSessions() {
1102
1191
  const v = await safeRpc('session.list', {}, '')
1103
- if (!v) { renderSessions(); return }
1192
+ if (!v) { renderSessions(); renderOverviewDesktop(); return }
1104
1193
  state.sessions = v.items || []
1105
1194
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1106
1195
  renderSessions()
1107
1196
  scheduleWorkbenchRefresh()
1197
+ renderOverviewDesktop()
1108
1198
  }
1109
1199
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1110
1200
  function sessionWorkspaceLabel(s) {
@@ -1135,8 +1225,12 @@ function renderSessions() {
1135
1225
  const wbIds = new Set()
1136
1226
  if (state.wb.bound && state.wb.projects) for (const w of state.wb.projects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1137
1227
  const root = state.wb.bound ? state.wb.path : ''
1138
- const visible = allItems.filter(s => !(state.wb.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1139
1228
  const archivedSet = new Set(state.archivedIds || [])
1229
+ const visible = allItems.filter(s => {
1230
+ if (!state.wb.bound) return true
1231
+ if (archivedSet.has(s.sessionId)) return true
1232
+ return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1233
+ })
1140
1234
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1141
1235
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
1142
1236
  const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
@@ -1257,7 +1351,7 @@ function eventHtml(entry) {
1257
1351
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1258
1352
  if (sysText) {
1259
1353
  const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
1260
- return `<details class="event ds-tool"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1354
+ return `<details class="event ds-tool ds-event-detail"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1261
1355
  }
1262
1356
  const text = blocks.map(blockHtml).join('')
1263
1357
  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>`
@@ -1457,6 +1551,7 @@ function renderNotifStack() {
1457
1551
  stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
1458
1552
  if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
1459
1553
  }))
1554
+ renderOverviewDesktop()
1460
1555
  }
1461
1556
  async function approveApproval(id, allow) {
1462
1557
  const a = state.approvals.find(x => x.approvalId === id)
@@ -1594,7 +1689,7 @@ async function loadFs(dir, silent) {
1594
1689
  $('fs-path').textContent = data.path
1595
1690
  $('fs-list').innerHTML = (data.entries || []).map(e => `
1596
1691
  <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
1597
- <span>${e.type === 'dir' ? '📁' : '📄'}</span>
1692
+ <span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
1598
1693
  <span class="ds-fs-name">${esc(e.name)}</span>
1599
1694
  <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
1600
1695
  </div>`).join('') || `<div class="ds-empty">${t('ds.fsEmpty')}</div>`
@@ -1607,6 +1702,12 @@ async function loadFs(dir, silent) {
1607
1702
  $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
1608
1703
  }
1609
1704
  }
1705
+
1706
+ function desktopFsIconSvg(isDir) {
1707
+ return isDir
1708
+ ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
1709
+ : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
1710
+ }
1610
1711
  function fsUp() {
1611
1712
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
1612
1713
  loadFs(fsParent(state.fs.path))
@@ -1684,15 +1785,21 @@ async function refreshWorkbench({ silent = false } = {}) {
1684
1785
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1685
1786
  if (listRes.ok) {
1686
1787
  const listData = await listRes.json().catch(() => ({}))
1687
- const have = new Set(items.map(w => wbPathKey(w.path)))
1688
- for (const entry of listData.entries || []) {
1689
- if (entry.type !== 'dir') continue
1690
- const projectPath = wbJoin(state.wb.path, entry.name)
1691
- if (have.has(wbPathKey(projectPath))) continue
1692
- try {
1693
- const created = await rpc('workspace.create', { path: projectPath })
1694
- if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
1695
- } catch {}
1788
+ if (Array.isArray(listData.entries)) {
1789
+ const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1790
+ for (let i = items.length - 1; i >= 0; i--) {
1791
+ if (!diskDirs.has(wbPathKey(items[i].path))) items.splice(i, 1)
1792
+ }
1793
+ const have = new Set(items.map(w => wbPathKey(w.path)))
1794
+ for (const entry of listData.entries) {
1795
+ if (entry.type !== 'dir') continue
1796
+ const projectPath = wbJoin(state.wb.path, entry.name)
1797
+ if (have.has(wbPathKey(projectPath))) continue
1798
+ try {
1799
+ const created = await rpc('workspace.create', { path: projectPath })
1800
+ if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
1801
+ } catch {}
1802
+ }
1696
1803
  }
1697
1804
  }
1698
1805
  } catch {}
@@ -1723,10 +1830,11 @@ function renderWorkbench() {
1723
1830
  panel.classList.toggle('hidden', !state.wb.expanded)
1724
1831
  if (!state.wb.expanded) return
1725
1832
  const projects = state.wb.projects || []
1833
+ const archivedSet = new Set(state.archivedIds || [])
1726
1834
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1727
1835
  html += projects.length ? projects.map(w => {
1728
1836
  const id = String(w.workspaceId || '')
1729
- const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1837
+ 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))
1730
1838
  const open = state.wb.open === id
1731
1839
  return `<div class="ds-wb-project ${open ? 'open' : ''}">
1732
1840
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
@@ -1777,7 +1885,7 @@ async function wbFsLoad(dir) {
1777
1885
  const dirs = (data.entries || []).filter(e => e.type === 'dir')
1778
1886
  box.innerHTML = dirs.length ? dirs.map(e => {
1779
1887
  const p = wbJoin(data.path, e.name)
1780
- return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span>📁</span><span class="ds-wb-fs-name">${esc(e.name)}</span><button type="button" class="ds-btn ds-wb-select" data-wb-select="${esc(p)}">${esc(t('wb.selectDir'))}</button></div>`
1888
+ return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span class="ds-fs-type">${desktopFsIconSvg(true)}</span><span class="ds-wb-fs-name">${esc(e.name)}</span><button type="button" class="ds-btn ds-wb-select" data-wb-select="${esc(p)}">${esc(t('wb.selectDir'))}</button></div>`
1781
1889
  }).join('') : `<div class="ds-empty">${esc(t('wb.empty'))}</div>`
1782
1890
  box.querySelectorAll('[data-wb-dir]').forEach(row => row.addEventListener('click', e => { if (!e.target.closest('[data-wb-select]')) wbFsLoad(row.dataset.wbDir) }))
1783
1891
  box.querySelectorAll('[data-wb-select]').forEach(button => button.addEventListener('click', () => bindWorkbench(button.dataset.wbSelect)))
@@ -1899,13 +2007,98 @@ function renderStats(days) {
1899
2007
  }
1900
2008
 
1901
2009
  /* ---------------- 视图与连接状态 ---------------- */
2010
+ function renderOverviewDesktop() {
2011
+ const ring = $('ds-overview-pulse-ring')
2012
+ if (!ring) return
2013
+ const checks = {
2014
+ gateway: !!state.token && !!state.server,
2015
+ dsh: !!state.hostInfo,
2016
+ mux: !!state.streamsOk?.mux,
2017
+ host: !!state.streamsOk?.host
2018
+ }
2019
+ const online = Object.values(checks).filter(Boolean).length
2020
+ const status = online === 4 ? 'Nominal' : online > 0 ? 'Degraded' : 'Offline'
2021
+ const pulseCard = document.querySelector('.ds-overview-pulse-card')
2022
+ if (pulseCard) {
2023
+ pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
2024
+ pulseCard.classList.add('status-' + status.toLowerCase())
2025
+ }
2026
+ ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
2027
+ $('ds-overview-health').textContent = online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore')
2028
+ $('ds-overview-health-caption').textContent = online === 4 ? t('ds.allLinked') : online ? t('ds.components', { n: online }) : t('ds.offlineShort')
2029
+ $('ds-overview-status').textContent = t(`ds.system${status}`)
2030
+ $('ds-overview-status-desc').textContent = t('ds.components', { n: online })
2031
+ for (const [name, ok] of Object.entries(checks)) {
2032
+ const item = document.querySelector(`[data-ds-overview-link="${name}"]`)
2033
+ if (!item) continue
2034
+ item.classList.toggle('ok', ok)
2035
+ item.classList.toggle('off', !ok)
2036
+ const value = item.querySelector('b')
2037
+ if (value) value.textContent = ok ? t('ds.online') : t('ds.offlineShort')
2038
+ }
2039
+
2040
+ const pending = [
2041
+ ...state.approvals.map(a => ({ kind: 'approval', item: a })),
2042
+ ...state.questions.map(q => ({ kind: 'question', item: q }))
2043
+ ]
2044
+ $('ds-overview-attention-count').textContent = pending.length ? t('ds.pendingCount', { n: pending.length }) : '—'
2045
+ $('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
2046
+ const title = titleOf(state.byId.get(item.sessionId))
2047
+ if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
2048
+ <span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(item.reason || t('ds.approvalReason', { reason: '' }))} · ${esc(title)}</span></span>
2049
+ <span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
2050
+ </div>`
2051
+ return `<button type="button" class="ds-overview-attention-item question" data-ds-overview-question="${esc(item.rpcId)}">
2052
+ <span class="ds-overview-mark">?</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.questions?.[0]?.question || t('ds.questionNotify'))}</span><span class="ds-overview-item-desc">${esc(title)}</span></span><span class="ds-overview-arrow">›</span>
2053
+ </button>`
2054
+ }).join('') : `<div class="ds-overview-empty">${t('ds.nothingPending')}</div>`
2055
+ $('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')))
2056
+ $('ds-overview-attention-list').querySelectorAll('[data-ds-overview-question]').forEach(btn => btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.dsOverviewQuestion))))
2057
+
2058
+ const running = state.sessions.filter(s => s.running).length
2059
+ 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)
2060
+ const primary = $('ds-overview-primary-action')
2061
+ if (primary) {
2062
+ let action = 'new'
2063
+ let label = t('ds.action.newSession')
2064
+ let sessionId = ''
2065
+ if (!state.token) {
2066
+ action = 'settings'
2067
+ label = t('ds.action.connect')
2068
+ } else if (online > 0 && online < 4) {
2069
+ action = 'refresh'
2070
+ label = t('ds.action.refresh')
2071
+ } else if (pending.length) {
2072
+ action = 'attention'
2073
+ label = t('ds.action.attention')
2074
+ } else if (sessions.length) {
2075
+ action = 'session'
2076
+ sessionId = sessions[0].sessionId
2077
+ label = t('ds.action.openSession')
2078
+ }
2079
+ primary.textContent = label
2080
+ primary.dataset.dsOverviewAction = action
2081
+ primary.dataset.dsOverviewSession = sessionId
2082
+ }
2083
+ $('ds-overview-dsh-version').textContent = state.hostInfo?.version || '—'
2084
+ $('ds-overview-gateway-version').textContent = checks.gateway ? t('ds.online') : t('ds.offlineShort')
2085
+ $('ds-overview-active-sessions').textContent = String(running)
2086
+ $('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
2087
+ $('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
2088
+ $('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)}">
2089
+ <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>
2090
+ </button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
2091
+ $('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
2092
+ }
2093
+
1902
2094
  function showView(id) {
1903
2095
  state.view = id
1904
- for (const v of ['view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
2096
+ for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
1905
2097
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
1906
- const titles = { 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
2098
+ const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
1907
2099
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
1908
2100
  else $('ds-title').textContent = t(titles[id])
2101
+ if (id === 'view-overview') renderOverviewDesktop()
1909
2102
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
1910
2103
  if (id === 'view-settings') showSettingsHome()
1911
2104
  }
@@ -1991,6 +2184,28 @@ function bindUi() {
1991
2184
  list.style.display = list.style.display === 'none' ? 'flex' : 'none'
1992
2185
  })
1993
2186
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
2187
+ $('ds-overview-refresh').addEventListener('click', async () => {
2188
+ toast(t('ds.loading'))
2189
+ if (state.token) {
2190
+ await refreshSessions()
2191
+ const host = await safeRpc('host.describe', {}, '')
2192
+ if (host) state.hostInfo = host
2193
+ }
2194
+ renderOverviewDesktop()
2195
+ })
2196
+ $('ds-overview-primary-action').addEventListener('click', () => {
2197
+ const button = $('ds-overview-primary-action')
2198
+ const action = button.dataset.dsOverviewAction
2199
+ if (action === 'session' && button.dataset.dsOverviewSession) return openSession(button.dataset.dsOverviewSession)
2200
+ if (action === 'new') return $('btn-new-session').click()
2201
+ if (action === 'settings') return showView('view-settings')
2202
+ if (action === 'refresh') return $('ds-overview-refresh').click()
2203
+ const first = document.querySelector('.ds-overview-attention-item')
2204
+ if (first) {
2205
+ first.scrollIntoView({ behavior: 'smooth', block: 'center' })
2206
+ if (first.matches('button')) first.focus({ preventScroll: true })
2207
+ }
2208
+ })
1994
2209
  $('session-list').addEventListener('click', (e) => {
1995
2210
  if (e.target.closest('[data-archived-toggle]')) {
1996
2211
  LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
@@ -2128,7 +2343,7 @@ function bindUi() {
2128
2343
  $('btn-lang').addEventListener('click', () => {
2129
2344
  I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
2130
2345
  $('btn-lang').textContent = I18N.lang === 'zh' ? 'EN' : '中文'
2131
- renderServers(); renderSessions(); updateConn(); themeApply()
2346
+ renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
2132
2347
  })
2133
2348
  $('fs-up').addEventListener('click', fsUp)
2134
2349
  $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
@@ -2140,7 +2355,7 @@ function bindUi() {
2140
2355
  async function start() {
2141
2356
  loadServers()
2142
2357
  renderServers()
2143
- showView('view-sessions')
2358
+ showView('view-overview')
2144
2359
  const urlToken = new URLSearchParams(location.search).get('token')
2145
2360
  if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
2146
2361
  if (!state.token) {
@@ -2155,9 +2370,12 @@ async function start() {
2155
2370
  if (state.token) {
2156
2371
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
2157
2372
  openStreams()
2158
- refreshSessions()
2373
+ await refreshSessions()
2374
+ const host = await safeRpc('host.describe', {}, '')
2375
+ if (host) state.hostInfo = host
2159
2376
  refreshWorkbench({ silent: true })
2160
2377
  }
2378
+ renderOverviewDesktop()
2161
2379
  }
2162
2380
 
2163
2381
  start()