dsh-remote-plugin 0.6.4 → 0.6.6

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.
@@ -49,6 +49,7 @@ const state = {
49
49
  serverLatency: {},
50
50
  selectingServer: false,
51
51
  sessions: [],
52
+ sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
52
53
  byId: new Map(),
53
54
  current: null,
54
55
  history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
@@ -66,6 +67,8 @@ const state = {
66
67
  const streams = {}
67
68
  let pollTimer = null
68
69
  let wsRetryTimer = null
70
+ let connTickTimer = null
71
+ let reconnectInfo = null
69
72
 
70
73
  function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c])) }
71
74
  function short(id) { return '…' + String(id).slice(-8) }
@@ -493,12 +496,16 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
493
496
 
494
497
  /* ---------------- API ---------------- */
495
498
  function apiUrl(path) { return (state.server || '') + path }
496
- async function rpc(method, payload = {}) {
497
- const res = await fetch(apiUrl('/api/' + method), {
499
+ async function rpc(method, payload = {}, timeoutMs = 45000) {
500
+ const opts = {
498
501
  method: 'POST',
499
502
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
500
503
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
501
- })
504
+ }
505
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
506
+ opts.signal = AbortSignal.timeout(timeoutMs)
507
+ }
508
+ const res = await fetch(apiUrl('/api/' + method), opts)
502
509
  if (res.status === 401) throw new Error('AUTH')
503
510
  if (!res.ok) throw new Error('HTTP ' + res.status)
504
511
  const full = await res.json()
@@ -507,11 +514,15 @@ async function rpc(method, payload = {}) {
507
514
  return full.result.value
508
515
  }
509
516
  async function respond(rpcId, value) {
510
- const res = await fetch(apiUrl('/api/respond'), {
517
+ const opts = {
511
518
  method: 'POST',
512
519
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
513
520
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
514
- })
521
+ }
522
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
523
+ opts.signal = AbortSignal.timeout(15000)
524
+ }
525
+ const res = await fetch(apiUrl('/api/respond'), opts)
515
526
  if (res.status === 401) throw new Error('AUTH')
516
527
  const receipt = await res.json()
517
528
  return receipt?.accepted === true
@@ -608,7 +619,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
608
619
  let best = null
609
620
  let ms = Infinity
610
621
  if (state.autoSelect[state.activeGroup] !== false) {
611
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
622
+ const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
623
+ for (const [u, latency] of measured) state.serverLatency[u] = latency
612
624
  best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
613
625
  chosen = best || (state.server || '')
614
626
  ms = best ? state.serverLatency[best] : Infinity
@@ -803,14 +815,50 @@ function deleteGroup(name) {
803
815
  }
804
816
 
805
817
  /* ---------------- 事件流 (WebSocket + 轮询降级) ---------------- */
818
+ function clearStreamTimers(ws) {
819
+ if (!ws) return
820
+ clearInterval(ws._hbTimer)
821
+ clearInterval(ws._staleTimer)
822
+ ws._hbTimer = null
823
+ ws._staleTimer = null
824
+ }
825
+
826
+ function clearConnTick() {
827
+ clearInterval(connTickTimer)
828
+ connTickTimer = null
829
+ }
830
+
831
+ function startConnTick() {
832
+ if (connTickTimer) return
833
+ connTickTimer = setInterval(() => {
834
+ updateConn()
835
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
836
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
837
+ clearConnTick()
838
+ }
839
+ }, 1000)
840
+ }
841
+
842
+ function setReconnect(delay) {
843
+ reconnectInfo = { at: Date.now() + delay }
844
+ startConnTick()
845
+ updateConn()
846
+ }
847
+
848
+ function clearReconnect() {
849
+ reconnectInfo = null
850
+ clearConnTick()
851
+ }
852
+
806
853
  function openStreams() {
807
854
  if (!state.token) return
808
855
  if (state.streamMode === 'poll') stopPolling()
809
856
  state.streamMode = 'ws'
857
+ clearReconnect()
810
858
  openStream('mux', onMuxFrame, true)
811
859
  openStream('host', onHostFrame, false)
812
860
  }
813
- function openStream(kind, handler, refreshOnOpen) {
861
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
814
862
  if (!state.token) return
815
863
  let base
816
864
  if (state.server) base = state.server.replace(/^http/, 'ws')
@@ -818,28 +866,64 @@ function openStream(kind, handler, refreshOnOpen) {
818
866
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=web`)
819
867
  try { streams[kind]?.close() } catch {}
820
868
  streams[kind] = ws
869
+ ws._attempt = 0
870
+ ws._lastMsgAt = 0
871
+ ws._isRestore = !!isRestore
821
872
  ws.onopen = () => {
822
873
  state.streamsOk[kind] = true
823
874
  state.errCount = 0
875
+ ws._attempt = 0
876
+ ws._lastMsgAt = Date.now()
877
+ clearStreamTimers(ws)
878
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
879
+ ws._hbTimer = setInterval(() => {
880
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
881
+ }, 25000)
882
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
883
+ ws._staleTimer = setInterval(() => {
884
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
885
+ try { ws.close() } catch {}
886
+ }
887
+ }, 10000)
824
888
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
889
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
825
890
  updateConn()
826
891
  if (kind === 'mux') { state.approvals = []; state.questions = []; renderNotifStack() }
827
892
  if (refreshOnOpen) refreshSessions()
828
893
  }
829
894
  ws.onmessage = (msg) => {
895
+ ws._lastMsgAt = Date.now()
830
896
  state.streamsOk[kind] = true
831
897
  state.errCount = 0
832
898
  updateConn()
833
899
  try { handler(JSON.parse(msg.data)) } catch {}
834
900
  }
835
901
  ws.onclose = () => {
902
+ clearStreamTimers(ws)
836
903
  state.streamsOk[kind] = false
837
904
  state.errCount++
838
905
  updateConn()
839
- if (state.streamMode === 'poll') return
906
+ if (!navigator.onLine) { clearReconnect(); return }
907
+ if (state.streamMode === 'poll') {
908
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
909
+ if (ws._isRestore && streams[kind] === ws) {
910
+ const attempt = ws._attempt || 0
911
+ ws._attempt = attempt + 1
912
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
913
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
914
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
915
+ }
916
+ return
917
+ }
840
918
  if (state.errCount >= 3) { enterPollMode(); return }
841
919
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
842
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
920
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
921
+ const attempt = ws._attempt || 0
922
+ ws._attempt = attempt + 1
923
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
924
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
925
+ setReconnect(delay)
926
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
843
927
  }
844
928
  ws.onerror = () => { try { ws.close() } catch {} }
845
929
  }
@@ -898,7 +982,12 @@ async function pollKind(kind) {
898
982
  let data
899
983
  try { data = await res.json() } catch { return }
900
984
  if (!data || !Array.isArray(data.events)) return
901
- if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
985
+ const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
986
+ if (reset) {
987
+ state.pollSeq[kind] = 0
988
+ if (kind === 'mux') renderNotifStack()
989
+ refreshSessions()
990
+ }
902
991
  for (const item of data.events) {
903
992
  if (item.seq > (state.pollSeq[kind] || 0)) {
904
993
  state.pollSeq[kind] = item.seq
@@ -911,9 +1000,32 @@ async function pollKind(kind) {
911
1000
  function tryRestoreWs() {
912
1001
  if (state.streamMode !== 'poll' || !state.token) return
913
1002
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
914
- openStream('mux', onMuxFrame, true)
915
- openStream('host', onHostFrame, false)
1003
+ openStream('mux', onMuxFrame, true, true)
1004
+ openStream('host', onHostFrame, false, true)
916
1005
  }
1006
+
1007
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
1008
+ window.addEventListener('offline', () => {
1009
+ clearReconnect()
1010
+ try { streams.mux?.close() } catch {}
1011
+ try { streams.host?.close() } catch {}
1012
+ if (state.streamMode === 'poll') stopPolling()
1013
+ updateConn()
1014
+ })
1015
+ window.addEventListener('online', () => {
1016
+ if (!state.token) { updateConn(); return }
1017
+ state.errCount = 0
1018
+ clearReconnect()
1019
+ openStreams()
1020
+ updateConn()
1021
+ })
1022
+ document.addEventListener('visibilitychange', () => {
1023
+ if (document.visibilityState === 'visible' && state.token &&
1024
+ (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
1025
+ openStreams()
1026
+ }
1027
+ })
1028
+
917
1029
  function onMuxFrame(full) {
918
1030
  const f = full.payload
919
1031
  if (!f) return
@@ -959,7 +1071,7 @@ function applyProjection(sessionId, key, value, seq) {
959
1071
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
960
1072
  }
961
1073
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
962
- function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
1074
+ function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
963
1075
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
964
1076
  function isGoalTerminal(goal) {
965
1077
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -991,17 +1103,55 @@ async function refreshSessions() {
991
1103
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
992
1104
  renderSessions()
993
1105
  }
1106
+ function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1107
+ function sessionWorkspaceLabel(s) {
1108
+ const cwd = sessionCwd(s)
1109
+ return cwd || t('ds.workspaceUnknown')
1110
+ }
1111
+ function workspaceDisplayName(label) {
1112
+ const value = String(label || '').trim()
1113
+ if (!value || value === t('ds.workspaceUnknown')) return value || t('ds.workspaceUnknown')
1114
+ const clean = value.replace(/[\\/]+$/, '')
1115
+ const parts = clean.split(/[\\/]/).filter(Boolean)
1116
+ return parts[parts.length - 1] || value
1117
+ }
1118
+ function sortedSessions() {
1119
+ const items = [...state.sessions]
1120
+ if (state.sessionSort === 'workspace') {
1121
+ return items.sort((a, b) => {
1122
+ const aw = sessionCwd(a) || '\uffff'
1123
+ const bw = sessionCwd(b) || '\uffff'
1124
+ const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1125
+ return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1126
+ })
1127
+ }
1128
+ return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1129
+ }
994
1130
  function renderSessions() {
995
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
996
- const html = items.map(s => {
1131
+ const items = sortedSessions()
1132
+ let lastWorkspace = null
1133
+ const rows = []
1134
+ for (const s of items) {
1135
+ const workspace = sessionWorkspaceLabel(s)
1136
+ const workspaceName = workspaceDisplayName(workspace)
1137
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1138
+ 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>`)
1139
+ lastWorkspace = workspace
1140
+ }
997
1141
  const title = titleOf(s)
998
- return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1142
+ rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
999
1143
  <span class="ds-session-title">${esc(title)}</span>
1144
+ <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1000
1145
  <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1001
- </button>`
1002
- }).join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
1146
+ </button>`)
1147
+ }
1148
+ const html = rows.join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
1003
1149
  $('session-list').innerHTML = html
1004
1150
  $('mobile-session-list').innerHTML = html
1151
+ $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1152
+ $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1153
+ const sort = $('session-sort')
1154
+ if (sort) sort.value = state.sessionSort
1005
1155
  document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
1006
1156
  }
1007
1157
 
@@ -1174,7 +1324,8 @@ async function goalAction(kind) {
1174
1324
  const objective = prompt(t('goal.editPrompt'), goal.objective || '')
1175
1325
  if (objective === null) return
1176
1326
  if (!objective.trim()) return toast(t('goal.cannotEmpty'), 'err')
1177
- await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
1327
+ const result = await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
1328
+ if (result == null) return
1178
1329
  toast(t('goal.updated'), 'ok')
1179
1330
  refreshSessions()
1180
1331
  renderSessionCards()
@@ -1185,7 +1336,8 @@ async function goalAction(kind) {
1185
1336
  if (!method) return
1186
1337
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1187
1338
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1188
- await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1339
+ const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1340
+ if (result == null) return
1189
1341
  if (kind === 'complete') setGoalPhaseLocal('complete')
1190
1342
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1191
1343
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1195,7 +1347,8 @@ async function goalAction(kind) {
1195
1347
 
1196
1348
  async function interruptSubagent(childId) {
1197
1349
  if (!confirm(t('subagent.confirmInterrupt'))) return
1198
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1350
+ const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1351
+ if (result == null) return
1199
1352
  toast(t('subagent.interruptSubmitted'), 'ok')
1200
1353
  setTimeout(renderSessionCards, 600)
1201
1354
  }
@@ -1230,7 +1383,11 @@ async function sendMessage() {
1230
1383
  if (!text || !state.current) return
1231
1384
  if (await runSlashCommand(text)) { input.value = ''; return }
1232
1385
  input.value = ''
1233
- const v = await safeRpc('session.prompt', { sessionId: state.current, text }, '')
1386
+ const v = await safeRpc('session.prompt', {
1387
+ sessionId: state.current,
1388
+ mode: 'queue',
1389
+ content: [{ type: 'text', text }]
1390
+ }, '')
1234
1391
  if (v) toast(t('ds.toastSent'), 'ok')
1235
1392
  }
1236
1393
 
@@ -1283,7 +1440,14 @@ function renderNotifStack() {
1283
1440
  async function approveApproval(id, allow) {
1284
1441
  const a = state.approvals.find(x => x.approvalId === id)
1285
1442
  if (!a) return
1286
- const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1443
+ let ok
1444
+ try {
1445
+ ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1446
+ } catch (e) {
1447
+ if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
1448
+ else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
1449
+ return
1450
+ }
1287
1451
  toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
1288
1452
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1289
1453
  renderNotifStack()
@@ -1312,7 +1476,14 @@ async function submitQuestion() {
1312
1476
  return ans
1313
1477
  }).filter(Boolean)
1314
1478
  if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
1315
- const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1479
+ let ok
1480
+ try {
1481
+ ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1482
+ } catch (e) {
1483
+ if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
1484
+ else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
1485
+ return
1486
+ }
1316
1487
  if (ok) {
1317
1488
  toast(t('ds.questionSubmitted'), 'ok')
1318
1489
  $('modal-question').classList.add('hidden')
@@ -1338,6 +1509,48 @@ function fsParent(p) {
1338
1509
  parts.pop()
1339
1510
  return parts.length ? '/' + parts.join('/') : '/'
1340
1511
  }
1512
+ async function openWorkspaceModal() {
1513
+ if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
1514
+ if (!state.fs.path) await loadFs(null, true)
1515
+ $('workspace-parent-path').textContent = state.fs.path || '~'
1516
+ $('workspace-name').value = ''
1517
+ $('modal-workspace').classList.remove('hidden')
1518
+ setTimeout(() => $('workspace-name').focus(), 50)
1519
+ }
1520
+ function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
1521
+ async function createWorkspace() {
1522
+ if (createWorkspace.busy) return
1523
+ const name = $('workspace-name').value.trim()
1524
+ if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
1525
+ createWorkspace.busy = true
1526
+ const parent = state.fs.path || ''
1527
+ const button = $('workspace-create')
1528
+ button.disabled = true
1529
+ try {
1530
+ const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
1531
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
1532
+ const data = await res.json().catch(() => ({}))
1533
+ if (!res.ok) {
1534
+ const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
1535
+ throw new Error(msg)
1536
+ }
1537
+ closeWorkspaceModal()
1538
+ await loadFs(parent || null, true)
1539
+ const v = await safeRpc('session.create', { cwd: data.path }, t('ds.toastOpFailed'))
1540
+ await refreshSessions()
1541
+ if (v?.sessionId) {
1542
+ toast(t('ds.workspaceCreated'), 'ok')
1543
+ openSession(v.sessionId)
1544
+ } else {
1545
+ toast(t('ds.workspaceCreatedNoSession'), 'ok')
1546
+ }
1547
+ } catch (e) {
1548
+ toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
1549
+ } finally {
1550
+ createWorkspace.busy = false
1551
+ button.disabled = false
1552
+ }
1553
+ }
1341
1554
  async function loadFs(dir, silent) {
1342
1555
  if (!state.token) {
1343
1556
  $('fs-path').textContent = t('ds.toastAuth')
@@ -1473,27 +1686,63 @@ function updateConn() {
1473
1686
  const cur = state.servers.find(s => s.url === state.server)
1474
1687
  const group = cur ? cur.group : state.activeGroup
1475
1688
  const label = cur ? (cur.note || cur.url) : (state.server || t('ds.origin'))
1689
+ const serverText = t('ds.currentServer', { group, url: label })
1690
+ if (!navigator.onLine) {
1691
+ el.textContent = t('ds.connOffline')
1692
+ el.className = 'ds-conn off'
1693
+ el.title = serverText
1694
+ $('server-badge').textContent = serverText
1695
+ return
1696
+ }
1476
1697
  if (state.streamMode === 'poll') {
1477
1698
  el.textContent = '●'
1478
1699
  el.className = 'ds-conn off'
1479
1700
  el.title = t('ds.connPollTitle')
1480
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1701
+ $('server-badge').textContent = serverText
1481
1702
  return
1482
1703
  }
1483
1704
  const any = Object.values(state.streamsOk).some(Boolean)
1484
1705
  const all = state.streamsOk.mux && state.streamsOk.host
1706
+ if (!all && reconnectInfo) {
1707
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
1708
+ el.textContent = remain > 0 ? t('ds.connReconnectIn', { n: remain }) : t('ds.connReconnecting')
1709
+ el.className = 'ds-conn ing'
1710
+ el.title = t('ds.connReconnecting') + ' · ' + serverText
1711
+ $('server-badge').textContent = serverText
1712
+ return
1713
+ }
1714
+ if (!all && state.errCount > 0 && !any) {
1715
+ el.textContent = t('ds.connFailed')
1716
+ el.className = 'ds-conn off'
1717
+ el.title = serverText
1718
+ $('server-badge').textContent = serverText
1719
+ return
1720
+ }
1485
1721
  el.textContent = '●'
1486
1722
  el.className = 'ds-conn ' + (all ? 'on' : any ? 'ing' : '')
1487
1723
  el.title = all ? t('ds.connOn') : any ? t('ds.connIng') : t('ds.connOff')
1488
- $('server-badge').textContent = t('ds.currentServer', { group, url: label })
1724
+ $('server-badge').textContent = serverText
1489
1725
  }
1490
1726
 
1491
1727
  /* ---------------- 初始化 ---------------- */
1492
1728
  function bindUi() {
1493
1729
  $('btn-new-session').addEventListener('click', async () => {
1494
- const v = await safeRpc('session.create', {}, '')
1730
+ let payload = {}
1731
+ // 与移动端保持一致:新会话继承 DSH 当前工作目录;查询失败时兼容回退。
1732
+ try {
1733
+ const host = await rpc('host.describe', {}, 5000)
1734
+ const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
1735
+ if (cwd) payload = { cwd }
1736
+ } catch {}
1737
+ const v = await safeRpc('session.create', payload, '')
1495
1738
  if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
1496
1739
  })
1740
+ $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
1741
+ $('session-sort')?.addEventListener('change', (e) => {
1742
+ state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
1743
+ LS.set('sessionSort', state.sessionSort)
1744
+ renderSessions()
1745
+ })
1497
1746
  $('btn-mobile-nav').addEventListener('click', () => {
1498
1747
  const list = $('mobile-session-list')
1499
1748
  list.style.display = list.style.display === 'none' ? 'flex' : 'none'
@@ -1552,6 +1801,10 @@ function bindUi() {
1552
1801
  $('notes-prev').addEventListener('click', () => scrollNotes(-1))
1553
1802
  $('notes-next').addEventListener('click', () => scrollNotes(1))
1554
1803
  $('notes-pages').addEventListener('scroll', updateNotesPage)
1804
+ $('workspace-cancel').addEventListener('click', closeWorkspaceModal)
1805
+ $('workspace-create').addEventListener('click', createWorkspace)
1806
+ $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
1807
+ $('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
1555
1808
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
1556
1809
  // 反馈
1557
1810
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
@@ -1614,6 +1867,7 @@ function bindUi() {
1614
1867
  renderServers(); renderSessions(); updateConn(); themeApply()
1615
1868
  })
1616
1869
  $('fs-up').addEventListener('click', fsUp)
1870
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
1617
1871
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
1618
1872
  $('btn-question-submit').addEventListener('click', submitQuestion)
1619
1873
  $('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })