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.
package/public/app.js CHANGED
@@ -46,6 +46,7 @@ const state = {
46
46
  serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
47
47
  selectingServer: false, // 防重入: 测速/切换中
48
48
  sessions: [],
49
+ sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
49
50
  byId: new Map(),
50
51
  current: null, // 当前打开的 sessionId
51
52
  hostInfo: null,
@@ -183,6 +184,24 @@ function apiUrl(path) {
183
184
  return (state.server || '') + path
184
185
  }
185
186
 
187
+ function updateBase() {
188
+ const configured = String(state.server || '').replace(/\/+$/, '')
189
+ if (configured) return configured
190
+ if (!/^https?:$/.test(location.protocol)) return ''
191
+ const origin = location.origin.replace(/\/+$/, '')
192
+ return location.pathname === '/remote' || location.pathname.startsWith('/remote/')
193
+ ? origin + '/remote'
194
+ : origin
195
+ }
196
+
197
+ function adminApiUrl(path) {
198
+ const base = String(state.server || '').replace(/\/+$/, '')
199
+ if (base) return base + path
200
+ // DSH 抽屉页面同源运行在 /remote/ 前缀下,管理接口也必须带此前缀;
201
+ // 独立网关根页面则继续使用 /admin/api/*。
202
+ return location.pathname.startsWith('/remote/') ? '/remote' + path : path
203
+ }
204
+
186
205
  function fmtCost(n) {
187
206
  return '¥' + (Number(n) || 0).toFixed(2)
188
207
  }
@@ -268,14 +287,14 @@ function renderStats(days) {
268
287
  </div>`
269
288
  }).join('')
270
289
  }
271
- async function rpc(method, payload = {}) {
290
+ async function rpc(method, payload = {}, timeoutMs = 45000) {
272
291
  const opts = {
273
292
  method: 'POST',
274
293
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
275
294
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
276
295
  }
277
296
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
278
- opts.signal = AbortSignal.timeout(45000)
297
+ opts.signal = AbortSignal.timeout(timeoutMs)
279
298
  }
280
299
  const res = await fetch(apiUrl('/api/' + method), opts)
281
300
  if (res.status === 401) throw new Error('AUTH')
@@ -290,11 +309,15 @@ async function rpc(method, payload = {}) {
290
309
  }
291
310
 
292
311
  async function respond(rpcId, value) {
293
- const res = await fetch(apiUrl('/api/respond'), {
312
+ const opts = {
294
313
  method: 'POST',
295
314
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
296
315
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
297
- })
316
+ }
317
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
318
+ opts.signal = AbortSignal.timeout(15000)
319
+ }
320
+ const res = await fetch(apiUrl('/api/respond'), opts)
298
321
  if (res.status === 401) throw new Error('AUTH')
299
322
  const receipt = await res.json()
300
323
  return receipt?.accepted === true
@@ -430,7 +453,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
430
453
  let ms = Infinity
431
454
 
432
455
  if (state.autoSelect[state.activeGroup] !== false) {
433
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
456
+ const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
457
+ for (const [u, latency] of measured) state.serverLatency[u] = latency
434
458
  best = candidates
435
459
  .filter(u => Number.isFinite(state.serverLatency[u]))
436
460
  .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
@@ -709,16 +733,54 @@ const streams = {}
709
733
  state.streamsOk = { mux: false, host: false }
710
734
  let pollTimer = null
711
735
  let wsRetryTimer = null
736
+ let connTickTimer = null
737
+ let reconnectInfo = null
738
+
739
+ function clearStreamTimers(ws) {
740
+ if (!ws) return
741
+ clearInterval(ws._hbTimer)
742
+ clearInterval(ws._staleTimer)
743
+ ws._hbTimer = null
744
+ ws._staleTimer = null
745
+ }
746
+
747
+ function clearConnTick() {
748
+ clearInterval(connTickTimer)
749
+ connTickTimer = null
750
+ }
751
+
752
+ function startConnTick() {
753
+ if (connTickTimer) return
754
+ connTickTimer = setInterval(() => {
755
+ updateConn()
756
+ if (!reconnectInfo || state.streamMode === 'poll' || !navigator.onLine ||
757
+ (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN)) {
758
+ clearConnTick()
759
+ }
760
+ }, 1000)
761
+ }
762
+
763
+ function setReconnect(delay) {
764
+ reconnectInfo = { at: Date.now() + delay }
765
+ startConnTick()
766
+ updateConn()
767
+ }
768
+
769
+ function clearReconnect() {
770
+ reconnectInfo = null
771
+ clearConnTick()
772
+ }
712
773
 
713
774
  function openStreams() {
714
775
  if (!state.token) return
715
776
  if (state.streamMode === 'poll') stopPolling()
716
777
  state.streamMode = 'ws'
778
+ clearReconnect()
717
779
  openStream('mux', onMuxFrame, true)
718
780
  openStream('host', onHostFrame, false)
719
781
  }
720
782
 
721
- function openStream(kind, handler, refreshOnOpen) {
783
+ function openStream(kind, handler, refreshOnOpen, isRestore) {
722
784
  if (!state.token) return
723
785
  let base
724
786
  if (state.server) {
@@ -731,11 +793,28 @@ function openStream(kind, handler, refreshOnOpen) {
731
793
  const ws = new WebSocket(`${base}/api/events.${kind}?token=${encodeURIComponent(state.token)}&client=${clientMark}`)
732
794
  try { streams[kind]?.close() } catch {}
733
795
  streams[kind] = ws
796
+ ws._attempt = 0
797
+ ws._lastMsgAt = 0
798
+ ws._isRestore = !!isRestore
734
799
  ws.onopen = () => {
735
800
  state.streamsOk[kind] = true
736
801
  state.errCount = 0
802
+ ws._attempt = 0
803
+ ws._lastMsgAt = Date.now()
804
+ clearStreamTimers(ws)
805
+ // 应用层心跳: 25s 发纯文本 ping, 防 NAT/WiFi 切换后的 WS 半开假活
806
+ ws._hbTimer = setInterval(() => {
807
+ try { if (ws.readyState === WebSocket.OPEN) ws.send('ping') } catch {}
808
+ }, 25000)
809
+ // 60s 没有任何消息(含 pong/业务帧)就主动断开, 触发指数退避重连
810
+ ws._staleTimer = setInterval(() => {
811
+ if (ws.readyState === WebSocket.OPEN && Date.now() - (ws._lastMsgAt || 0) > 60000) {
812
+ try { ws.close() } catch {}
813
+ }
814
+ }, 10000)
737
815
  // 重连成功:切回 WS 并停止轮询
738
816
  if (state.streamMode === 'poll') { stopPolling(); state.streamMode = 'ws' }
817
+ if (streams.mux?.readyState === WebSocket.OPEN && streams.host?.readyState === WebSocket.OPEN) clearReconnect()
739
818
  updateConn()
740
819
  // mux 每次(重)连接都会重放“仍待处理”的审批/提问基线:
741
820
  // 先清空旧列表, 避免“桌面已自定义回答, 手机漏收 question/resolved”后永久残留。
@@ -747,6 +826,7 @@ function openStream(kind, handler, refreshOnOpen) {
747
826
  if (refreshOnOpen) refreshAll()
748
827
  }
749
828
  ws.onmessage = (msg) => {
829
+ ws._lastMsgAt = Date.now()
750
830
  state.streamsOk[kind] = true
751
831
  state.errCount = 0
752
832
  updateConn()
@@ -756,16 +836,34 @@ function openStream(kind, handler, refreshOnOpen) {
756
836
  } catch {}
757
837
  }
758
838
  ws.onclose = () => {
839
+ clearStreamTimers(ws)
759
840
  state.streamsOk[kind] = false
760
841
  state.errCount++
761
842
  updateConn()
762
- if (state.streamMode === 'poll') return
843
+ if (!navigator.onLine) { clearReconnect(); return }
844
+ if (state.streamMode === 'poll') {
845
+ // 降级轮询期间: 恢复尝试失败也用退避, 避免 30s 固定间隔内空转
846
+ if (ws._isRestore && streams[kind] === ws) {
847
+ const attempt = ws._attempt || 0
848
+ ws._attempt = attempt + 1
849
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
850
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
851
+ setTimeout(() => openStream(kind, handler, refreshOnOpen, true), delay)
852
+ }
853
+ return
854
+ }
763
855
  // 连续失败 3 次 -> 降级为轮询
764
856
  if (state.errCount >= 3) { enterPollMode(); return }
765
857
  // 多服务器: 连续掉线若干次就重测速, 自动换到当前可达的最快地址
766
858
  if (state.servers.length && state.errCount % 5 === 0) setTimeout(() => selectFastestServer({ silent: true }), 300)
767
- // 无条件重连; 页面被挂起时定时器暂停, visibilitychange 会再触发一次
768
- if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), 1200)
859
+ // 指数退避 + 20% 抖动: min(1200 * 2^attempt, 30000)
860
+ const attempt = ws._attempt || 0
861
+ ws._attempt = attempt + 1
862
+ const baseDelay = Math.min(1200 * Math.pow(2, attempt), 30000)
863
+ const delay = Math.round(baseDelay * (0.8 + Math.random() * 0.4))
864
+ setReconnect(delay)
865
+ // 页面被挂起时定时器暂停, visibilitychange 会再触发一次
866
+ if (streams[kind] === ws) setTimeout(() => openStream(kind, handler, refreshOnOpen), delay)
769
867
  }
770
868
  ws.onerror = () => { try { ws.close() } catch {} }
771
869
  }
@@ -824,8 +922,18 @@ async function pollKind(kind) {
824
922
  let data
825
923
  try { data = await res.json() } catch { return }
826
924
  if (!data || !Array.isArray(data.events)) return
827
- // 网关重启后 seq 会重置:落后就从头拉当前缓冲
828
- if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
925
+ // 网关重启或客户端离线过久后,游标可能落后于内存环形缓冲;
926
+ // 从当前缓冲重新接收,并刷新权威会话列表,避免静默漏事件。
927
+ const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
928
+ if (reset) {
929
+ state.pollSeq[kind] = 0
930
+ if (kind === 'mux') {
931
+ state.approvals = []
932
+ state.questions = []
933
+ renderPending()
934
+ }
935
+ scheduleRefresh()
936
+ }
829
937
  for (const item of data.events) {
830
938
  if (item.seq > (state.pollSeq[kind] || 0)) {
831
939
  state.pollSeq[kind] = item.seq
@@ -838,8 +946,8 @@ async function pollKind(kind) {
838
946
  function tryRestoreWs() {
839
947
  if (state.streamMode !== 'poll' || !state.token) return
840
948
  // 轮询继续跑,等 WS 真正 onopen 后再切回,避免重连窗口丢事件
841
- openStream('mux', onMuxFrame, true)
842
- openStream('host', onHostFrame, false)
949
+ openStream('mux', onMuxFrame, true, true)
950
+ openStream('host', onHostFrame, false, true)
843
951
  }
844
952
 
845
953
  /* 回前台恢复: 强制重排修复 MIUI WebView 后台切回时 sticky 顶栏不绘制的问题 */
@@ -876,6 +984,23 @@ setInterval(() => {
876
984
  setInterval(() => {
877
985
  if (document.visibilityState === 'visible' && state.servers.length) selectFastestServer({ silent: true })
878
986
  }, 300000)
987
+
988
+ /* 网络感知: 离线立刻关 WS + 显示离线, 在线立即重连 */
989
+ window.addEventListener('offline', () => {
990
+ clearReconnect()
991
+ try { streams.mux?.close() } catch {}
992
+ try { streams.host?.close() } catch {}
993
+ if (state.streamMode === 'poll') stopPolling()
994
+ updateConn()
995
+ })
996
+ window.addEventListener('online', () => {
997
+ if (!state.token) { updateConn(); return }
998
+ state.errCount = 0
999
+ clearReconnect()
1000
+ openStreams()
1001
+ updateConn()
1002
+ })
1003
+
879
1004
  function onMuxFrame(full) {
880
1005
  const f = full.payload
881
1006
  if (!f) return
@@ -979,7 +1104,8 @@ function applyProjection(sessionId, key, value, seq) {
979
1104
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
980
1105
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
981
1106
  else renderSessions()
982
- }function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
1107
+ }
1108
+ function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
983
1109
  function short(id) { return '…' + String(id).slice(-8) }
984
1110
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
985
1111
  function isGoalTerminal(goal) {
@@ -997,10 +1123,42 @@ function updatePendingBadge() {
997
1123
  if (pending) $('nav-pending').textContent = pending
998
1124
  }
999
1125
 
1126
+ function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1127
+ function sessionWorkspaceLabel(s) {
1128
+ const cwd = sessionCwd(s)
1129
+ return cwd || t('sessions.workspaceUnknown')
1130
+ }
1131
+ function workspaceDisplayName(label) {
1132
+ const value = String(label || '').trim()
1133
+ if (!value || value === t('sessions.workspaceUnknown')) return value || t('sessions.workspaceUnknown')
1134
+ const clean = value.replace(/[\\/]+$/, '')
1135
+ const parts = clean.split(/[\\/]/).filter(Boolean)
1136
+ return parts[parts.length - 1] || value
1137
+ }
1138
+ function sortedSessions() {
1139
+ const items = [...state.sessions]
1140
+ if (state.sessionSort === 'workspace') {
1141
+ return items.sort((a, b) => {
1142
+ const aw = sessionCwd(a) || '\uffff'
1143
+ const bw = sessionCwd(b) || '\uffff'
1144
+ const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1145
+ return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1146
+ })
1147
+ }
1148
+ return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1149
+ }
1000
1150
  function renderSessions() {
1001
1151
  const list = $('session-list')
1002
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1003
- list.innerHTML = items.map(s => {
1152
+ const items = sortedSessions()
1153
+ let lastWorkspace = null
1154
+ const rows = []
1155
+ for (const s of items) {
1156
+ const workspace = sessionWorkspaceLabel(s)
1157
+ const workspaceName = workspaceDisplayName(workspace)
1158
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1159
+ rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1160
+ lastWorkspace = workspace
1161
+ }
1004
1162
  const title = titleOf(s)
1005
1163
  const goal = goalOf(s)
1006
1164
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
@@ -1010,7 +1168,7 @@ function renderSessions() {
1010
1168
  if (pending) dots.push('pending')
1011
1169
  const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1012
1170
  const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1013
- return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1171
+ rows.push(`<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1014
1172
  <div class="sc-title">${esc(title)}</div>
1015
1173
  <div class="sc-meta">
1016
1174
  <span class="sc-dot ${dots.join(' ')}"></span>
@@ -1018,9 +1176,14 @@ function renderSessions() {
1018
1176
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1019
1177
  ${badge}${queueBadge}
1020
1178
  </div>
1179
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1021
1180
  <span class="sc-arrow">›</span>
1022
- </div>`
1023
- }).join('')
1181
+ </div>`)
1182
+ }
1183
+ list.innerHTML = rows.join('')
1184
+ list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1185
+ const sort = $('session-sort')
1186
+ if (sort) sort.value = state.sessionSort
1024
1187
  $('home-empty').classList.toggle('hidden', items.length > 0)
1025
1188
  const running = state.sessions.filter(s => s.running).length
1026
1189
  const pending = state.approvals.length + state.questions.length
@@ -1154,7 +1317,7 @@ function restoreCachedHistory() {
1154
1317
  if (!cached?.events?.length) return false
1155
1318
  const h = emptyHistory()
1156
1319
  for (const e of cached.events) {
1157
- if (!e?.seq) continue
1320
+ if (e?.seq == null) continue
1158
1321
  h.seqs.add(e.seq)
1159
1322
  h.visible.push(e)
1160
1323
  }
@@ -1522,7 +1685,8 @@ async function goalAction(kind) {
1522
1685
  if (!method) return
1523
1686
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1524
1687
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1525
- await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1688
+ const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1689
+ if (result == null) return
1526
1690
  if (kind === 'complete') setGoalPhaseLocal('complete')
1527
1691
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1528
1692
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1531,7 +1695,8 @@ async function goalAction(kind) {
1531
1695
 
1532
1696
  async function interruptSubagent(childId) {
1533
1697
  if (!confirm(t('subagent.confirmInterrupt'))) return
1534
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1698
+ const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1699
+ if (result == null) return
1535
1700
  toast(t('subagent.interruptSubmitted'), 'ok')
1536
1701
  setTimeout(renderSessionCards, 600)
1537
1702
  }
@@ -1693,7 +1858,18 @@ async function cancelSession() {
1693
1858
  }
1694
1859
 
1695
1860
  async function newSession() {
1696
- const v = await safeRpc('session.create', {}, t('home.createFailed'))
1861
+ let payload = {}
1862
+ // DSH 的 host.describe 返回当前工作目录。每次创建前短暂刷新一次,
1863
+ // 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
1864
+ try {
1865
+ const host = await rpc('host.describe', {}, 5000)
1866
+ const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
1867
+ if (cwd) {
1868
+ state.hostInfo = host
1869
+ payload = { cwd }
1870
+ }
1871
+ } catch {}
1872
+ const v = await safeRpc('session.create', payload, t('home.createFailed'))
1697
1873
  if (!v?.sessionId) return
1698
1874
  toast(t('home.created'), 'ok')
1699
1875
  await refreshSessions()
@@ -1740,7 +1916,14 @@ function renderPending() {
1740
1916
  async function approveApproval(id, allow) {
1741
1917
  const a = state.approvals.find(x => x.approvalId === id)
1742
1918
  if (!a) return
1743
- const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1919
+ let ok
1920
+ try {
1921
+ ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1922
+ } catch (e) {
1923
+ if (e.message === 'AUTH') authFailure()
1924
+ else toast(t('pending.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
1925
+ return
1926
+ }
1744
1927
  toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
1745
1928
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1746
1929
  renderPending()
@@ -1771,7 +1954,14 @@ async function submitQuestion() {
1771
1954
  return ans
1772
1955
  }).filter(Boolean)
1773
1956
  if (!answers.length) return toast(t('question.needAnswer'), 'err')
1774
- const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1957
+ let ok
1958
+ try {
1959
+ ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1960
+ } catch (e) {
1961
+ if (e.message === 'AUTH') authFailure()
1962
+ else toast(t('question.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
1963
+ return
1964
+ }
1775
1965
  if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1776
1966
  else toast(t('question.stale'), 'err')
1777
1967
  }
@@ -1819,6 +2009,50 @@ function fsParent(p) {
1819
2009
  return clean.slice(0, idx)
1820
2010
  }
1821
2011
 
2012
+ async function openWorkspaceModal() {
2013
+ if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
2014
+ if (!state.fs.path) await loadFs(null, { silent: true })
2015
+ $('workspace-parent-path').textContent = state.fs.path || '~'
2016
+ $('workspace-name').value = ''
2017
+ $('modal-workspace').classList.remove('hidden')
2018
+ setTimeout(() => $('workspace-name').focus(), 50)
2019
+ }
2020
+ function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
2021
+ async function createWorkspace() {
2022
+ if (createWorkspace.busy) return
2023
+ const name = $('workspace-name').value.trim()
2024
+ if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
2025
+ createWorkspace.busy = true
2026
+ const parent = state.fs.path || ''
2027
+ const button = $('workspace-create')
2028
+ button.disabled = true
2029
+ try {
2030
+ const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
2031
+ if (res.status === 401) { fsAuthError(401); return }
2032
+ const data = await res.json().catch(() => ({}))
2033
+ if (!res.ok) {
2034
+ const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
2035
+ throw new Error(msg)
2036
+ }
2037
+ closeWorkspaceModal()
2038
+ await loadFs(parent || null, { silent: true })
2039
+ const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
2040
+ await refreshSessions()
2041
+ if (v?.sessionId) {
2042
+ toast(t('workspace.created'), 'ok')
2043
+ openSession(v.sessionId)
2044
+ } else {
2045
+ toast(t('workspace.createdNoSession'), 'ok')
2046
+ }
2047
+ } catch (e) {
2048
+ if (e.message === 'AUTH') fsAuthError(401)
2049
+ else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
2050
+ } finally {
2051
+ createWorkspace.busy = false
2052
+ button.disabled = false
2053
+ }
2054
+ }
2055
+
1822
2056
  function fsApiUrl(sub, params = {}) {
1823
2057
  const u = new URL(apiUrl('/fs' + sub), location.href)
1824
2058
  for (const [k, v] of Object.entries(params)) {
@@ -2242,7 +2476,8 @@ async function submitGoalEdit() {
2242
2476
  if (!goal) return
2243
2477
  const objective = $('goal-edit-text')?.value?.trim()
2244
2478
  if (!objective) return toast(t('goal.cannotEmpty'), 'err')
2245
- await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
2479
+ const result = await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
2480
+ if (result == null) return
2246
2481
  $('modal-goal').classList.add('hidden')
2247
2482
  toast(t('goal.updated'), 'ok')
2248
2483
  scheduleRefresh()
@@ -2386,7 +2621,7 @@ async function checkUpdate(silent) {
2386
2621
  if (!silent) toast(t('update.noVersion'), 'err')
2387
2622
  return
2388
2623
  }
2389
- const base = state.server
2624
+ const base = updateBase()
2390
2625
  if (!base) {
2391
2626
  if (!silent) toast(t('update.needServer'), 'err')
2392
2627
  $('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
@@ -2463,7 +2698,7 @@ async function verifyUpdateApk(info, url) {
2463
2698
  async function downloadUpdate() {
2464
2699
  const info = state.updateInfo
2465
2700
  if (!info) return
2466
- const base = state.server || ''
2701
+ const base = updateBase()
2467
2702
  let url
2468
2703
  try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
2469
2704
  catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
@@ -2678,8 +2913,7 @@ async function schedulePeakReminders() {
2678
2913
  const b = bgBridge()
2679
2914
  if (!b?.startPeakReminder) return false
2680
2915
  try {
2681
- b.startPeakReminder()
2682
- return true
2916
+ return b.startPeakReminder() !== false
2683
2917
  } catch { return false }
2684
2918
  }
2685
2919
 
@@ -2688,8 +2922,7 @@ async function cancelPeakReminders() {
2688
2922
  const b = bgBridge()
2689
2923
  if (!b?.stopPeakReminder) return false
2690
2924
  try {
2691
- b.stopPeakReminder()
2692
- return true
2925
+ return b.stopPeakReminder() !== false
2693
2926
  } catch { return false }
2694
2927
  }
2695
2928
 
@@ -2727,16 +2960,36 @@ function updateConn() {
2727
2960
  const ms = state.serverLatency[state.server]
2728
2961
  const curGroup = cur ? cur.group : state.activeGroup
2729
2962
  const curLabel = cur ? (cur.note || cur.url) : (state.server || t('speed.origin'))
2963
+ const titleBase = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2964
+ if (!navigator.onLine) {
2965
+ el.textContent = t('conn.offline')
2966
+ el.className = 'topbar-btn conn-badge off'
2967
+ el.title = titleBase
2968
+ return
2969
+ }
2730
2970
  if (state.streamMode === 'poll') {
2731
2971
  el.textContent = t('conn.poll')
2732
2972
  el.className = 'topbar-btn conn-badge off'
2733
- el.title = t('conn.pollTitle') + ' · ' + t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2973
+ el.title = t('conn.pollTitle') + ' · ' + titleBase
2734
2974
  return
2735
2975
  }
2736
2976
  const ok = !!state.streamsOk?.mux
2977
+ if (!ok && reconnectInfo) {
2978
+ const remain = Math.max(0, Math.ceil((reconnectInfo.at - Date.now()) / 1000))
2979
+ el.textContent = remain > 0 ? t('conn.reconnectIn', { n: remain }) : t('conn.reconnecting')
2980
+ el.className = 'topbar-btn conn-badge off'
2981
+ el.title = t('conn.reconnecting') + ' · ' + titleBase
2982
+ return
2983
+ }
2984
+ if (!ok && state.errCount > 0) {
2985
+ el.textContent = t('conn.failed')
2986
+ el.className = 'topbar-btn conn-badge off'
2987
+ el.title = titleBase
2988
+ return
2989
+ }
2737
2990
  el.textContent = ok ? t('conn.on') : t('conn.off')
2738
2991
  el.className = 'topbar-btn conn-badge ' + (ok ? 'on' : 'off')
2739
- el.title = t('conn.titleGroup', { group: curGroup, url: curLabel, ms: Number.isFinite(ms) ? ms + 'ms' : '—' })
2992
+ el.title = titleBase
2740
2993
  }
2741
2994
 
2742
2995
  function autosize(el) {
@@ -2862,6 +3115,55 @@ function initToken() {
2862
3115
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
2863
3116
  }
2864
3117
 
3118
+ function renderDshControlStatus(v) {
3119
+ const desc = $('dsh-control-desc')
3120
+ if (!desc || !v) return
3121
+ const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3122
+ if (v.supported === false) {
3123
+ desc.textContent = v.message || t('settings.dshUnsupported')
3124
+ buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
3125
+ return
3126
+ }
3127
+ buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
3128
+ desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
3129
+ }
3130
+
3131
+ async function loadDshControl() {
3132
+ if (!state.token || !$('dsh-control-desc')) return
3133
+ try {
3134
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
3135
+ if (res.status === 401) return authFailure()
3136
+ renderDshControlStatus(await res.json())
3137
+ } catch {
3138
+ $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
3139
+ }
3140
+ }
3141
+
3142
+ async function controlDsh(action) {
3143
+ const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
3144
+ const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3145
+ buttons.forEach(b => { b.disabled = true })
3146
+ const desc = $('dsh-control-desc')
3147
+ if (desc) desc.textContent = t('settings.dshStarting', { action: label })
3148
+ try {
3149
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), {
3150
+ method: 'POST',
3151
+ headers: { authorization: 'Bearer ' + state.token, 'content-type': 'application/json' },
3152
+ body: JSON.stringify({ action }),
3153
+ })
3154
+ if (res.status === 401) return authFailure()
3155
+ const v = await res.json().catch(() => ({}))
3156
+ if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
3157
+ renderDshControlStatus(v)
3158
+ toast(t('settings.dshStarted', { action: label }), 'ok')
3159
+ } catch (e) {
3160
+ if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
3161
+ toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
3162
+ } finally {
3163
+ buttons.forEach(b => { b.disabled = false })
3164
+ }
3165
+ }
3166
+
2865
3167
  function renderLangBtn() {
2866
3168
  const btn = $('btn-lang')
2867
3169
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
@@ -2978,6 +3280,12 @@ function bindUi() {
2978
3280
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
2979
3281
  })
2980
3282
  $('btn-new-session').addEventListener('click', newSession)
3283
+ $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
3284
+ $('session-sort')?.addEventListener('change', (e) => {
3285
+ state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
3286
+ LS.set('sessionSort', state.sessionSort)
3287
+ renderSessions()
3288
+ })
2981
3289
  $('btn-cancel').addEventListener('click', cancelSession)
2982
3290
  $('btn-send').addEventListener('click', sendMessage)
2983
3291
  $('btn-plus').addEventListener('click', toggleComposerMenu)
@@ -3025,6 +3333,10 @@ function bindUi() {
3025
3333
  // goal
3026
3334
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
3027
3335
  $('goal-edit').addEventListener('click', submitGoalEdit)
3336
+ $('workspace-cancel').addEventListener('click', closeWorkspaceModal)
3337
+ $('workspace-create').addEventListener('click', createWorkspace)
3338
+ $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
3339
+ $('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
3028
3340
  // 设置
3029
3341
  $('view-settings').addEventListener('click', (e) => {
3030
3342
  const group = e.target.closest('[data-settings-group]')
@@ -3054,6 +3366,8 @@ function bindUi() {
3054
3366
  $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
3055
3367
  }
3056
3368
  })
3369
+ $('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
3370
+ $('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
3057
3371
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
3058
3372
  $('btn-download-update').addEventListener('click', downloadUpdate)
3059
3373
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
@@ -3080,10 +3394,18 @@ function bindUi() {
3080
3394
  }
3081
3395
  const ok = await ensureNotify()
3082
3396
  if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
3083
- await schedulePeakReminders()
3397
+ const started = await schedulePeakReminders()
3398
+ if (!started) {
3399
+ e.target.checked = false
3400
+ return toast(t('peakRemind.failed'), 'err')
3401
+ }
3084
3402
  toast(t('peakRemind.on'), 'ok')
3085
3403
  } else {
3086
- await cancelPeakReminders()
3404
+ const stopped = await cancelPeakReminders()
3405
+ if (!stopped) {
3406
+ e.target.checked = true
3407
+ return toast(t('peakRemind.failed'), 'err')
3408
+ }
3087
3409
  toast(t('peakRemind.off'), 'ok')
3088
3410
  }
3089
3411
  LS.set('peakRemind', e.target.checked ? '1' : '0')
@@ -3129,6 +3451,7 @@ function bindUi() {
3129
3451
 
3130
3452
  // 文件页
3131
3453
  $('fs-up').addEventListener('click', fsUp)
3454
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
3132
3455
  $('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
3133
3456
  $('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
3134
3457
  $('fs-file-input').addEventListener('change', (e) => {
@@ -3195,6 +3518,7 @@ async function boot() {
3195
3518
  await refreshAll()
3196
3519
  const host = await safeRpc('host.describe', {}, '')
3197
3520
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
3521
+ loadDshControl()
3198
3522
  // 启动后自动检查一次更新(静默)
3199
3523
  setTimeout(() => checkUpdate(true), 4000)
3200
3524
  }