dsh-remote-plugin 0.6.5 → 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
@@ -898,8 +922,18 @@ async function pollKind(kind) {
898
922
  let data
899
923
  try { data = await res.json() } catch { return }
900
924
  if (!data || !Array.isArray(data.events)) return
901
- // 网关重启后 seq 会重置:落后就从头拉当前缓冲
902
- 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
+ }
903
937
  for (const item of data.events) {
904
938
  if (item.seq > (state.pollSeq[kind] || 0)) {
905
939
  state.pollSeq[kind] = item.seq
@@ -1070,7 +1104,8 @@ function applyProjection(sessionId, key, value, seq) {
1070
1104
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
1071
1105
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
1072
1106
  else renderSessions()
1073
- }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')) }
1074
1109
  function short(id) { return '…' + String(id).slice(-8) }
1075
1110
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1076
1111
  function isGoalTerminal(goal) {
@@ -1088,10 +1123,42 @@ function updatePendingBadge() {
1088
1123
  if (pending) $('nav-pending').textContent = pending
1089
1124
  }
1090
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
+ }
1091
1150
  function renderSessions() {
1092
1151
  const list = $('session-list')
1093
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1094
- 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
+ }
1095
1162
  const title = titleOf(s)
1096
1163
  const goal = goalOf(s)
1097
1164
  const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
@@ -1101,7 +1168,7 @@ function renderSessions() {
1101
1168
  if (pending) dots.push('pending')
1102
1169
  const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1103
1170
  const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1104
- 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)}">
1105
1172
  <div class="sc-title">${esc(title)}</div>
1106
1173
  <div class="sc-meta">
1107
1174
  <span class="sc-dot ${dots.join(' ')}"></span>
@@ -1109,9 +1176,14 @@ function renderSessions() {
1109
1176
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1110
1177
  ${badge}${queueBadge}
1111
1178
  </div>
1179
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1112
1180
  <span class="sc-arrow">›</span>
1113
- </div>`
1114
- }).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
1115
1187
  $('home-empty').classList.toggle('hidden', items.length > 0)
1116
1188
  const running = state.sessions.filter(s => s.running).length
1117
1189
  const pending = state.approvals.length + state.questions.length
@@ -1245,7 +1317,7 @@ function restoreCachedHistory() {
1245
1317
  if (!cached?.events?.length) return false
1246
1318
  const h = emptyHistory()
1247
1319
  for (const e of cached.events) {
1248
- if (!e?.seq) continue
1320
+ if (e?.seq == null) continue
1249
1321
  h.seqs.add(e.seq)
1250
1322
  h.visible.push(e)
1251
1323
  }
@@ -1613,7 +1685,8 @@ async function goalAction(kind) {
1613
1685
  if (!method) return
1614
1686
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1615
1687
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1616
- 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
1617
1690
  if (kind === 'complete') setGoalPhaseLocal('complete')
1618
1691
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1619
1692
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1622,7 +1695,8 @@ async function goalAction(kind) {
1622
1695
 
1623
1696
  async function interruptSubagent(childId) {
1624
1697
  if (!confirm(t('subagent.confirmInterrupt'))) return
1625
- 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
1626
1700
  toast(t('subagent.interruptSubmitted'), 'ok')
1627
1701
  setTimeout(renderSessionCards, 600)
1628
1702
  }
@@ -1784,7 +1858,18 @@ async function cancelSession() {
1784
1858
  }
1785
1859
 
1786
1860
  async function newSession() {
1787
- 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'))
1788
1873
  if (!v?.sessionId) return
1789
1874
  toast(t('home.created'), 'ok')
1790
1875
  await refreshSessions()
@@ -1831,7 +1916,14 @@ function renderPending() {
1831
1916
  async function approveApproval(id, allow) {
1832
1917
  const a = state.approvals.find(x => x.approvalId === id)
1833
1918
  if (!a) return
1834
- 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
+ }
1835
1927
  toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
1836
1928
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1837
1929
  renderPending()
@@ -1862,7 +1954,14 @@ async function submitQuestion() {
1862
1954
  return ans
1863
1955
  }).filter(Boolean)
1864
1956
  if (!answers.length) return toast(t('question.needAnswer'), 'err')
1865
- 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
+ }
1866
1965
  if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1867
1966
  else toast(t('question.stale'), 'err')
1868
1967
  }
@@ -1910,6 +2009,50 @@ function fsParent(p) {
1910
2009
  return clean.slice(0, idx)
1911
2010
  }
1912
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
+
1913
2056
  function fsApiUrl(sub, params = {}) {
1914
2057
  const u = new URL(apiUrl('/fs' + sub), location.href)
1915
2058
  for (const [k, v] of Object.entries(params)) {
@@ -2333,7 +2476,8 @@ async function submitGoalEdit() {
2333
2476
  if (!goal) return
2334
2477
  const objective = $('goal-edit-text')?.value?.trim()
2335
2478
  if (!objective) return toast(t('goal.cannotEmpty'), 'err')
2336
- 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
2337
2481
  $('modal-goal').classList.add('hidden')
2338
2482
  toast(t('goal.updated'), 'ok')
2339
2483
  scheduleRefresh()
@@ -2477,7 +2621,7 @@ async function checkUpdate(silent) {
2477
2621
  if (!silent) toast(t('update.noVersion'), 'err')
2478
2622
  return
2479
2623
  }
2480
- const base = state.server
2624
+ const base = updateBase()
2481
2625
  if (!base) {
2482
2626
  if (!silent) toast(t('update.needServer'), 'err')
2483
2627
  $('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
@@ -2554,7 +2698,7 @@ async function verifyUpdateApk(info, url) {
2554
2698
  async function downloadUpdate() {
2555
2699
  const info = state.updateInfo
2556
2700
  if (!info) return
2557
- const base = state.server || ''
2701
+ const base = updateBase()
2558
2702
  let url
2559
2703
  try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
2560
2704
  catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
@@ -2769,8 +2913,7 @@ async function schedulePeakReminders() {
2769
2913
  const b = bgBridge()
2770
2914
  if (!b?.startPeakReminder) return false
2771
2915
  try {
2772
- b.startPeakReminder()
2773
- return true
2916
+ return b.startPeakReminder() !== false
2774
2917
  } catch { return false }
2775
2918
  }
2776
2919
 
@@ -2779,8 +2922,7 @@ async function cancelPeakReminders() {
2779
2922
  const b = bgBridge()
2780
2923
  if (!b?.stopPeakReminder) return false
2781
2924
  try {
2782
- b.stopPeakReminder()
2783
- return true
2925
+ return b.stopPeakReminder() !== false
2784
2926
  } catch { return false }
2785
2927
  }
2786
2928
 
@@ -2973,6 +3115,55 @@ function initToken() {
2973
3115
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
2974
3116
  }
2975
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
+
2976
3167
  function renderLangBtn() {
2977
3168
  const btn = $('btn-lang')
2978
3169
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
@@ -3089,6 +3280,12 @@ function bindUi() {
3089
3280
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
3090
3281
  })
3091
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
+ })
3092
3289
  $('btn-cancel').addEventListener('click', cancelSession)
3093
3290
  $('btn-send').addEventListener('click', sendMessage)
3094
3291
  $('btn-plus').addEventListener('click', toggleComposerMenu)
@@ -3136,6 +3333,10 @@ function bindUi() {
3136
3333
  // goal
3137
3334
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
3138
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() })
3139
3340
  // 设置
3140
3341
  $('view-settings').addEventListener('click', (e) => {
3141
3342
  const group = e.target.closest('[data-settings-group]')
@@ -3165,6 +3366,8 @@ function bindUi() {
3165
3366
  $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
3166
3367
  }
3167
3368
  })
3369
+ $('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
3370
+ $('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
3168
3371
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
3169
3372
  $('btn-download-update').addEventListener('click', downloadUpdate)
3170
3373
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
@@ -3191,10 +3394,18 @@ function bindUi() {
3191
3394
  }
3192
3395
  const ok = await ensureNotify()
3193
3396
  if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
3194
- await schedulePeakReminders()
3397
+ const started = await schedulePeakReminders()
3398
+ if (!started) {
3399
+ e.target.checked = false
3400
+ return toast(t('peakRemind.failed'), 'err')
3401
+ }
3195
3402
  toast(t('peakRemind.on'), 'ok')
3196
3403
  } else {
3197
- await cancelPeakReminders()
3404
+ const stopped = await cancelPeakReminders()
3405
+ if (!stopped) {
3406
+ e.target.checked = true
3407
+ return toast(t('peakRemind.failed'), 'err')
3408
+ }
3198
3409
  toast(t('peakRemind.off'), 'ok')
3199
3410
  }
3200
3411
  LS.set('peakRemind', e.target.checked ? '1' : '0')
@@ -3240,6 +3451,7 @@ function bindUi() {
3240
3451
 
3241
3452
  // 文件页
3242
3453
  $('fs-up').addEventListener('click', fsUp)
3454
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
3243
3455
  $('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
3244
3456
  $('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
3245
3457
  $('fs-file-input').addEventListener('change', (e) => {
@@ -3306,6 +3518,7 @@ async function boot() {
3306
3518
  await refreshAll()
3307
3519
  const host = await safeRpc('host.describe', {}, '')
3308
3520
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
3521
+ loadDshControl()
3309
3522
  // 启动后自动检查一次更新(静默)
3310
3523
  setTimeout(() => checkUpdate(true), 4000)
3311
3524
  }
@@ -15,12 +15,29 @@ html, body { height: 100%; margin: 0; overflow: hidden; overscroll-behavior: non
15
15
  .ds-btn:active { filter: brightness(.96); }
16
16
  a.ds-btn { text-decoration: none; }
17
17
  .ds-new-session .ds-btn { width: 100%; justify-content: center; }
18
- .ds-section-label { font-size: 11px; color: var(--dsr-muted); letter-spacing: .8px; padding: 10px 14px 6px; font-weight: 700; }
18
+ .ds-section-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-width: 0; padding-right: 10px; }
19
+ .ds-section-label { min-width: 0; flex: 1; font-size: 11px; color: var(--dsr-muted); letter-spacing: .8px; padding: 10px 14px 6px; font-weight: 700; }
20
+ .ds-session-sort {
21
+ width: 92px; min-width: 0; height: 26px; padding: 0 5px; color: var(--dsr-muted);
22
+ background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 7px;
23
+ font: inherit; font-size: 10.5px; outline: none;
24
+ }
25
+ .ds-session-sort:focus { border-color: var(--dsr-accent-line); color: var(--dsr-text); }
19
26
  .ds-session-list { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0 8px; display: flex; flex-direction: column; gap: 3px; }
20
- .ds-session-item { width: 100%; text-align: left; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 13px; padding: 8px 9px; border-radius: 9px; cursor: pointer; display: flex; flex-direction: column; gap: 3px; }
27
+ .ds-session-group {
28
+ display: flex; align-items: center; gap: 6px; flex: 0 0 auto; min-width: 0; min-height: 28px;
29
+ box-sizing: border-box; padding: 8px 10px 4px; border-bottom: 1px solid var(--dsr-line);
30
+ color: var(--dsr-accent-strong); font-size: 10px; font-weight: 700; line-height: 1.2;
31
+ }
32
+ .ds-session-group-icon { flex: 0 0 auto; color: var(--dsr-accent-2); font-size: 11px; }
33
+ .ds-session-group-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
34
+ .ds-session-item { width: 100%; flex: 0 0 auto; text-align: left; border: none; background: transparent; color: var(--dsr-text); font: inherit; font-size: 13px; padding: 8px 9px; border-radius: 9px; cursor: pointer; display: flex; flex-direction: column; gap: 3px; }
21
35
  .ds-session-item:hover { background: var(--dsr-bg-2); }
22
36
  .ds-session-item.current { background: var(--dsr-accent-soft); color: var(--dsr-accent-strong); }
23
37
  .ds-session-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
38
+ .ds-session-workspace { min-width: 0; color: var(--dsr-muted); font-size: 10.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
39
+ .ds-session-list.workspace-sorted .ds-session-workspace { display: none; }
40
+ .ds-session-list.workspace-sorted .ds-session-item { padding-top: 9px; padding-bottom: 9px; }
24
41
  .ds-session-meta { display: flex; gap: 7px; font-size: 11px; color: var(--dsr-muted); align-items: center; }
25
42
  .ds-session-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsr-line); }
26
43
  .ds-session-dot.running { background: var(--dsr-warning); }
@@ -146,7 +163,7 @@ a.ds-btn { text-decoration: none; }
146
163
  .ds-composer { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--dsr-line); background: var(--dsr-panel); }
147
164
  .ds-composer textarea { width: 100%; min-width: 0; resize: none; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 10px 14px; font: inherit; font-size: 13.5px; line-height: 1.5; outline: none; min-height: 44px; max-height: 120px; box-sizing: border-box; }
148
165
  .ds-composer-actions { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
149
- .ds-composer-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
166
+ .ds-composer-left { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
150
167
  .ds-composer-right { display: flex; align-items: center; flex: none; }
151
168
  .ds-composer .ds-btn { min-height: 34px; box-sizing: border-box; }
152
169
  .ds-send-btn { width: 36px; height: 36px; min-height: 36px; padding: 0; border-radius: 50%; background: var(--dsr-accent-strong); border-color: var(--dsr-accent-strong); color: var(--dsr-on-accent); font-size: 18px; line-height: 1; justify-content: center; }
@@ -269,6 +286,11 @@ a.ds-btn { text-decoration: none; }
269
286
  .ds-modal-card { width: min(520px, 100%); background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 14px; padding: 16px; }
270
287
  .ds-modal-title { font-weight: 700; margin-bottom: 10px; }
271
288
  .ds-modal-body { display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow-y: auto; }
289
+ .ds-workspace-create-desc { margin: 0; color: var(--dsr-muted); line-height: 1.55; }
290
+ .ds-workspace-create-location { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsr-muted); font-size: 12px; }
291
+ .ds-workspace-create-location code { min-width: 0; flex: 1; padding: 7px 9px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsr-text); background: var(--dsr-bg-2); border: 1px solid var(--dsr-line); border-radius: 9px; }
292
+ .ds-workspace-name { width: 100%; box-sizing: border-box; background: var(--dsr-bg-2); color: var(--dsr-text); border: 1px solid var(--dsr-line); border-radius: 9px; padding: 8px 10px; font: inherit; outline: none; }
293
+ .ds-workspace-name:focus { border-color: var(--dsr-accent-line); }
272
294
  .ds-modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
273
295
  .ds-q-item { border: 1px solid var(--dsr-line); border-radius: 10px; padding: 9px 11px; }
274
296
  .ds-q-text { font-size: 13.5px; margin-bottom: 6px; }
@@ -332,3 +354,12 @@ a.ds-btn { text-decoration: none; }
332
354
  .ds-toast-stack { left: 10px; right: 10px; width: auto; }
333
355
  .mobile-only { display: block; }
334
356
  }
357
+
358
+ @media (max-width: 520px) {
359
+ .ds-topbar { gap: 6px; padding: 0 8px; }
360
+ .ds-top-right { gap: 5px; }
361
+ .ds-composer-actions { align-items: stretch; flex-wrap: wrap; gap: 6px; }
362
+ .ds-composer-left { flex: 1 1 100%; gap: 5px; }
363
+ .ds-composer-left .ds-btn { flex: 1 1 0; min-width: 0; padding-left: 7px; padding-right: 7px; overflow: hidden; text-overflow: ellipsis; }
364
+ .ds-composer-right { margin-left: auto; }
365
+ }
@@ -20,7 +20,14 @@
20
20
  <div class="ds-new-session">
21
21
  <button id="btn-new-session" class="ds-btn primary" data-i18n="ds.newSession">+ 新会话</button>
22
22
  </div>
23
- <div class="ds-section-label" data-i18n="ds.sessions">会话</div>
23
+ <div class="ds-section-head">
24
+ <div class="ds-section-label" data-i18n="ds.sessions">会话</div>
25
+ <select id="session-sort" class="ds-session-sort" data-i18n-aria="ds.sortLabel">
26
+ <option value="time" data-i18n="ds.sortTime">按时间</option>
27
+ <option value="workspace" data-i18n="ds.sortWorkspace">按工作区</option>
28
+ </select>
29
+ <button id="btn-new-workspace" class="ds-icon-btn ds-workspace-create" data-i18n-title="ds.newWorkspace" data-i18n-aria="ds.newWorkspace">+</button>
30
+ </div>
24
31
  <div class="ds-session-list" id="session-list"></div>
25
32
  <div class="ds-sidebar-foot">
26
33
  <button class="ds-nav-item" data-view="view-files"><span class="ds-nav-ico">⇅</span><span data-i18n="ds.files">文件传输</span></button>
@@ -116,6 +123,7 @@
116
123
  <div class="ds-fs-bar">
117
124
  <button id="fs-up" class="ds-btn" data-i18n="ds.fsUp">上级</button>
118
125
  <span id="fs-path" class="ds-fs-path">/</span>
126
+ <button id="fs-new-workspace" class="ds-btn" data-i18n="ds.fsNewWorkspace">新建工作区</button>
119
127
  <button id="fs-refresh" class="ds-btn" data-i18n="ds.fsRefresh">刷新</button>
120
128
  </div>
121
129
  <div id="fs-list" class="ds-fs-list"></div>
@@ -266,6 +274,22 @@
266
274
  </div>
267
275
  </div>
268
276
 
277
+ <!-- 新建工作区模态 -->
278
+ <div id="modal-workspace" class="ds-modal hidden">
279
+ <div class="ds-modal-card">
280
+ <div class="ds-modal-title" data-i18n="ds.workspaceCreateTitle">新建工作区</div>
281
+ <div class="ds-modal-body">
282
+ <p class="ds-workspace-create-desc" data-i18n="ds.workspaceCreateDesc">将在当前文件目录下创建文件夹,并自动打开新会话。</p>
283
+ <div class="ds-workspace-create-location"><span data-i18n="ds.workspaceParent">父目录</span><code id="workspace-parent-path">~</code></div>
284
+ <input id="workspace-name" class="ds-workspace-name" maxlength="120" autocomplete="off" data-i18n-placeholder="ds.workspaceNamePlaceholder" placeholder="例如:my-project">
285
+ </div>
286
+ <div class="ds-modal-actions">
287
+ <button id="workspace-cancel" class="ds-btn" data-i18n="ds.cancel">取消</button>
288
+ <button id="workspace-create" class="ds-btn primary" data-i18n="ds.workspaceCreate">创建并打开</button>
289
+ </div>
290
+ </div>
291
+ </div>
292
+
269
293
  <!-- 预设提示词管理模态 -->
270
294
  <div id="modal-presets" class="ds-modal hidden">
271
295
  <div class="ds-modal-card">
@@ -321,14 +345,16 @@
321
345
  <script>
322
346
  window.DESKTOP_STR = {
323
347
  zh: {
324
- 'ds.repo': 'GitHub 仓库', 'ds.newSession': '+ 新会话', 'ds.sessions': '会话',
348
+ 'ds.repo': 'GitHub 仓库', 'ds.newSession': '+ 新会话', 'ds.newWorkspace': '新建工作区', 'ds.sessions': '会话',
349
+ 'ds.sortLabel': '会话排序', 'ds.sortTime': '按时间', 'ds.sortWorkspace': '按工作区', 'ds.workspace': '工作区', 'ds.workspaceUnknown': '未指定工作区',
325
350
  'ds.files': '文件传输', 'ds.settings': '设置', 'ds.stats': '统计', 'ds.menu': '导航',
326
351
  'ds.send': '发送', 'ds.composerPlaceholder': '输入消息,Enter 发送…', 'ds.presets': '预设',
327
352
  'ds.commands': '指令',
328
353
  'ds.cmdCompact': '/compact 压缩对话历史', 'ds.cmdExport': '/export 导出会话日志 ZIP',
329
354
  'ds.cmdFeedback': '/feedback 反馈当前会话', 'ds.cmdGoal': '/goal 设置/查看任务目标',
330
355
  'ds.cmdPermission': '/permission 切换权限预设', 'ds.cmdPlan': '/plan 进入/退出计划模式',
331
- 'ds.fsUp': '上级', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
356
+ 'ds.fsUp': '上级', 'ds.fsNewWorkspace': '新建工作区', 'ds.fsRefresh': '刷新', 'ds.fsEmpty': '目录为空',
357
+ 'ds.workspaceCreateTitle': '新建工作区', 'ds.workspaceCreateDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'ds.workspaceParent': '父目录', 'ds.workspaceNamePlaceholder': '例如:my-project', 'ds.workspaceCreate': '创建并打开', 'ds.cancel': '取消', 'ds.workspaceNameRequired': '请输入工作区名称', 'ds.workspaceExists': '该目录已存在', 'ds.workspaceInvalidName': '名称不能包含路径分隔符', 'ds.workspaceCreateFailed': '创建工作区失败', 'ds.workspaceCreated': '工作区已创建', 'ds.workspaceCreatedNoSession': '工作区已创建,但新会话未能打开',
332
358
  'ds.groupGeneral': '通用', 'ds.groupGeneralDesc': '工具调用、预设提示词',
333
359
  'ds.groupServers': '服务器', 'ds.groupServersDesc': '服务器地址',
334
360
  'ds.groupNotify': '通知', 'ds.groupNotifyDesc': '通知与提醒',
@@ -365,7 +391,7 @@
365
391
  'ds.approvalTitle': '工具审批', 'ds.questionNotify': '需要回答',
366
392
  'ds.approvalBody': '{tool} · {server}', 'ds.approvalReason': '原因:{reason}',
367
393
  'ds.allowed': '已允许', 'ds.rejected': '已拒绝', 'ds.ignored': '已忽略',
368
- 'ds.stale': '请求已失效',
394
+ 'ds.stale': '请求已失效', 'ds.pendingSubmitFailed': '提交失败:{msg}',
369
395
  'ds.statsTodayTokens': '今日 Token', 'ds.statsTodayCost': '今日费用', 'ds.statsPeakShare': '高峰占比',
370
396
  'ds.statsInput': '未缓存输入', 'ds.statsCacheRead': '缓存命中', 'ds.statsCacheWrite': '缓存写入', 'ds.statsOutput': '输出',
371
397
  'ds.statsPeak': '高峰', 'ds.statsOff': '空闲', 'ds.statsDays': '近 {n} 天',
@@ -416,14 +442,16 @@
416
442
  'menu.modelTitle': '模型切换', 'menu.effortTitle': '思考深度',
417
443
  },
418
444
  en: {
419
- 'ds.repo': 'GitHub repo', 'ds.newSession': '+ New session', 'ds.sessions': 'Sessions',
445
+ 'ds.repo': 'GitHub repo', 'ds.newSession': '+ New session', 'ds.newWorkspace': 'New workspace', 'ds.sessions': 'Sessions',
446
+ 'ds.sortLabel': 'Session sort', 'ds.sortTime': 'By time', 'ds.sortWorkspace': 'By workspace', 'ds.workspace': 'Workspace', 'ds.workspaceUnknown': 'No workspace',
420
447
  'ds.files': 'Files', 'ds.settings': 'Settings', 'ds.stats': 'Stats', 'ds.menu': 'Menu',
421
448
  'ds.send': 'Send', 'ds.composerPlaceholder': 'Type a message, Enter to send…', 'ds.presets': 'Presets',
422
449
  'ds.commands': 'Commands',
423
450
  'ds.cmdCompact': '/compact Compress conversation history', 'ds.cmdExport': '/export Export session log ZIP',
424
451
  'ds.cmdFeedback': '/feedback Feedback current session', 'ds.cmdGoal': '/goal Set/view task goal',
425
452
  'ds.cmdPermission': '/permission Switch permission preset', 'ds.cmdPlan': '/plan Enter/exit plan mode',
426
- 'ds.fsUp': 'Up', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
453
+ 'ds.fsUp': 'Up', 'ds.fsNewWorkspace': 'New workspace', 'ds.fsRefresh': 'Refresh', 'ds.fsEmpty': 'Empty directory',
454
+ 'ds.workspaceCreateTitle': 'New workspace', 'ds.workspaceCreateDesc': 'Create a folder in the current file directory and open a new session there.', 'ds.workspaceParent': 'Parent folder', 'ds.workspaceNamePlaceholder': 'For example: my-project', 'ds.workspaceCreate': 'Create & open', 'ds.cancel': 'Cancel', 'ds.workspaceNameRequired': 'Enter a workspace name', 'ds.workspaceExists': 'That folder already exists', 'ds.workspaceInvalidName': 'The name cannot contain path separators', 'ds.workspaceCreateFailed': 'Could not create workspace', 'ds.workspaceCreated': 'Workspace created', 'ds.workspaceCreatedNoSession': 'Workspace created, but the new session could not be opened',
427
455
  'ds.groupGeneral': 'General', 'ds.groupGeneralDesc': 'Tool calls, prompt presets',
428
456
  'ds.groupServers': 'Servers', 'ds.groupServersDesc': 'Server address',
429
457
  'ds.groupNotify': 'Notifications', 'ds.groupNotifyDesc': 'Notifications & reminders',
@@ -460,7 +488,7 @@
460
488
  'ds.approvalTitle': 'Tool approval', 'ds.questionNotify': 'Question',
461
489
  'ds.approvalBody': '{tool} · {server}', 'ds.approvalReason': 'Reason: {reason}',
462
490
  'ds.allowed': 'Allowed', 'ds.rejected': 'Rejected', 'ds.ignored': 'Ignored',
463
- 'ds.stale': 'Request is stale',
491
+ 'ds.stale': 'Request is stale', 'ds.pendingSubmitFailed': 'Submit failed: {msg}',
464
492
  'ds.statsTodayTokens': 'Tokens today', 'ds.statsTodayCost': 'Cost today', 'ds.statsPeakShare': 'Peak share',
465
493
  'ds.statsInput': 'Uncached input', 'ds.statsCacheRead': 'Cache read', 'ds.statsCacheWrite': 'Cache write', 'ds.statsOutput': 'Output',
466
494
  'ds.statsPeak': 'Peak', 'ds.statsOff': 'Off-peak', 'ds.statsDays': 'Last {n} days',