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.
@@ -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 },
@@ -495,12 +496,16 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
495
496
 
496
497
  /* ---------------- API ---------------- */
497
498
  function apiUrl(path) { return (state.server || '') + path }
498
- async function rpc(method, payload = {}) {
499
- const res = await fetch(apiUrl('/api/' + method), {
499
+ async function rpc(method, payload = {}, timeoutMs = 45000) {
500
+ const opts = {
500
501
  method: 'POST',
501
502
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
502
503
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
503
- })
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)
504
509
  if (res.status === 401) throw new Error('AUTH')
505
510
  if (!res.ok) throw new Error('HTTP ' + res.status)
506
511
  const full = await res.json()
@@ -509,11 +514,15 @@ async function rpc(method, payload = {}) {
509
514
  return full.result.value
510
515
  }
511
516
  async function respond(rpcId, value) {
512
- const res = await fetch(apiUrl('/api/respond'), {
517
+ const opts = {
513
518
  method: 'POST',
514
519
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
515
520
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
516
- })
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)
517
526
  if (res.status === 401) throw new Error('AUTH')
518
527
  const receipt = await res.json()
519
528
  return receipt?.accepted === true
@@ -610,7 +619,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
610
619
  let best = null
611
620
  let ms = Infinity
612
621
  if (state.autoSelect[state.activeGroup] !== false) {
613
- 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
614
624
  best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
615
625
  chosen = best || (state.server || '')
616
626
  ms = best ? state.serverLatency[best] : Infinity
@@ -972,7 +982,12 @@ async function pollKind(kind) {
972
982
  let data
973
983
  try { data = await res.json() } catch { return }
974
984
  if (!data || !Array.isArray(data.events)) return
975
- 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
+ }
976
991
  for (const item of data.events) {
977
992
  if (item.seq > (state.pollSeq[kind] || 0)) {
978
993
  state.pollSeq[kind] = item.seq
@@ -1056,7 +1071,7 @@ function applyProjection(sessionId, key, value, seq) {
1056
1071
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
1057
1072
  }
1058
1073
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1059
- 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')) }
1060
1075
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1061
1076
  function isGoalTerminal(goal) {
1062
1077
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1088,17 +1103,55 @@ async function refreshSessions() {
1088
1103
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1089
1104
  renderSessions()
1090
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
+ }
1091
1130
  function renderSessions() {
1092
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1093
- 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
+ }
1094
1141
  const title = titleOf(s)
1095
- 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)}">
1096
1143
  <span class="ds-session-title">${esc(title)}</span>
1144
+ <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1097
1145
  <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1098
- </button>`
1099
- }).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>`
1100
1149
  $('session-list').innerHTML = html
1101
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
1102
1155
  document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
1103
1156
  }
1104
1157
 
@@ -1271,7 +1324,8 @@ async function goalAction(kind) {
1271
1324
  const objective = prompt(t('goal.editPrompt'), goal.objective || '')
1272
1325
  if (objective === null) return
1273
1326
  if (!objective.trim()) return toast(t('goal.cannotEmpty'), 'err')
1274
- 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
1275
1329
  toast(t('goal.updated'), 'ok')
1276
1330
  refreshSessions()
1277
1331
  renderSessionCards()
@@ -1282,7 +1336,8 @@ async function goalAction(kind) {
1282
1336
  if (!method) return
1283
1337
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1284
1338
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1285
- 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
1286
1341
  if (kind === 'complete') setGoalPhaseLocal('complete')
1287
1342
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1288
1343
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1292,7 +1347,8 @@ async function goalAction(kind) {
1292
1347
 
1293
1348
  async function interruptSubagent(childId) {
1294
1349
  if (!confirm(t('subagent.confirmInterrupt'))) return
1295
- 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
1296
1352
  toast(t('subagent.interruptSubmitted'), 'ok')
1297
1353
  setTimeout(renderSessionCards, 600)
1298
1354
  }
@@ -1384,7 +1440,14 @@ function renderNotifStack() {
1384
1440
  async function approveApproval(id, allow) {
1385
1441
  const a = state.approvals.find(x => x.approvalId === id)
1386
1442
  if (!a) return
1387
- 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
+ }
1388
1451
  toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
1389
1452
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1390
1453
  renderNotifStack()
@@ -1413,7 +1476,14 @@ async function submitQuestion() {
1413
1476
  return ans
1414
1477
  }).filter(Boolean)
1415
1478
  if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
1416
- 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
+ }
1417
1487
  if (ok) {
1418
1488
  toast(t('ds.questionSubmitted'), 'ok')
1419
1489
  $('modal-question').classList.add('hidden')
@@ -1439,6 +1509,48 @@ function fsParent(p) {
1439
1509
  parts.pop()
1440
1510
  return parts.length ? '/' + parts.join('/') : '/'
1441
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
+ }
1442
1554
  async function loadFs(dir, silent) {
1443
1555
  if (!state.token) {
1444
1556
  $('fs-path').textContent = t('ds.toastAuth')
@@ -1615,9 +1727,22 @@ function updateConn() {
1615
1727
  /* ---------------- 初始化 ---------------- */
1616
1728
  function bindUi() {
1617
1729
  $('btn-new-session').addEventListener('click', async () => {
1618
- 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, '')
1619
1738
  if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
1620
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
+ })
1621
1746
  $('btn-mobile-nav').addEventListener('click', () => {
1622
1747
  const list = $('mobile-session-list')
1623
1748
  list.style.display = list.style.display === 'none' ? 'flex' : 'none'
@@ -1676,6 +1801,10 @@ function bindUi() {
1676
1801
  $('notes-prev').addEventListener('click', () => scrollNotes(-1))
1677
1802
  $('notes-next').addEventListener('click', () => scrollNotes(1))
1678
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() })
1679
1808
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
1680
1809
  // 反馈
1681
1810
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
@@ -1738,6 +1867,7 @@ function bindUi() {
1738
1867
  renderServers(); renderSessions(); updateConn(); themeApply()
1739
1868
  })
1740
1869
  $('fs-up').addEventListener('click', fsUp)
1870
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
1741
1871
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
1742
1872
  $('btn-question-submit').addEventListener('click', submitQuestion)
1743
1873
  $('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })
package/public/index.html CHANGED
@@ -33,7 +33,14 @@
33
33
  <div class="stat-strip" id="stat-strip"></div>
34
34
  <div class="section-head">
35
35
  <span data-i18n="nav.sessions">会话</span>
36
- <button id="btn-new-session" class="mini-btn" data-i18n="home.newSession">+ 新会话</button>
36
+ <div class="section-actions">
37
+ <select id="session-sort" class="session-sort" data-i18n-aria="sessions.sortLabel">
38
+ <option value="time" data-i18n="sessions.sortTime">按时间</option>
39
+ <option value="workspace" data-i18n="sessions.sortWorkspace">按工作区</option>
40
+ </select>
41
+ <button id="btn-new-workspace" class="mini-btn" data-i18n="home.newWorkspace">+ 工作区</button>
42
+ <button id="btn-new-session" class="mini-btn" data-i18n="home.newSession">+ 新会话</button>
43
+ </div>
37
44
  </div>
38
45
  <div id="session-list" class="session-list"></div>
39
46
  <div id="home-empty" class="empty hidden" data-i18n="home.empty">暂无会话</div>
@@ -44,6 +51,7 @@
44
51
  <div class="fs-head">
45
52
  <button id="fs-up" class="icon-btn" data-i18n-title="fs.up" data-i18n-aria="fs.up">‹</button>
46
53
  <div id="fs-path" class="fs-path" data-i18n="fs.loading">加载中…</div>
54
+ <button id="fs-new-workspace" class="mini-btn" data-i18n="fs.newWorkspace">新建工作区</button>
47
55
  <button id="fs-upload-btn" class="mini-btn" data-i18n="fs.upload">上传</button>
48
56
  <button id="fs-refresh" class="icon-btn" data-i18n-title="a11y.refresh" data-i18n-aria="a11y.refresh">⟳</button>
49
57
  </div>
@@ -328,6 +336,13 @@
328
336
  <div><div class="setting-name" data-i18n="settings.hostTitle">DSH 状态</div><div class="setting-desc" id="host-desc" data-i18n="settings.hostProbing">探测中…</div></div>
329
337
  <button id="btn-host-describe" class="mini-btn" data-i18n="settings.probe">探测</button>
330
338
  </div>
339
+ <div class="setting-row">
340
+ <div><div class="setting-name" data-i18n="settings.dshControlTitle">远程启动 DSH</div><div class="setting-desc" id="dsh-control-desc" data-i18n="settings.dshControlProbing">检查本机服务状态…</div></div>
341
+ <div class="setting-actions">
342
+ <button id="btn-dsh-start" class="mini-btn" data-i18n="settings.dshStart">启动</button>
343
+ <button id="btn-dsh-restart" class="mini-btn" data-i18n="settings.dshRestart">重启</button>
344
+ </div>
345
+ </div>
331
346
  <div class="setting-row">
332
347
  <div><div class="setting-name" data-i18n="settings.updateTitle">检查更新</div><div class="setting-desc" id="update-desc" data-i18n="settings.updateLoading">加载中…</div>
333
348
  <button id="btn-download-update" class="mini-btn hidden" style="margin-top:6px" data-i18n="settings.downloadUpdate">下载并安装更新</button>
@@ -418,6 +433,22 @@
418
433
  </div>
419
434
  </div>
420
435
 
436
+ <!-- 新建工作区模态 -->
437
+ <div id="modal-workspace" class="modal hidden">
438
+ <div class="modal-card">
439
+ <div class="modal-title" data-i18n="workspace.createTitle">新建工作区</div>
440
+ <div class="modal-body">
441
+ <p class="workspace-create-desc" data-i18n="workspace.createDesc">将在当前文件目录下创建文件夹,并自动打开新会话。</p>
442
+ <div class="workspace-create-location"><span data-i18n="workspace.parent">父目录</span><code id="workspace-parent-path">~</code></div>
443
+ <input id="workspace-name" class="fb-input" maxlength="120" autocomplete="off" data-i18n-placeholder="workspace.namePlaceholder" placeholder="例如:my-project">
444
+ </div>
445
+ <div class="modal-actions">
446
+ <button id="workspace-cancel" class="btn subtle" data-i18n="workspace.cancel">取消</button>
447
+ <button id="workspace-create" class="btn primary" data-i18n="workspace.create">创建并打开</button>
448
+ </div>
449
+ </div>
450
+ </div>
451
+
421
452
  <!-- goal 模态 -->
422
453
  <div id="modal-goal" class="modal hidden">
423
454
  <div class="modal-card">
@@ -502,8 +533,11 @@
502
533
  'statsPage.peak': '高峰', 'statsPage.off': '空闲', 'statsPage.days': '近 {n} 天',
503
534
  'statsPage.gatewayDown': '统计需要网关运行', 'statsPage.empty': '暂无统计,产生会话后自动聚合',
504
535
  'statsPage.note': '注:本数据仅在使用 DeepSeek 官方 API 时估算;基于 token 计算,与官网账单可能有出入,一切以官网为准。统计自 2026-08-17 定价生效日起。',
505
- 'home.newSession': '+ 新会话', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
536
+ 'home.newSession': '+ 新会话', 'home.newWorkspace': '+ 工作区', 'home.empty': '暂无会话', 'home.createFailed': '新建会话失败', 'home.created': '会话已创建',
537
+ 'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.exists': '该目录已存在', 'workspace.invalidName': '名称不能包含路径分隔符', 'workspace.createFailed': '创建工作区失败:{msg}', 'workspace.created': '工作区已创建', 'workspace.createdNoSession': '工作区已创建,但新会话未能打开',
538
+ 'workspace.createTitle': '新建工作区', 'workspace.createDesc': '将在当前文件目录下创建文件夹,并自动打开新会话。', 'workspace.parent': '父目录', 'workspace.namePlaceholder': '例如:my-project', 'workspace.cancel': '取消', 'workspace.create': '创建并打开', 'workspace.nameRequired': '请输入工作区名称', 'workspace.created': '工作区已创建', 'workspace.sessionFailed': '目录已创建,但新会话打开失败:{msg}',
506
539
  'sessions.running': '运行中', 'sessions.statRunning': '运行中', 'sessions.statPending': '待处理', 'sessions.statTotal': '会话总数',
540
+ 'sessions.sortLabel': '会话排序', 'sessions.sortTime': '按时间', 'sessions.sortWorkspace': '按工作区', 'sessions.workspace': '工作区', 'sessions.workspaceUnknown': '未指定工作区',
507
541
  'sessions.queueBadge': '队列 {n}', 'sessions.goalBadge': '目标·{phase}',
508
542
  'sessions.cacheFallback': '网络不可用:显示本地缓存的会话列表',
509
543
  'err.badResponse': '坏响应', 'err.dshError': 'DSH 返回错误', 'err.accessDenied': '访问被拒绝:请检查令牌',
@@ -570,11 +604,11 @@
570
604
  'pending.title': '待处理', 'pending.count': '{n} 项', 'pending.approvalTitle': '🔧 {tool} 请求批准', 'pending.noReason': '无说明',
571
605
  'pending.allow': '允许', 'pending.reject': '拒绝', 'pending.question': '❓ {question}',
572
606
  'pending.questionCount': '共 {n} 个问题', 'pending.answer': '去回答', 'pending.empty': '暂无待处理事项',
573
- 'pending.allowed': '已允许', 'pending.rejected': '已拒绝', 'pending.stale': '审批已不在待处理状态',
607
+ 'pending.allowed': '已允许', 'pending.rejected': '已拒绝', 'pending.stale': '审批已不在待处理状态', 'pending.submitFailed': '审批提交失败:{msg}',
574
608
  'question.customPlaceholder': '其他 / 自定义回答(可选)', 'question.needAnswer': '请先选择或填写回答',
575
- 'question.submitted': '已提交回答', 'question.stale': '提问已不在待处理状态',
609
+ 'question.submitted': '已提交回答', 'question.stale': '提问已不在待处理状态', 'question.submitFailed': '回答提交失败:{msg}',
576
610
  'jobs.title': '后台任务', 'jobs.empty': '暂无后台任务',
577
- 'fs.loading': '加载中…', 'fs.up': '返回上级', 'fs.upload': '上传', 'fs.pause': '暂停', 'fs.resume': '继续', 'fs.cancel': '取消',
611
+ 'fs.loading': '加载中…', 'fs.up': '返回上级', 'fs.newWorkspace': '新建工作区', 'fs.upload': '上传', 'fs.pause': '暂停', 'fs.resume': '继续', 'fs.cancel': '取消',
578
612
  'fs.pullDown': '下拉刷新', 'fs.pullRelease': '松开刷新', 'fs.refreshing': '刷新中…',
579
613
  'fs.noToken': '未设置令牌', 'fs.goSettings': '请先到「设置」页粘贴网关令牌',
580
614
  'fs.notFound': '目录不存在', 'fs.forbidden': '路径不在允许范围内',
@@ -625,7 +659,7 @@
625
659
  'peakRemind.peak0912': '高峰 9:00-12:00', 'peakRemind.off1214': '空闲 12:00-14:00',
626
660
  'peakRemind.peak1418': '高峰 14:00-18:00', 'peakRemind.off1809': '空闲 18:00-次日 9:00',
627
661
  'peakRemind.browserOnly': '峰谷提醒仅在 App 内有效',
628
- 'peakRemind.on': '峰谷提醒已开启', 'peakRemind.off': '峰谷提醒已关闭',
662
+ 'peakRemind.on': '峰谷提醒已开启', 'peakRemind.off': '峰谷提醒已关闭', 'peakRemind.failed': '峰谷提醒服务启动失败,请重试',
629
663
  'settings.bgTitle': '后台轮询', 'settings.bgDesc': '退后台后由前台服务定时拉取事件,待办审批/提问不遗漏',
630
664
  'settings.bgIntervalTitle': '轮询间隔', 'settings.bgIntervalDesc': '灭屏后系统可能拉长间隔(Doze 平台限制)',
631
665
  'settings.bgInterval30s': '30 秒', 'settings.bgInterval1m': '1 分钟', 'settings.bgInterval5m': '5 分钟', 'settings.bgInterval15m': '15 分钟',
@@ -646,6 +680,7 @@
646
680
  'theme.panelTitle': '选择配色', 'theme.close': '关闭',
647
681
  'settings.tokenTitle': '访问令牌', 'settings.change': '更换',
648
682
  'settings.hostTitle': 'DSH 状态', 'settings.hostProbing': '探测中…', 'settings.probe': '探测', 'settings.probeFailed': '探测失败',
683
+ 'settings.dshControlTitle': '远程启动 DSH', 'settings.dshControlProbing': '检查本机服务状态…', 'settings.dshRunning': '运行中', 'settings.dshStopped': '已停止', 'settings.dshUnsupported': '当前系统未配置可控的 DSH 服务', 'settings.dshStart': '启动', 'settings.dshRestart': '重启', 'settings.dshStarting': '正在{action} DSH…', 'settings.dshStarted': 'DSH 已{action}', 'settings.dshFailed': 'DSH 操作失败:{msg}',
649
684
  'settings.hostDesc': 'DSH {version} · {cwd} · 附加会话 {n}',
650
685
  'settings.updateTitle': '检查更新', 'settings.updateLoading': '加载中…', 'settings.downloadUpdate': '下载并安装更新', 'settings.check': '检查',
651
686
  'settings.feedbackTitle': '反馈渠道', 'settings.feedbackDesc': 'GitHub / Gitee / B站:反馈 bug、提建议、唠嗑',
@@ -684,8 +719,11 @@
684
719
  'statsPage.peak': 'Peak', 'statsPage.off': 'Off-peak', 'statsPage.days': 'Last {n} days',
685
720
  'statsPage.gatewayDown': 'Stats require the gateway', 'statsPage.empty': 'No stats yet — they aggregate as sessions happen',
686
721
  'statsPage.note': 'Note: estimates assume the official DeepSeek API. Token-based calculation may differ from the official bill; always defer to deepseek.com. Stats start from the 2026-08-17 pricing date.',
687
- 'home.newSession': '+ New session', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
722
+ 'home.newSession': '+ New session', 'home.newWorkspace': '+ Workspace', 'home.empty': 'No sessions yet', 'home.createFailed': 'Failed to create session', 'home.created': 'Session created',
723
+ 'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.exists': 'That folder already exists', 'workspace.invalidName': 'The name cannot contain path separators', 'workspace.createFailed': 'Could not create workspace: {msg}', 'workspace.created': 'Workspace created', 'workspace.createdNoSession': 'Workspace created, but the new session could not be opened',
724
+ 'workspace.createTitle': 'New workspace', 'workspace.createDesc': 'Create a folder in the current file directory and open a new session there.', 'workspace.parent': 'Parent folder', 'workspace.namePlaceholder': 'For example: my-project', 'workspace.cancel': 'Cancel', 'workspace.create': 'Create & open', 'workspace.nameRequired': 'Enter a workspace name', 'workspace.created': 'Workspace created', 'workspace.sessionFailed': 'Folder created, but the new session could not be opened: {msg}',
688
725
  'sessions.running': 'Running', 'sessions.statRunning': 'Running', 'sessions.statPending': 'Pending', 'sessions.statTotal': 'Sessions',
726
+ 'sessions.sortLabel': 'Session sort', 'sessions.sortTime': 'By time', 'sessions.sortWorkspace': 'By workspace', 'sessions.workspace': 'Workspace', 'sessions.workspaceUnknown': 'No workspace',
689
727
  'sessions.queueBadge': 'Queue {n}', 'sessions.goalBadge': 'Goal·{phase}',
690
728
  'sessions.cacheFallback': 'Network unavailable: showing cached session list',
691
729
  'err.badResponse': 'Bad response', 'err.dshError': 'DSH returned an error', 'err.accessDenied': 'Access denied: check your token',
@@ -752,11 +790,11 @@
752
790
  'pending.title': 'Inbox', 'pending.count': '{n} items', 'pending.approvalTitle': '🔧 {tool} requests approval', 'pending.noReason': 'No reason given',
753
791
  'pending.allow': 'Allow', 'pending.reject': 'Reject', 'pending.question': '❓ {question}',
754
792
  'pending.questionCount': '{n} questions', 'pending.answer': 'Answer', 'pending.empty': 'Nothing pending',
755
- 'pending.allowed': 'Allowed', 'pending.rejected': 'Rejected', 'pending.stale': 'Approval is no longer pending',
793
+ 'pending.allowed': 'Allowed', 'pending.rejected': 'Rejected', 'pending.stale': 'Approval is no longer pending', 'pending.submitFailed': 'Approval failed: {msg}',
756
794
  'question.customPlaceholder': 'Other / custom answer (optional)', 'question.needAnswer': 'Select or type an answer first',
757
- 'question.submitted': 'Answer submitted', 'question.stale': 'Question is no longer pending',
795
+ 'question.submitted': 'Answer submitted', 'question.stale': 'Question is no longer pending', 'question.submitFailed': 'Answer failed: {msg}',
758
796
  'jobs.title': 'Background jobs', 'jobs.empty': 'No background jobs',
759
- 'fs.loading': 'Loading…', 'fs.up': 'Up one level', 'fs.upload': 'Upload', 'fs.pause': 'Pause', 'fs.resume': 'Resume', 'fs.cancel': 'Cancel',
797
+ 'fs.loading': 'Loading…', 'fs.up': 'Up one level', 'fs.newWorkspace': 'New workspace', 'fs.upload': 'Upload', 'fs.pause': 'Pause', 'fs.resume': 'Resume', 'fs.cancel': 'Cancel',
760
798
  'fs.pullDown': 'Pull to refresh', 'fs.pullRelease': 'Release to refresh', 'fs.refreshing': 'Refreshing…',
761
799
  'fs.noToken': 'No token set', 'fs.goSettings': 'Go to Settings and paste the gateway token first',
762
800
  'fs.notFound': 'Directory not found', 'fs.forbidden': 'Path is outside the allowed roots',
@@ -807,7 +845,7 @@
807
845
  'peakRemind.peak0912': 'Peak 9:00-12:00', 'peakRemind.off1214': 'Off-peak 12:00-14:00',
808
846
  'peakRemind.peak1418': 'Peak 14:00-18:00', 'peakRemind.off1809': 'Off-peak 18:00-9:00 next day',
809
847
  'peakRemind.browserOnly': 'Peak reminders only work in the app',
810
- 'peakRemind.on': 'Peak reminders enabled', 'peakRemind.off': 'Peak reminders disabled',
848
+ 'peakRemind.on': 'Peak reminders enabled', 'peakRemind.off': 'Peak reminders disabled', 'peakRemind.failed': 'Could not start peak reminder service; try again',
811
849
  'settings.bgTitle': 'Background polling', 'settings.bgDesc': 'A foreground service polls events while the app is in the background, so approvals/questions are not missed',
812
850
  'settings.bgIntervalTitle': 'Polling interval', 'settings.bgIntervalDesc': 'Screen-off may stretch the interval (Doze platform limit)',
813
851
  'settings.bgInterval30s': '30 seconds', 'settings.bgInterval1m': '1 minute', 'settings.bgInterval5m': '5 minutes', 'settings.bgInterval15m': '15 minutes',
@@ -828,6 +866,7 @@
828
866
  'theme.panelTitle': 'Choose theme', 'theme.close': 'Close',
829
867
  'settings.tokenTitle': 'Access token', 'settings.change': 'Change',
830
868
  'settings.hostTitle': 'DSH status', 'settings.hostProbing': 'Probing…', 'settings.probe': 'Probe', 'settings.probeFailed': 'Probe failed',
869
+ 'settings.dshControlTitle': 'Remote DSH control', 'settings.dshControlProbing': 'Checking local service…', 'settings.dshRunning': 'Running', 'settings.dshStopped': 'Stopped', 'settings.dshUnsupported': 'No controllable DSH service is configured', 'settings.dshStart': 'Start', 'settings.dshRestart': 'Restart', 'settings.dshStarting': '{action}ing DSH…', 'settings.dshStarted': 'DSH {action}ed', 'settings.dshFailed': 'DSH operation failed: {msg}',
831
870
  'settings.hostDesc': 'DSH {version} · {cwd} · {n} attached sessions',
832
871
  'settings.updateTitle': 'Check for updates', 'settings.updateLoading': 'Loading…', 'settings.downloadUpdate': 'Download & install update', 'settings.check': 'Check',
833
872
  'settings.feedbackTitle': 'Feedback', 'settings.feedbackDesc': 'GitHub / Gitee / Bilibili: report bugs, suggest features, or just chat',
package/public/styles.css CHANGED
@@ -120,9 +120,17 @@ a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
120
120
 
121
121
  .section-head {
122
122
  display: flex; align-items: center; justify-content: space-between;
123
- margin: 14px 2px 8px; font-size: 13px; font-weight: 700; color: var(--dsr-muted);
123
+ gap: 8px; min-width: 0; margin: 14px 2px 8px; font-size: 13px; font-weight: 700; color: var(--dsr-muted);
124
124
  letter-spacing: 1px;
125
125
  }
126
+ .section-head > span:first-child { min-width: 0; }
127
+ .section-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; flex-wrap: wrap; }
128
+ .session-sort {
129
+ min-width: 0; max-width: 112px; height: 30px; padding: 0 7px;
130
+ color: var(--dsr-muted); background: var(--dsr-panel); border: 1px solid var(--dsr-line);
131
+ border-radius: 8px; font: inherit; font-size: 11px; outline: none;
132
+ }
133
+ .session-sort:focus { border-color: var(--dsr-accent-line); color: var(--dsr-text); }
126
134
  .section-head.small { margin-top: 6px; }
127
135
 
128
136
  /* ---------- Token 统计(移动端) ---------- */
@@ -155,7 +163,7 @@ a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
155
163
  .session-list { display: flex; flex-direction: column; gap: 8px; }
156
164
  .session-card {
157
165
  background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: var(--dsr-radius);
158
- padding: 12px 13px; position: relative; overflow: hidden;
166
+ flex: 0 0 auto; padding: 12px 13px; position: relative; overflow: hidden;
159
167
  }
160
168
  .session-card:active { background: var(--dsr-panel-2); }
161
169
  .session-card.current { border-color: var(--dsr-accent-line); box-shadow: 0 0 0 1px var(--dsr-accent-soft); }
@@ -164,6 +172,20 @@ a.mini-btn { text-decoration: none; display: inline-flex; align-items: center; }
164
172
  text-overflow: ellipsis; padding-right: 44px;
165
173
  }
166
174
  .sc-meta { display: flex; gap: 8px; align-items: center; margin-top: 4px; font-size: 12px; color: var(--dsr-muted); flex-wrap: wrap; }
175
+ .sc-workspace {
176
+ min-width: 0; margin-top: 5px; padding-right: 28px; color: var(--dsr-muted); font-size: 11px;
177
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
178
+ }
179
+ .session-group-label {
180
+ display: flex; align-items: center; gap: 6px; flex: 0 0 auto; min-width: 0; min-height: 28px;
181
+ box-sizing: border-box; margin: 8px 2px 0; padding: 6px 8px 4px; border-bottom: 1px solid var(--dsr-line);
182
+ color: var(--dsr-accent-strong); font-size: 11px; font-weight: 700;
183
+ }
184
+ .session-group-icon { flex: 0 0 auto; color: var(--dsr-accent-2); font-size: 12px; }
185
+ .session-group-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
186
+ .session-list.workspace-sorted .sc-workspace { display: none; }
187
+ .session-list.workspace-sorted .session-card { padding-top: 11px; padding-bottom: 11px; }
188
+ }
167
189
  .sc-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--dsr-line); display: inline-block; }
168
190
  .sc-dot.running { background: var(--dsr-warning); box-shadow: 0 0 8px var(--dsr-warning-line); animation: pulse 1.6s infinite; }
169
191
  .sc-dot.pending { background: var(--dsr-accent-2); box-shadow: 0 0 8px var(--dsr-accent-2-line); }
@@ -404,9 +426,9 @@ body.in-session .main { padding-bottom: 84px; }
404
426
  .job-state { font-size: 12px; color: var(--dsr-muted); margin-top: 3px; }
405
427
 
406
428
  /* ---------- 文件传输 ---------- */
407
- .fs-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
429
+ .fs-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
408
430
  .fs-path {
409
- flex: 1; min-width: 0; font-size: 12.5px; color: var(--dsr-muted);
431
+ flex: 1 1 150px; min-width: 0; font-size: 12.5px; color: var(--dsr-muted);
410
432
  background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 10px;
411
433
  padding: 8px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
412
434
  }
@@ -612,6 +634,9 @@ body.in-session .main { padding-bottom: 84px; }
612
634
  }
613
635
  .modal-title { font-size: 16px; font-weight: 700; margin-bottom: 10px; }
614
636
  .modal-body { font-size: 14px; }
637
+ .workspace-create-desc { margin: 0 0 12px; color: var(--dsr-muted); line-height: 1.55; }
638
+ .workspace-create-location { display: flex; align-items: center; gap: 8px; min-width: 0; margin-bottom: 10px; color: var(--dsr-muted); font-size: 12px; }
639
+ .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-panel); border: 1px solid var(--dsr-line); border-radius: 9px; }
615
640
  .modal-body .q-item { background: var(--dsr-panel); border: 1px solid var(--dsr-line); border-radius: 12px; padding: 11px 12px; margin-bottom: 10px; }
616
641
  .modal-body .q-text { font-weight: 600; margin-bottom: 8px; }
617
642
  .modal-body .q-option {
@@ -635,6 +660,15 @@ body.in-session .main { padding-bottom: 84px; }
635
660
  @media (max-width: 720px) {
636
661
  #btn-theme .t-label { display: none; }
637
662
  #btn-theme .theme-swatch-dot { margin-right: 0; }
663
+ .section-actions { gap: 4px; }
664
+ .section-actions .session-sort { max-width: 98px; }
665
+ .section-actions .mini-btn { max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
666
+ .topbar { padding-left: 10px; padding-right: 10px; }
667
+ .topbar-right { gap: 4px; min-width: 0; }
668
+ .topbar-right .topbar-btn.icon-btn { width: 38px; height: 38px; }
669
+ .topbar-right .topbar-btn.conn-badge { max-width: 72px; padding-left: 8px; padding-right: 8px; overflow: hidden; text-overflow: ellipsis; }
670
+ .setting-row { align-items: flex-start; flex-wrap: wrap; }
671
+ .setting-row > .setting-actions { width: 100%; justify-content: flex-end; }
638
672
  }
639
673
  .theme-panel-list { display: flex; flex-direction: column; gap: 8px; }
640
674
  .theme-option {
@@ -1,10 +1,14 @@
1
1
  {
2
- "version": "0.6.5",
2
+ "version": "0.6.6",
3
3
  "apkUrl": "dsh-remote.apk",
4
- "sha256": "132da02e3b5cb2b348f71cdc88590430fdc88abe6ee764788f658372c239e317",
5
- "releasedAt": "2026-08-19T12:12:05.411Z",
6
- "notes": "连接稳定性全面优化:25 秒心跳保活(WiFi 切换 / NAT 超时不再假活);60 秒无消息自动重连,指数退避重连(1s → 30s 上限 + 抖动);网关死连接自动清理(60 秒无数据双向销毁);断网自动检测与恢复(离线 / 重连中倒计时 / 连接失败状态可见);降级轮询恢复优化。",
4
+ "sha256": "ddb80405e8b8b3183295bf150504621a0f900cd6507025600a33ab399d21f10b",
5
+ "releasedAt": "2026-08-21T07:09:29.907Z",
6
+ "notes": "新增新建工作区,按当前文件目录创建文件夹并自动打开会话;优化工作区排序分组、路径显示和小屏布局。",
7
7
  "history": [
8
+ {
9
+ "version": "0.6.6",
10
+ "notes": "新增新建工作区,按当前文件目录创建文件夹并自动打开会话;优化工作区排序分组、路径显示和小屏布局。"
11
+ },
8
12
  {
9
13
  "version": "0.6.5",
10
14
  "notes": "连接稳定性全面优化:25 秒心跳保活(WiFi 切换 / NAT 超时不再假活);60 秒无消息自动重连,指数退避重连(1s → 30s 上限 + 抖动);网关死连接自动清理(60 秒无数据双向销毁);断网自动检测与恢复(离线 / 重连中倒计时 / 连接失败状态可见);降级轮询恢复优化。"
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.6.5"
2
+ "version": "0.6.6"
3
3
  }