dsh-remote-plugin 0.6.5 → 0.6.7

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 },
@@ -61,6 +62,8 @@ const state = {
61
62
  pollSeq: { mux: 0, host: 0 },
62
63
  fs: { path: null, initial: null, loaded: false },
63
64
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
65
+ wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
66
+ archivedIds: [],
64
67
  view: 'sessions'
65
68
  }
66
69
  const streams = {}
@@ -495,12 +498,16 @@ function hideTip() { const tip = $('ds-tip'); if (tip) tip.classList.add('hidden
495
498
 
496
499
  /* ---------------- API ---------------- */
497
500
  function apiUrl(path) { return (state.server || '') + path }
498
- async function rpc(method, payload = {}) {
499
- const res = await fetch(apiUrl('/api/' + method), {
501
+ async function rpc(method, payload = {}, timeoutMs = 45000) {
502
+ const opts = {
500
503
  method: 'POST',
501
504
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
502
505
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
503
- })
506
+ }
507
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
508
+ opts.signal = AbortSignal.timeout(timeoutMs)
509
+ }
510
+ const res = await fetch(apiUrl('/api/' + method), opts)
504
511
  if (res.status === 401) throw new Error('AUTH')
505
512
  if (!res.ok) throw new Error('HTTP ' + res.status)
506
513
  const full = await res.json()
@@ -509,11 +516,15 @@ async function rpc(method, payload = {}) {
509
516
  return full.result.value
510
517
  }
511
518
  async function respond(rpcId, value) {
512
- const res = await fetch(apiUrl('/api/respond'), {
519
+ const opts = {
513
520
  method: 'POST',
514
521
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' },
515
522
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
516
- })
523
+ }
524
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
525
+ opts.signal = AbortSignal.timeout(15000)
526
+ }
527
+ const res = await fetch(apiUrl('/api/respond'), opts)
517
528
  if (res.status === 401) throw new Error('AUTH')
518
529
  const receipt = await res.json()
519
530
  return receipt?.accepted === true
@@ -610,7 +621,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
610
621
  let best = null
611
622
  let ms = Infinity
612
623
  if (state.autoSelect[state.activeGroup] !== false) {
613
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
624
+ const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
625
+ for (const [u, latency] of measured) state.serverLatency[u] = latency
614
626
  best = candidates.filter(u => Number.isFinite(state.serverLatency[u])).sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
615
627
  chosen = best || (state.server || '')
616
628
  ms = best ? state.serverLatency[best] : Infinity
@@ -972,7 +984,12 @@ async function pollKind(kind) {
972
984
  let data
973
985
  try { data = await res.json() } catch { return }
974
986
  if (!data || !Array.isArray(data.events)) return
975
- if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
987
+ const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
988
+ if (reset) {
989
+ state.pollSeq[kind] = 0
990
+ if (kind === 'mux') renderNotifStack()
991
+ refreshSessions()
992
+ }
976
993
  for (const item of data.events) {
977
994
  if (item.seq > (state.pollSeq[kind] || 0)) {
978
995
  state.pollSeq[kind] = item.seq
@@ -1056,7 +1073,7 @@ function applyProjection(sessionId, key, value, seq) {
1056
1073
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
1057
1074
  }
1058
1075
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1059
- function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
1076
+ function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('ds.sessions')) }
1060
1077
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1061
1078
  function isGoalTerminal(goal) {
1062
1079
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1087,19 +1104,76 @@ async function refreshSessions() {
1087
1104
  state.sessions = v.items || []
1088
1105
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1089
1106
  renderSessions()
1107
+ scheduleWorkbenchRefresh()
1108
+ }
1109
+ function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1110
+ function sessionWorkspaceLabel(s) {
1111
+ const cwd = sessionCwd(s)
1112
+ return cwd || t('ds.workspaceUnknown')
1113
+ }
1114
+ function workspaceDisplayName(label) {
1115
+ const value = String(label || '').trim()
1116
+ if (!value || value === t('ds.workspaceUnknown')) return value || t('ds.workspaceUnknown')
1117
+ const clean = value.replace(/[\\/]+$/, '')
1118
+ const parts = clean.split(/[\\/]/).filter(Boolean)
1119
+ return parts[parts.length - 1] || value
1120
+ }
1121
+ function sortedSessions() {
1122
+ const items = [...state.sessions]
1123
+ if (state.sessionSort === 'workspace') {
1124
+ return items.sort((a, b) => {
1125
+ const aw = sessionCwd(a) || '\uffff'
1126
+ const bw = sessionCwd(b) || '\uffff'
1127
+ const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1128
+ return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1129
+ })
1130
+ }
1131
+ return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1090
1132
  }
1091
1133
  function renderSessions() {
1092
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1093
- const html = items.map(s => {
1094
- const title = titleOf(s)
1095
- return `<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1096
- <span class="ds-session-title">${esc(title)}</span>
1097
- <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>`
1134
+ const allItems = sortedSessions()
1135
+ const wbIds = new Set()
1136
+ if (state.wb.bound && state.wb.projects) for (const w of state.wb.projects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1137
+ const root = state.wb.bound ? state.wb.path : ''
1138
+ const visible = allItems.filter(s => !(state.wb.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1139
+ const archivedSet = new Set(state.archivedIds || [])
1140
+ const archived = visible.filter(s => archivedSet.has(s.sessionId))
1141
+ const main = visible.filter(s => !archivedSet.has(s.sessionId))
1142
+ const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
1143
+ const renderItems = (items) => {
1144
+ let lastWorkspace = null
1145
+ const rows = []
1146
+ for (const s of items) {
1147
+ const workspace = sessionWorkspaceLabel(s)
1148
+ const workspaceName = workspaceDisplayName(workspace)
1149
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1150
+ 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>`)
1151
+ lastWorkspace = workspace
1152
+ }
1153
+ const title = titleOf(s)
1154
+ rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1155
+ <span class="ds-session-title">${esc(title)}</span>
1156
+ <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1157
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1158
+ </button>`)
1159
+ }
1160
+ return rows.join('')
1161
+ }
1162
+ const divider = archived.length ? `<button class="ds-archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1163
+ const hiddenByWorkbench = allItems.length - visible.length
1164
+ const html = renderItems(main) + divider + (showArchived ? renderItems(archived) : '') || `<div class="ds-empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('ds.sessionsEmpty'))}</div>`
1100
1165
  $('session-list').innerHTML = html
1101
1166
  $('mobile-session-list').innerHTML = html
1167
+ $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1168
+ $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1169
+ const sort = $('session-sort')
1170
+ if (sort) sort.value = state.sessionSort
1171
+ document.querySelectorAll('[data-archived-toggle]').forEach(b => b.addEventListener('click', () => {
1172
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
1173
+ renderSessions()
1174
+ }))
1102
1175
  document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
1176
+ renderWorkbench()
1103
1177
  }
1104
1178
 
1105
1179
  async function openSession(id) {
@@ -1271,7 +1345,8 @@ async function goalAction(kind) {
1271
1345
  const objective = prompt(t('goal.editPrompt'), goal.objective || '')
1272
1346
  if (objective === null) return
1273
1347
  if (!objective.trim()) return toast(t('goal.cannotEmpty'), 'err')
1274
- await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
1348
+ const result = await safeRpc('goal.edit', { sessionId: state.current, ref, objective: objective.trim() }, t('goal.updateFailed'))
1349
+ if (result == null) return
1275
1350
  toast(t('goal.updated'), 'ok')
1276
1351
  refreshSessions()
1277
1352
  renderSessionCards()
@@ -1282,7 +1357,8 @@ async function goalAction(kind) {
1282
1357
  if (!method) return
1283
1358
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1284
1359
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1285
- await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1360
+ const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1361
+ if (result == null) return
1286
1362
  if (kind === 'complete') setGoalPhaseLocal('complete')
1287
1363
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1288
1364
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1292,7 +1368,8 @@ async function goalAction(kind) {
1292
1368
 
1293
1369
  async function interruptSubagent(childId) {
1294
1370
  if (!confirm(t('subagent.confirmInterrupt'))) return
1295
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1371
+ const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1372
+ if (result == null) return
1296
1373
  toast(t('subagent.interruptSubmitted'), 'ok')
1297
1374
  setTimeout(renderSessionCards, 600)
1298
1375
  }
@@ -1384,7 +1461,14 @@ function renderNotifStack() {
1384
1461
  async function approveApproval(id, allow) {
1385
1462
  const a = state.approvals.find(x => x.approvalId === id)
1386
1463
  if (!a) return
1387
- const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1464
+ let ok
1465
+ try {
1466
+ ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
1467
+ } catch (e) {
1468
+ if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
1469
+ else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
1470
+ return
1471
+ }
1388
1472
  toast(ok ? (allow ? t('ds.allowed') : t('ds.rejected')) : t('ds.stale'), ok ? 'ok' : 'err')
1389
1473
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1390
1474
  renderNotifStack()
@@ -1413,7 +1497,14 @@ async function submitQuestion() {
1413
1497
  return ans
1414
1498
  }).filter(Boolean)
1415
1499
  if (!answers.length) return toast(t('ds.questionNeedAnswer'), 'err')
1416
- const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1500
+ let ok
1501
+ try {
1502
+ ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
1503
+ } catch (e) {
1504
+ if (e.message === 'AUTH') toast(t('ds.toastAuth'), 'err')
1505
+ else toast(t('ds.pendingSubmitFailed', { msg: e.message || t('ds.feedbackNetworkError') }), 'err')
1506
+ return
1507
+ }
1417
1508
  if (ok) {
1418
1509
  toast(t('ds.questionSubmitted'), 'ok')
1419
1510
  $('modal-question').classList.add('hidden')
@@ -1439,6 +1530,48 @@ function fsParent(p) {
1439
1530
  parts.pop()
1440
1531
  return parts.length ? '/' + parts.join('/') : '/'
1441
1532
  }
1533
+ async function openWorkspaceModal() {
1534
+ if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
1535
+ if (!state.fs.path) await loadFs(null, true)
1536
+ $('workspace-parent-path').textContent = state.fs.path || '~'
1537
+ $('workspace-name').value = ''
1538
+ $('modal-workspace').classList.remove('hidden')
1539
+ setTimeout(() => $('workspace-name').focus(), 50)
1540
+ }
1541
+ function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
1542
+ async function createWorkspace() {
1543
+ if (createWorkspace.busy) return
1544
+ const name = $('workspace-name').value.trim()
1545
+ if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
1546
+ createWorkspace.busy = true
1547
+ const parent = state.fs.path || ''
1548
+ const button = $('workspace-create')
1549
+ button.disabled = true
1550
+ try {
1551
+ const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
1552
+ if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
1553
+ const data = await res.json().catch(() => ({}))
1554
+ if (!res.ok) {
1555
+ const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
1556
+ throw new Error(msg)
1557
+ }
1558
+ closeWorkspaceModal()
1559
+ await loadFs(parent || null, true)
1560
+ const v = await safeRpc('session.create', { cwd: data.path }, t('ds.toastOpFailed'))
1561
+ await refreshSessions()
1562
+ if (v?.sessionId) {
1563
+ toast(t('ds.workspaceCreated'), 'ok')
1564
+ openSession(v.sessionId)
1565
+ } else {
1566
+ toast(t('ds.workspaceCreatedNoSession'), 'ok')
1567
+ }
1568
+ } catch (e) {
1569
+ toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
1570
+ } finally {
1571
+ createWorkspace.busy = false
1572
+ button.disabled = false
1573
+ }
1574
+ }
1442
1575
  async function loadFs(dir, silent) {
1443
1576
  if (!state.token) {
1444
1577
  $('fs-path').textContent = t('ds.toastAuth')
@@ -1480,6 +1613,228 @@ function fsUp() {
1480
1613
  }
1481
1614
  }
1482
1615
 
1616
+ /* ---------------- 工作台绑定 / 项目会话 ---------------- */
1617
+ function wbPathKey(p) {
1618
+ let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
1619
+ if (value.length > 1) value = value.replace(/\/+$/, '')
1620
+ const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
1621
+ return windows ? value.toLowerCase() : value
1622
+ }
1623
+ function wbBaseName(p) {
1624
+ const value = String(p || '').replace(/[\\/]+$/, '')
1625
+ return value.split(/[\\/]/).pop() || value
1626
+ }
1627
+ function wbStrictInside(pathValue, rootValue) {
1628
+ const pathKey = wbPathKey(pathValue)
1629
+ const rootKey = wbPathKey(rootValue)
1630
+ if (!pathKey || !rootKey || pathKey === rootKey) return false
1631
+ return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
1632
+ }
1633
+ function wbJoin(root, name) {
1634
+ const raw = String(root || '')
1635
+ const separator = raw.includes('\\') ? '\\' : '/'
1636
+ return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
1637
+ }
1638
+ function wbFsParent(p) {
1639
+ if (!p) return null
1640
+ const raw = String(p)
1641
+ const separator = raw.includes('\\') ? '\\' : '/'
1642
+ const index = raw.lastIndexOf(separator)
1643
+ if (index <= 0 || /^[A-Za-z]:$/.test(raw.slice(0, index))) return null
1644
+ return raw.slice(0, index)
1645
+ }
1646
+ async function wbGateway(method, pathname, body) {
1647
+ const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' } }
1648
+ if (body !== undefined) {
1649
+ options.headers['content-type'] = 'application/json'
1650
+ options.body = JSON.stringify(body)
1651
+ }
1652
+ const res = await fetch(apiUrl(pathname), options)
1653
+ if (res.status === 401) throw new Error('AUTH')
1654
+ const data = await res.json().catch(() => ({}))
1655
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status))
1656
+ return data
1657
+ }
1658
+ async function refreshWorkbench({ silent = false } = {}) {
1659
+ if (!state.token) { renderWorkbench(); return }
1660
+ let wb = null
1661
+ try {
1662
+ wb = await wbGateway('GET', '/workbench')
1663
+ state.wb.apiMissing = false
1664
+ } catch (e) {
1665
+ if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
1666
+ if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
1667
+ if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
1668
+ }
1669
+ const wl = await safeRpc('workspace.list', {}, '')
1670
+ state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
1671
+ if (!wb) { renderWorkbench(); renderSessions(); return }
1672
+ if (!wb.bound) {
1673
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1674
+ renderWorkbench()
1675
+ renderSessions()
1676
+ return
1677
+ }
1678
+ state.wb.bound = true
1679
+ state.wb.path = wb.path || ''
1680
+ state.wb.title = wb.title || ''
1681
+ if (!wl) { state.wb.projects = []; renderWorkbench(); renderSessions(); return }
1682
+ const items = Array.isArray(wl.items) ? wl.items.slice() : []
1683
+ try {
1684
+ const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1685
+ if (listRes.ok) {
1686
+ const listData = await listRes.json().catch(() => ({}))
1687
+ const have = new Set(items.map(w => wbPathKey(w.path)))
1688
+ for (const entry of listData.entries || []) {
1689
+ if (entry.type !== 'dir') continue
1690
+ const projectPath = wbJoin(state.wb.path, entry.name)
1691
+ if (have.has(wbPathKey(projectPath))) continue
1692
+ try {
1693
+ const created = await rpc('workspace.create', { path: projectPath })
1694
+ if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
1695
+ } catch {}
1696
+ }
1697
+ }
1698
+ } catch {}
1699
+ state.wb.projects = items
1700
+ .filter(w => wbStrictInside(w.path, state.wb.path))
1701
+ .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true }))
1702
+ renderWorkbench()
1703
+ renderSessions()
1704
+ }
1705
+ function renderWorkbench() {
1706
+ const box = $('workbench-box')
1707
+ if (!box) return
1708
+ const unbound = $('wb-unbound')
1709
+ const bound = $('wb-bound')
1710
+ const hint = $('wb-api-hint')
1711
+ if (!state.wb.bound) {
1712
+ unbound.classList.remove('hidden')
1713
+ bound.classList.add('hidden')
1714
+ hint?.classList.toggle('hidden', !state.wb.apiMissing)
1715
+ return
1716
+ }
1717
+ unbound.classList.add('hidden')
1718
+ bound.classList.remove('hidden')
1719
+ $('wb-head-text').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
1720
+ $('wb-head').setAttribute('aria-expanded', state.wb.expanded ? 'true' : 'false')
1721
+ $('wb-caret').textContent = state.wb.expanded ? '▾' : '▸'
1722
+ const panel = $('wb-panel')
1723
+ panel.classList.toggle('hidden', !state.wb.expanded)
1724
+ if (!state.wb.expanded) return
1725
+ const projects = state.wb.projects || []
1726
+ let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1727
+ html += projects.length ? projects.map(w => {
1728
+ const id = String(w.workspaceId || '')
1729
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1730
+ const open = state.wb.open === id
1731
+ return `<div class="ds-wb-project ${open ? 'open' : ''}">
1732
+ <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
1733
+ <span class="ds-wb-caret" aria-hidden="true">${open ? '▾' : '▸'}</span>
1734
+ <span class="ds-wb-project-title" title="${esc(w.path)}">${esc(w.title || wbBaseName(w.path) || short(id))}</span>
1735
+ <span class="ds-wb-project-count">${sessions.length}</span>
1736
+ </button>
1737
+ <div class="ds-wb-project-body ${open ? '' : 'hidden'}">
1738
+ <button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
1739
+ ${sessions.length ? sessions.map(s => `<button type="button" class="ds-wb-session ${state.current === s.sessionId ? 'current' : ''}" data-wb-session="${esc(s.sessionId)}"><span class="ds-wb-session-dot ${s.running ? 'running' : ''}"></span><span class="ds-wb-session-title">${esc(titleOf(s))}</span></button>`).join('') : `<div class="ds-wb-session-empty">${esc(t('wb.noSessions'))}</div>`}
1740
+ </div>
1741
+ </div>`
1742
+ }).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
1743
+ html += `<button type="button" class="ds-mini-btn ds-wb-unbind-panel" data-wb-unbind-panel>${esc(t('wb.unbind'))}</button>`
1744
+ panel.innerHTML = html
1745
+ panel.querySelectorAll('[data-wb-head]').forEach(button => button.addEventListener('click', () => {
1746
+ state.wb.open = state.wb.open === button.dataset.wbHead ? null : button.dataset.wbHead
1747
+ renderWorkbench()
1748
+ }))
1749
+ panel.querySelectorAll('[data-wb-new]').forEach(button => button.addEventListener('click', async () => {
1750
+ const value = await safeRpc('session.create', { workspaceId: button.dataset.wbNew }, '')
1751
+ if (value?.sessionId) { await refreshSessions(); openSession(value.sessionId) }
1752
+ }))
1753
+ panel.querySelectorAll('[data-wb-session]').forEach(button => button.addEventListener('click', () => openSession(button.dataset.wbSession)))
1754
+ panel.querySelectorAll('[data-wb-unbind-panel]').forEach(button => button.addEventListener('click', unbindWorkbench))
1755
+ }
1756
+ const wbFs = { path: null, initial: null }
1757
+ function openWorkbenchModal() {
1758
+ $('modal-workbench').classList.remove('hidden')
1759
+ wbFs.path = null
1760
+ wbFs.initial = null
1761
+ wbFsLoad(null)
1762
+ setTimeout(() => $('wb-path-input').focus(), 50)
1763
+ }
1764
+ function closeWorkbenchModal() { $('modal-workbench').classList.add('hidden') }
1765
+ async function wbFsLoad(dir) {
1766
+ const box = $('wb-fs-list')
1767
+ const target = dir ?? wbFs.path ?? ''
1768
+ box.innerHTML = `<div class="ds-empty">${esc(t('ds.loading'))}</div>`
1769
+ $('wb-fs-path').textContent = target ? '…' + target.slice(-40) : '~'
1770
+ try {
1771
+ const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
1772
+ const data = await res.json().catch(() => ({}))
1773
+ if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
1774
+ wbFs.path = data.path
1775
+ if (!wbFs.initial) wbFs.initial = data.path
1776
+ $('wb-fs-path').textContent = data.path
1777
+ const dirs = (data.entries || []).filter(e => e.type === 'dir')
1778
+ box.innerHTML = dirs.length ? dirs.map(e => {
1779
+ const p = wbJoin(data.path, e.name)
1780
+ return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span>📁</span><span class="ds-wb-fs-name">${esc(e.name)}</span><button type="button" class="ds-btn ds-wb-select" data-wb-select="${esc(p)}">${esc(t('wb.selectDir'))}</button></div>`
1781
+ }).join('') : `<div class="ds-empty">${esc(t('wb.empty'))}</div>`
1782
+ box.querySelectorAll('[data-wb-dir]').forEach(row => row.addEventListener('click', e => { if (!e.target.closest('[data-wb-select]')) wbFsLoad(row.dataset.wbDir) }))
1783
+ box.querySelectorAll('[data-wb-select]').forEach(button => button.addEventListener('click', () => bindWorkbench(button.dataset.wbSelect)))
1784
+ } catch (e) {
1785
+ $('wb-fs-path').textContent = target || '~'
1786
+ box.innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
1787
+ }
1788
+ }
1789
+ function wbFsUp() {
1790
+ if (wbFs.path && wbFs.initial && wbFs.path !== wbFs.initial) {
1791
+ const parent = wbFsParent(wbFs.path)
1792
+ if (parent) wbFsLoad(parent)
1793
+ }
1794
+ }
1795
+ async function bindWorkbench(rawPath) {
1796
+ const value = String(rawPath || '').trim()
1797
+ if (!value) return toast(t('wb.pathEmpty'), 'err')
1798
+ try {
1799
+ const wb = await wbGateway('POST', '/workbench/bind', { path: value })
1800
+ state.wb = { bound: true, path: wb.path || value, title: wb.title || '', expanded: true, projects: null, open: null, apiMissing: false }
1801
+ const paths = [state.wb.path]
1802
+ try {
1803
+ const res = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1804
+ const data = await res.json().catch(() => ({}))
1805
+ for (const e of data.entries || []) if (e.type === 'dir') paths.push(wbJoin(state.wb.path, e.name))
1806
+ } catch {}
1807
+ for (const projectPath of paths) {
1808
+ try { await rpc('workspace.create', { path: projectPath }) } catch {}
1809
+ }
1810
+ closeWorkbenchModal()
1811
+ await refreshWorkbench({ silent: true })
1812
+ await refreshSessions()
1813
+ toast(t('wb.boundOk', { path: state.wb.path }), 'ok')
1814
+ } catch (e) {
1815
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1816
+ toast(t('wb.bindFailed', { msg: e.message }), 'err')
1817
+ }
1818
+ }
1819
+ async function unbindWorkbench() {
1820
+ if (!confirm(t('wb.unbindConfirm'))) return
1821
+ try {
1822
+ await wbGateway('POST', '/workbench/unbind')
1823
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1824
+ renderWorkbench()
1825
+ renderSessions()
1826
+ toast(t('wb.unboundOk'), 'ok')
1827
+ } catch (e) {
1828
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1829
+ toast(t('wb.unbindFailed', { msg: e.message }), 'err')
1830
+ }
1831
+ }
1832
+ let wbRefreshTimer = null
1833
+ function scheduleWorkbenchRefresh() {
1834
+ clearTimeout(wbRefreshTimer)
1835
+ wbRefreshTimer = setTimeout(() => refreshWorkbench({ silent: true }), 400)
1836
+ }
1837
+
1483
1838
  /* ---------------- 统计 ---------------- */
1484
1839
  function bucketTokens(b) { return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0) }
1485
1840
  let statsDrawerOpened = false
@@ -1615,18 +1970,52 @@ function updateConn() {
1615
1970
  /* ---------------- 初始化 ---------------- */
1616
1971
  function bindUi() {
1617
1972
  $('btn-new-session').addEventListener('click', async () => {
1618
- const v = await safeRpc('session.create', {}, '')
1973
+ let payload = {}
1974
+ // 与移动端保持一致:新会话继承 DSH 当前工作目录;查询失败时兼容回退。
1975
+ try {
1976
+ const host = await rpc('host.describe', {}, 5000)
1977
+ const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
1978
+ if (cwd) payload = { cwd }
1979
+ } catch {}
1980
+ const v = await safeRpc('session.create', payload, '')
1619
1981
  if (v?.sessionId) { await refreshSessions(); openSession(v.sessionId) }
1620
1982
  })
1983
+ $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
1984
+ $('session-sort')?.addEventListener('change', (e) => {
1985
+ state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
1986
+ LS.set('sessionSort', state.sessionSort)
1987
+ renderSessions()
1988
+ })
1621
1989
  $('btn-mobile-nav').addEventListener('click', () => {
1622
1990
  const list = $('mobile-session-list')
1623
1991
  list.style.display = list.style.display === 'none' ? 'flex' : 'none'
1624
1992
  })
1625
1993
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
1626
1994
  $('session-list').addEventListener('click', (e) => {
1995
+ if (e.target.closest('[data-archived-toggle]')) {
1996
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
1997
+ renderSessions()
1998
+ return
1999
+ }
1627
2000
  const item = e.target.closest('[data-id]')
1628
2001
  if (item) openSession(item.dataset.id)
1629
2002
  })
2003
+ $('btn-wb-bind').addEventListener('click', openWorkbenchModal)
2004
+ $('btn-wb-bind-manual').addEventListener('click', () => bindWorkbench($('wb-path-input').value))
2005
+ $('wb-path-input').addEventListener('keydown', e => {
2006
+ if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); bindWorkbench($('wb-path-input').value) }
2007
+ })
2008
+ $('wb-fs-up').addEventListener('click', wbFsUp)
2009
+ $('wb-fs-home').addEventListener('click', () => wbFsLoad(wbFs.initial || null))
2010
+ $('btn-wb-modal-close').addEventListener('click', closeWorkbenchModal)
2011
+ $('modal-workbench').addEventListener('click', e => { if (e.target === $('modal-workbench')) closeWorkbenchModal() })
2012
+ $('wb-head').addEventListener('click', () => {
2013
+ state.wb.expanded = !state.wb.expanded
2014
+ if (state.wb.expanded && !state.wb.projects) refreshWorkbench({ silent: false })
2015
+ else renderWorkbench()
2016
+ })
2017
+ $('btn-wb-path').addEventListener('click', () => { if (state.wb.path) toast(t('wb.boundPath', { path: state.wb.path }), 'ok') })
2018
+ $('btn-wb-unbind').addEventListener('click', unbindWorkbench)
1630
2019
  $('btn-send').addEventListener('click', sendMessage)
1631
2020
  $('composer').addEventListener('keydown', (e) => {
1632
2021
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
@@ -1676,6 +2065,10 @@ function bindUi() {
1676
2065
  $('notes-prev').addEventListener('click', () => scrollNotes(-1))
1677
2066
  $('notes-next').addEventListener('click', () => scrollNotes(1))
1678
2067
  $('notes-pages').addEventListener('scroll', updateNotesPage)
2068
+ $('workspace-cancel').addEventListener('click', closeWorkspaceModal)
2069
+ $('workspace-create').addEventListener('click', createWorkspace)
2070
+ $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
2071
+ $('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
1679
2072
  $('modal-notes').addEventListener('click', (e) => { if (e.target === $('modal-notes')) closeNotesModal() })
1680
2073
  // 反馈
1681
2074
  $('btn-feedback').addEventListener('click', (e) => { e.stopPropagation(); toggleFeedbackMenu() })
@@ -1738,6 +2131,7 @@ function bindUi() {
1738
2131
  renderServers(); renderSessions(); updateConn(); themeApply()
1739
2132
  })
1740
2133
  $('fs-up').addEventListener('click', fsUp)
2134
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
1741
2135
  $('fs-refresh').addEventListener('click', () => loadFs(state.fs.path || null))
1742
2136
  $('btn-question-submit').addEventListener('click', submitQuestion)
1743
2137
  $('btn-question-cancel').addEventListener('click', () => { $('modal-question').classList.add('hidden'); toast(t('ds.ignored'), 'ok') })
@@ -1755,12 +2149,14 @@ async function start() {
1755
2149
  }
1756
2150
  $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1757
2151
  bindUi()
2152
+ renderWorkbench()
1758
2153
  updateConn()
1759
2154
  checkNotesOnStart()
1760
2155
  if (state.token) {
1761
2156
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1762
2157
  openStreams()
1763
2158
  refreshSessions()
2159
+ refreshWorkbench({ silent: true })
1764
2160
  }
1765
2161
  }
1766
2162
 
@@ -10,6 +10,38 @@
10
10
  if (saved === 'zh' || saved === 'en') return saved
11
11
  return (navigator.language || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en'
12
12
  }
13
+ const BUILTIN = {
14
+ zh: {
15
+ 'wb.unbound': 'DSH Remote(未绑定)', 'wb.bind': '绑定工作台', 'wb.bound': 'DSH Remote(绑定 {title})',
16
+ 'wb.viewPath': '已绑定 workspace', 'wb.unbind': '解绑', 'wb.unbindConfirm': '确定解绑当前工作台?解绑后这些会话将回到扁平会话列表。',
17
+ 'wb.modalTitle': '绑定工作台', 'wb.modalDesc': '选择工作台根目录,其下每个子文件夹会自动成为项目工作区。',
18
+ 'wb.up': '上级', 'wb.home': '根目录', 'wb.selectDir': '选择此目录', 'wb.pathPlaceholder': '或手动输入绝对路径…',
19
+ 'wb.pathEmpty': '请输入要绑定的路径', 'wb.empty': '此目录没有子文件夹', 'wb.boundOk': '工作台已绑定:{path}',
20
+ 'wb.bindFailed': '绑定失败:{msg}', 'wb.unboundOk': '已解绑工作台', 'wb.unbindFailed': '解绑失败:{msg}',
21
+ 'wb.boundPath': '绑定路径:{path}', 'wb.projects': '项目', 'wb.noProjects': '暂无项目(根目录下新建文件夹后会自动出现)',
22
+ 'wb.newSession': '新会话', 'wb.noSessions': '暂无会话', 'wb.flatHidden': '工作台会话已收起({n} 个在工作台面板)',
23
+ 'wb.apiMissing': '工作台接口不可用(需更新网关)', 'wb.loadFailed': '工作台加载失败:{msg}',
24
+ 'wb.archivedHidden': '------隐藏已归档会话------', 'wb.archivedShown': '------显示已归档会话------'
25
+ },
26
+ en: {
27
+ 'wb.unbound': 'DSH Remote (unbound)', 'wb.bind': 'Bind workbench', 'wb.bound': 'DSH Remote (bound {title})',
28
+ 'wb.viewPath': 'Bound workspace', 'wb.unbind': 'Unbind', 'wb.unbindConfirm': 'Unbind the current workbench? These sessions will return to the flat list.',
29
+ 'wb.modalTitle': 'Bind workbench', 'wb.modalDesc': 'Choose the workbench root; each subfolder becomes a project workspace automatically.',
30
+ 'wb.up': 'Up', 'wb.home': 'Root', 'wb.selectDir': 'Select this folder', 'wb.pathPlaceholder': 'Or type an absolute path…',
31
+ 'wb.pathEmpty': 'Enter a path to bind', 'wb.empty': 'No subfolders in this folder', 'wb.boundOk': 'Workbench bound: {path}',
32
+ 'wb.bindFailed': 'Bind failed: {msg}', 'wb.unboundOk': 'Workbench unbound', 'wb.unbindFailed': 'Unbind failed: {msg}',
33
+ 'wb.boundPath': 'Bound path: {path}', 'wb.projects': 'Projects', 'wb.noProjects': 'No projects yet',
34
+ 'wb.newSession': 'New session', 'wb.noSessions': 'No sessions', 'wb.flatHidden': 'Workbench sessions folded ({n} in the workbench panel)',
35
+ 'wb.apiMissing': 'Workbench API unavailable (update gateway)', 'wb.loadFailed': 'Workbench load failed: {msg}',
36
+ 'wb.archivedHidden': '------ Hide archived ------', 'wb.archivedShown': '------ Show archived ------'
37
+ }
38
+ }
39
+ function mergeDicts(strings) {
40
+ return {
41
+ zh: Object.assign({}, BUILTIN.zh, strings?.zh || {}),
42
+ en: Object.assign({}, BUILTIN.en, strings?.en || {})
43
+ }
44
+ }
13
45
  let dict = null
14
46
  let lang = detect()
15
47
 
@@ -46,7 +78,7 @@
46
78
  }
47
79
 
48
80
  window.I18N = {
49
- init(strings) { dict = strings || dict; return apply(document) },
81
+ init(strings) { dict = mergeDicts(strings || dict || {}); return apply(document) },
50
82
  t, setLang, apply,
51
83
  get lang() { return lang }
52
84
  }