dsh-remote-plugin 0.6.6 → 0.6.8

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.
@@ -52,6 +52,7 @@ const state = {
52
52
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
53
53
  byId: new Map(),
54
54
  current: null,
55
+ hostInfo: null,
55
56
  history: { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity },
56
57
  approvals: [],
57
58
  questions: [],
@@ -62,6 +63,8 @@ const state = {
62
63
  pollSeq: { mux: 0, host: 0 },
63
64
  fs: { path: null, initial: null, loaded: false },
64
65
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
66
+ wb: { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false },
67
+ archivedIds: [],
65
68
  view: 'sessions'
66
69
  }
67
70
  const streams = {}
@@ -1053,7 +1056,7 @@ function onHostFrame(full) {
1053
1056
  if (['host/session-added', 'host/session-removed', 'host/workspace-changed', 'host/workspace-removed', 'host/workspace-order-changed', 'host/archived-sessions-changed'].includes(f.type)) refreshSessions()
1054
1057
  if (f.type === 'host/session-status') {
1055
1058
  const s = state.byId.get(f.sessionId)
1056
- if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions() }
1059
+ if (s) { s.running = f.running; if (state.current === f.sessionId) renderSessions(); renderOverviewDesktop() }
1057
1060
  }
1058
1061
  }
1059
1062
  function applyProjection(sessionId, key, value, seq) {
@@ -1098,10 +1101,12 @@ function onSessionEvent(sessionId, event) {
1098
1101
  /* ---------------- 会话 ---------------- */
1099
1102
  async function refreshSessions() {
1100
1103
  const v = await safeRpc('session.list', {}, '')
1101
- if (!v) { renderSessions(); return }
1104
+ if (!v) { renderSessions(); renderOverviewDesktop(); return }
1102
1105
  state.sessions = v.items || []
1103
1106
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1104
1107
  renderSessions()
1108
+ scheduleWorkbenchRefresh()
1109
+ renderOverviewDesktop()
1105
1110
  }
1106
1111
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1107
1112
  function sessionWorkspaceLabel(s) {
@@ -1128,31 +1133,53 @@ function sortedSessions() {
1128
1133
  return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1129
1134
  }
1130
1135
  function renderSessions() {
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
1136
+ const allItems = sortedSessions()
1137
+ const wbIds = new Set()
1138
+ if (state.wb.bound && state.wb.projects) for (const w of state.wb.projects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1139
+ const root = state.wb.bound ? state.wb.path : ''
1140
+ const archivedSet = new Set(state.archivedIds || [])
1141
+ const visible = allItems.filter(s => {
1142
+ if (!state.wb.bound) return true
1143
+ if (archivedSet.has(s.sessionId)) return true
1144
+ return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1145
+ })
1146
+ const archived = visible.filter(s => archivedSet.has(s.sessionId))
1147
+ const main = visible.filter(s => !archivedSet.has(s.sessionId))
1148
+ const showArchived = LS.get('dsShowArchivedV1', '0') === '1'
1149
+ const renderItems = (items) => {
1150
+ let lastWorkspace = null
1151
+ const rows = []
1152
+ for (const s of items) {
1153
+ const workspace = sessionWorkspaceLabel(s)
1154
+ const workspaceName = workspaceDisplayName(workspace)
1155
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1156
+ 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>`)
1157
+ lastWorkspace = workspace
1158
+ }
1159
+ const title = titleOf(s)
1160
+ rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1161
+ <span class="ds-session-title">${esc(title)}</span>
1162
+ <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1163
+ <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1164
+ </button>`)
1140
1165
  }
1141
- const title = titleOf(s)
1142
- rows.push(`<button class="ds-session-item ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1143
- <span class="ds-session-title">${esc(title)}</span>
1144
- <span class="ds-session-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</span>
1145
- <span class="ds-session-meta"><span class="ds-session-dot ${s.running ? 'running' : ''}"></span>${fmtTime(s.updatedAt)}</span>
1146
- </button>`)
1147
- }
1148
- const html = rows.join('') || `<div class="ds-empty">${t('ds.sessionsEmpty')}</div>`
1166
+ return rows.join('')
1167
+ }
1168
+ const divider = archived.length ? `<button class="ds-archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1169
+ const hiddenByWorkbench = allItems.length - visible.length
1170
+ const html = renderItems(main) + divider + (showArchived ? renderItems(archived) : '') || `<div class="ds-empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('ds.sessionsEmpty'))}</div>`
1149
1171
  $('session-list').innerHTML = html
1150
1172
  $('mobile-session-list').innerHTML = html
1151
1173
  $('session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1152
1174
  $('mobile-session-list').classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1153
1175
  const sort = $('session-sort')
1154
1176
  if (sort) sort.value = state.sessionSort
1177
+ document.querySelectorAll('[data-archived-toggle]').forEach(b => b.addEventListener('click', () => {
1178
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
1179
+ renderSessions()
1180
+ }))
1155
1181
  document.querySelectorAll('[data-id]').forEach(b => b.addEventListener('click', () => openSession(b.dataset.id)))
1182
+ renderWorkbench()
1156
1183
  }
1157
1184
 
1158
1185
  async function openSession(id) {
@@ -1236,7 +1263,7 @@ function eventHtml(entry) {
1236
1263
  const sysText = type === 'user/message' ? systemReminderText(blocks) : ''
1237
1264
  if (sysText) {
1238
1265
  const shown = sysText.length > 400 ? sysText.slice(0, 400) + '…' : sysText
1239
- return `<details class="event ds-tool"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1266
+ return `<details class="event ds-tool ds-event-detail"><summary>${esc(t('ds.eventSystemReminder'))}</summary><pre>${esc(shown)}</pre></details>`
1240
1267
  }
1241
1268
  const text = blocks.map(blockHtml).join('')
1242
1269
  return `<div class="ds-msg ${esc(role)}"><div class="role">${esc(role === 'user' ? t('ds.role.me') : t('ds.role.dsh'))}</div>${text || '<span style="opacity:.6">…</span>'}</div>`
@@ -1436,6 +1463,7 @@ function renderNotifStack() {
1436
1463
  stack.querySelectorAll('.ds-notif-card').forEach(card => card.addEventListener('keydown', (e) => {
1437
1464
  if (e.key === 'Escape') toast(t('ds.ignored'), 'ok')
1438
1465
  }))
1466
+ renderOverviewDesktop()
1439
1467
  }
1440
1468
  async function approveApproval(id, allow) {
1441
1469
  const a = state.approvals.find(x => x.approvalId === id)
@@ -1573,7 +1601,7 @@ async function loadFs(dir, silent) {
1573
1601
  $('fs-path').textContent = data.path
1574
1602
  $('fs-list').innerHTML = (data.entries || []).map(e => `
1575
1603
  <div class="ds-fs-row" data-fs-path="${esc(e.path)}" data-fs-dir="${e.type === 'dir' ? '1' : '0'}">
1576
- <span>${e.type === 'dir' ? '📁' : '📄'}</span>
1604
+ <span class="ds-fs-type">${desktopFsIconSvg(e.type === 'dir')}</span>
1577
1605
  <span class="ds-fs-name">${esc(e.name)}</span>
1578
1606
  <span class="ds-fs-size">${e.type === 'dir' ? '' : fmtSize(e.size)}</span>
1579
1607
  </div>`).join('') || `<div class="ds-empty">${t('ds.fsEmpty')}</div>`
@@ -1586,12 +1614,247 @@ async function loadFs(dir, silent) {
1586
1614
  $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
1587
1615
  }
1588
1616
  }
1617
+
1618
+ function desktopFsIconSvg(isDir) {
1619
+ return isDir
1620
+ ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 6.5h6l2 2H20a1 1 0 0 1 1 1v8.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5a1 1 0 0 1 .5-1Z"/></svg>'
1621
+ : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5h8l4 4V20a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V4.5a1 1 0 0 1 1-1Z"/><path d="M14 3.5v4h4M8 13h8M8 16h6"/></svg>'
1622
+ }
1589
1623
  function fsUp() {
1590
1624
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) {
1591
1625
  loadFs(fsParent(state.fs.path))
1592
1626
  }
1593
1627
  }
1594
1628
 
1629
+ /* ---------------- 工作台绑定 / 项目会话 ---------------- */
1630
+ function wbPathKey(p) {
1631
+ let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
1632
+ if (value.length > 1) value = value.replace(/\/+$/, '')
1633
+ const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
1634
+ return windows ? value.toLowerCase() : value
1635
+ }
1636
+ function wbBaseName(p) {
1637
+ const value = String(p || '').replace(/[\\/]+$/, '')
1638
+ return value.split(/[\\/]/).pop() || value
1639
+ }
1640
+ function wbStrictInside(pathValue, rootValue) {
1641
+ const pathKey = wbPathKey(pathValue)
1642
+ const rootKey = wbPathKey(rootValue)
1643
+ if (!pathKey || !rootKey || pathKey === rootKey) return false
1644
+ return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
1645
+ }
1646
+ function wbJoin(root, name) {
1647
+ const raw = String(root || '')
1648
+ const separator = raw.includes('\\') ? '\\' : '/'
1649
+ return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
1650
+ }
1651
+ function wbFsParent(p) {
1652
+ if (!p) return null
1653
+ const raw = String(p)
1654
+ const separator = raw.includes('\\') ? '\\' : '/'
1655
+ const index = raw.lastIndexOf(separator)
1656
+ if (index <= 0 || /^[A-Za-z]:$/.test(raw.slice(0, index))) return null
1657
+ return raw.slice(0, index)
1658
+ }
1659
+ async function wbGateway(method, pathname, body) {
1660
+ const options = { method, headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': 'web' } }
1661
+ if (body !== undefined) {
1662
+ options.headers['content-type'] = 'application/json'
1663
+ options.body = JSON.stringify(body)
1664
+ }
1665
+ const res = await fetch(apiUrl(pathname), options)
1666
+ if (res.status === 401) throw new Error('AUTH')
1667
+ const data = await res.json().catch(() => ({}))
1668
+ if (!res.ok) throw new Error(data.error || ('HTTP ' + res.status))
1669
+ return data
1670
+ }
1671
+ async function refreshWorkbench({ silent = false } = {}) {
1672
+ if (!state.token) { renderWorkbench(); return }
1673
+ let wb = null
1674
+ try {
1675
+ wb = await wbGateway('GET', '/workbench')
1676
+ state.wb.apiMissing = false
1677
+ } catch (e) {
1678
+ if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
1679
+ if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
1680
+ if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
1681
+ }
1682
+ const wl = await safeRpc('workspace.list', {}, '')
1683
+ state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
1684
+ if (!wb) { renderWorkbench(); renderSessions(); return }
1685
+ if (!wb.bound) {
1686
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1687
+ renderWorkbench()
1688
+ renderSessions()
1689
+ return
1690
+ }
1691
+ state.wb.bound = true
1692
+ state.wb.path = wb.path || ''
1693
+ state.wb.title = wb.title || ''
1694
+ if (!wl) { state.wb.projects = []; renderWorkbench(); renderSessions(); return }
1695
+ const items = Array.isArray(wl.items) ? wl.items.slice() : []
1696
+ try {
1697
+ const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1698
+ if (listRes.ok) {
1699
+ const listData = await listRes.json().catch(() => ({}))
1700
+ if (Array.isArray(listData.entries)) {
1701
+ const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1702
+ for (let i = items.length - 1; i >= 0; i--) {
1703
+ if (!diskDirs.has(wbPathKey(items[i].path))) items.splice(i, 1)
1704
+ }
1705
+ const have = new Set(items.map(w => wbPathKey(w.path)))
1706
+ for (const entry of listData.entries) {
1707
+ if (entry.type !== 'dir') continue
1708
+ const projectPath = wbJoin(state.wb.path, entry.name)
1709
+ if (have.has(wbPathKey(projectPath))) continue
1710
+ try {
1711
+ const created = await rpc('workspace.create', { path: projectPath })
1712
+ if (created?.workspace) { items.push(created.workspace); have.add(wbPathKey(projectPath)) }
1713
+ } catch {}
1714
+ }
1715
+ }
1716
+ }
1717
+ } catch {}
1718
+ state.wb.projects = items
1719
+ .filter(w => wbStrictInside(w.path, state.wb.path))
1720
+ .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true }))
1721
+ renderWorkbench()
1722
+ renderSessions()
1723
+ }
1724
+ function renderWorkbench() {
1725
+ const box = $('workbench-box')
1726
+ if (!box) return
1727
+ const unbound = $('wb-unbound')
1728
+ const bound = $('wb-bound')
1729
+ const hint = $('wb-api-hint')
1730
+ if (!state.wb.bound) {
1731
+ unbound.classList.remove('hidden')
1732
+ bound.classList.add('hidden')
1733
+ hint?.classList.toggle('hidden', !state.wb.apiMissing)
1734
+ return
1735
+ }
1736
+ unbound.classList.add('hidden')
1737
+ bound.classList.remove('hidden')
1738
+ $('wb-head-text').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
1739
+ $('wb-head').setAttribute('aria-expanded', state.wb.expanded ? 'true' : 'false')
1740
+ $('wb-caret').textContent = state.wb.expanded ? '▾' : '▸'
1741
+ const panel = $('wb-panel')
1742
+ panel.classList.toggle('hidden', !state.wb.expanded)
1743
+ if (!state.wb.expanded) return
1744
+ const projects = state.wb.projects || []
1745
+ const archivedSet = new Set(state.archivedIds || [])
1746
+ let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
1747
+ html += projects.length ? projects.map(w => {
1748
+ const id = String(w.workspaceId || '')
1749
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1750
+ const open = state.wb.open === id
1751
+ return `<div class="ds-wb-project ${open ? 'open' : ''}">
1752
+ <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
1753
+ <span class="ds-wb-caret" aria-hidden="true">${open ? '▾' : '▸'}</span>
1754
+ <span class="ds-wb-project-title" title="${esc(w.path)}">${esc(w.title || wbBaseName(w.path) || short(id))}</span>
1755
+ <span class="ds-wb-project-count">${sessions.length}</span>
1756
+ </button>
1757
+ <div class="ds-wb-project-body ${open ? '' : 'hidden'}">
1758
+ <button type="button" class="ds-mini-btn ds-wb-new-session" data-wb-new="${esc(id)}">+ ${esc(t('wb.newSession'))}</button>
1759
+ ${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>`}
1760
+ </div>
1761
+ </div>`
1762
+ }).join('') : `<div class="ds-wb-empty">${esc(t('wb.noProjects'))}</div>`
1763
+ html += `<button type="button" class="ds-mini-btn ds-wb-unbind-panel" data-wb-unbind-panel>${esc(t('wb.unbind'))}</button>`
1764
+ panel.innerHTML = html
1765
+ panel.querySelectorAll('[data-wb-head]').forEach(button => button.addEventListener('click', () => {
1766
+ state.wb.open = state.wb.open === button.dataset.wbHead ? null : button.dataset.wbHead
1767
+ renderWorkbench()
1768
+ }))
1769
+ panel.querySelectorAll('[data-wb-new]').forEach(button => button.addEventListener('click', async () => {
1770
+ const value = await safeRpc('session.create', { workspaceId: button.dataset.wbNew }, '')
1771
+ if (value?.sessionId) { await refreshSessions(); openSession(value.sessionId) }
1772
+ }))
1773
+ panel.querySelectorAll('[data-wb-session]').forEach(button => button.addEventListener('click', () => openSession(button.dataset.wbSession)))
1774
+ panel.querySelectorAll('[data-wb-unbind-panel]').forEach(button => button.addEventListener('click', unbindWorkbench))
1775
+ }
1776
+ const wbFs = { path: null, initial: null }
1777
+ function openWorkbenchModal() {
1778
+ $('modal-workbench').classList.remove('hidden')
1779
+ wbFs.path = null
1780
+ wbFs.initial = null
1781
+ wbFsLoad(null)
1782
+ setTimeout(() => $('wb-path-input').focus(), 50)
1783
+ }
1784
+ function closeWorkbenchModal() { $('modal-workbench').classList.add('hidden') }
1785
+ async function wbFsLoad(dir) {
1786
+ const box = $('wb-fs-list')
1787
+ const target = dir ?? wbFs.path ?? ''
1788
+ box.innerHTML = `<div class="ds-empty">${esc(t('ds.loading'))}</div>`
1789
+ $('wb-fs-path').textContent = target ? '…' + target.slice(-40) : '~'
1790
+ try {
1791
+ const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
1792
+ const data = await res.json().catch(() => ({}))
1793
+ if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
1794
+ wbFs.path = data.path
1795
+ if (!wbFs.initial) wbFs.initial = data.path
1796
+ $('wb-fs-path').textContent = data.path
1797
+ const dirs = (data.entries || []).filter(e => e.type === 'dir')
1798
+ box.innerHTML = dirs.length ? dirs.map(e => {
1799
+ const p = wbJoin(data.path, e.name)
1800
+ return `<div class="ds-wb-fs-row" data-wb-dir="${esc(p)}"><span class="ds-fs-type">${desktopFsIconSvg(true)}</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>`
1801
+ }).join('') : `<div class="ds-empty">${esc(t('wb.empty'))}</div>`
1802
+ box.querySelectorAll('[data-wb-dir]').forEach(row => row.addEventListener('click', e => { if (!e.target.closest('[data-wb-select]')) wbFsLoad(row.dataset.wbDir) }))
1803
+ box.querySelectorAll('[data-wb-select]').forEach(button => button.addEventListener('click', () => bindWorkbench(button.dataset.wbSelect)))
1804
+ } catch (e) {
1805
+ $('wb-fs-path').textContent = target || '~'
1806
+ box.innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
1807
+ }
1808
+ }
1809
+ function wbFsUp() {
1810
+ if (wbFs.path && wbFs.initial && wbFs.path !== wbFs.initial) {
1811
+ const parent = wbFsParent(wbFs.path)
1812
+ if (parent) wbFsLoad(parent)
1813
+ }
1814
+ }
1815
+ async function bindWorkbench(rawPath) {
1816
+ const value = String(rawPath || '').trim()
1817
+ if (!value) return toast(t('wb.pathEmpty'), 'err')
1818
+ try {
1819
+ const wb = await wbGateway('POST', '/workbench/bind', { path: value })
1820
+ state.wb = { bound: true, path: wb.path || value, title: wb.title || '', expanded: true, projects: null, open: null, apiMissing: false }
1821
+ const paths = [state.wb.path]
1822
+ try {
1823
+ const res = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
1824
+ const data = await res.json().catch(() => ({}))
1825
+ for (const e of data.entries || []) if (e.type === 'dir') paths.push(wbJoin(state.wb.path, e.name))
1826
+ } catch {}
1827
+ for (const projectPath of paths) {
1828
+ try { await rpc('workspace.create', { path: projectPath }) } catch {}
1829
+ }
1830
+ closeWorkbenchModal()
1831
+ await refreshWorkbench({ silent: true })
1832
+ await refreshSessions()
1833
+ toast(t('wb.boundOk', { path: state.wb.path }), 'ok')
1834
+ } catch (e) {
1835
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1836
+ toast(t('wb.bindFailed', { msg: e.message }), 'err')
1837
+ }
1838
+ }
1839
+ async function unbindWorkbench() {
1840
+ if (!confirm(t('wb.unbindConfirm'))) return
1841
+ try {
1842
+ await wbGateway('POST', '/workbench/unbind')
1843
+ state.wb = { bound: false, path: '', title: '', expanded: false, projects: null, open: null, apiMissing: false }
1844
+ renderWorkbench()
1845
+ renderSessions()
1846
+ toast(t('wb.unboundOk'), 'ok')
1847
+ } catch (e) {
1848
+ if (e.message === 'AUTH') return toast(t('ds.toastAuth'), 'err')
1849
+ toast(t('wb.unbindFailed', { msg: e.message }), 'err')
1850
+ }
1851
+ }
1852
+ let wbRefreshTimer = null
1853
+ function scheduleWorkbenchRefresh() {
1854
+ clearTimeout(wbRefreshTimer)
1855
+ wbRefreshTimer = setTimeout(() => refreshWorkbench({ silent: true }), 400)
1856
+ }
1857
+
1595
1858
  /* ---------------- 统计 ---------------- */
1596
1859
  function bucketTokens(b) { return (b.input || 0) + (b.cacheRead || 0) + (b.cacheWrite || 0) + (b.output || 0) }
1597
1860
  let statsDrawerOpened = false
@@ -1656,13 +1919,98 @@ function renderStats(days) {
1656
1919
  }
1657
1920
 
1658
1921
  /* ---------------- 视图与连接状态 ---------------- */
1922
+ function renderOverviewDesktop() {
1923
+ const ring = $('ds-overview-pulse-ring')
1924
+ if (!ring) return
1925
+ const checks = {
1926
+ gateway: !!state.token && !!state.server,
1927
+ dsh: !!state.hostInfo,
1928
+ mux: !!state.streamsOk?.mux,
1929
+ host: !!state.streamsOk?.host
1930
+ }
1931
+ const online = Object.values(checks).filter(Boolean).length
1932
+ const status = online === 4 ? 'Nominal' : online > 0 ? 'Degraded' : 'Offline'
1933
+ const pulseCard = document.querySelector('.ds-overview-pulse-card')
1934
+ if (pulseCard) {
1935
+ pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
1936
+ pulseCard.classList.add('status-' + status.toLowerCase())
1937
+ }
1938
+ ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
1939
+ $('ds-overview-health').textContent = online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore')
1940
+ $('ds-overview-health-caption').textContent = online === 4 ? t('ds.allLinked') : online ? t('ds.components', { n: online }) : t('ds.offlineShort')
1941
+ $('ds-overview-status').textContent = t(`ds.system${status}`)
1942
+ $('ds-overview-status-desc').textContent = t('ds.components', { n: online })
1943
+ for (const [name, ok] of Object.entries(checks)) {
1944
+ const item = document.querySelector(`[data-ds-overview-link="${name}"]`)
1945
+ if (!item) continue
1946
+ item.classList.toggle('ok', ok)
1947
+ item.classList.toggle('off', !ok)
1948
+ const value = item.querySelector('b')
1949
+ if (value) value.textContent = ok ? t('ds.online') : t('ds.offlineShort')
1950
+ }
1951
+
1952
+ const pending = [
1953
+ ...state.approvals.map(a => ({ kind: 'approval', item: a })),
1954
+ ...state.questions.map(q => ({ kind: 'question', item: q }))
1955
+ ]
1956
+ $('ds-overview-attention-count').textContent = pending.length ? t('ds.pendingCount', { n: pending.length }) : '—'
1957
+ $('ds-overview-attention-list').innerHTML = pending.length ? pending.slice(0, 4).map(({ kind, item }) => {
1958
+ const title = titleOf(state.byId.get(item.sessionId))
1959
+ if (kind === 'approval') return `<div class="ds-overview-attention-item" data-ds-overview-approval="${esc(item.approvalId)}">
1960
+ <span class="ds-overview-mark">⌁</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.toolName || t('ds.toolDefault'))}</span><span class="ds-overview-item-desc">${esc(item.reason || t('ds.approvalReason', { reason: '' }))} · ${esc(title)}</span></span>
1961
+ <span class="ds-overview-actions"><button class="ds-btn allow" data-ds-overview-approve="1">${t('ds.allow')}</button><button class="ds-btn reject" data-ds-overview-approve="0">${t('ds.reject')}</button></span>
1962
+ </div>`
1963
+ return `<button type="button" class="ds-overview-attention-item question" data-ds-overview-question="${esc(item.rpcId)}">
1964
+ <span class="ds-overview-mark">?</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(item.questions?.[0]?.question || t('ds.questionNotify'))}</span><span class="ds-overview-item-desc">${esc(title)}</span></span><span class="ds-overview-arrow">›</span>
1965
+ </button>`
1966
+ }).join('') : `<div class="ds-overview-empty">${t('ds.nothingPending')}</div>`
1967
+ $('ds-overview-attention-list').querySelectorAll('[data-ds-overview-approve]').forEach(btn => btn.addEventListener('click', () => approveApproval(btn.closest('[data-ds-overview-approval]')?.dataset.dsOverviewApproval || '', btn.dataset.dsOverviewApprove === '1')))
1968
+ $('ds-overview-attention-list').querySelectorAll('[data-ds-overview-question]').forEach(btn => btn.addEventListener('click', () => openQuestionModal(state.questions.find(q => q.rpcId === btn.dataset.dsOverviewQuestion))))
1969
+
1970
+ const running = state.sessions.filter(s => s.running).length
1971
+ const sessions = [...state.sessions].sort((a, b) => Number(b.running) - Number(a.running) || (new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))).slice(0, 6)
1972
+ const primary = $('ds-overview-primary-action')
1973
+ if (primary) {
1974
+ let action = 'new'
1975
+ let label = t('ds.action.newSession')
1976
+ let sessionId = ''
1977
+ if (!state.token) {
1978
+ action = 'settings'
1979
+ label = t('ds.action.connect')
1980
+ } else if (online > 0 && online < 4) {
1981
+ action = 'refresh'
1982
+ label = t('ds.action.refresh')
1983
+ } else if (pending.length) {
1984
+ action = 'attention'
1985
+ label = t('ds.action.attention')
1986
+ } else if (sessions.length) {
1987
+ action = 'session'
1988
+ sessionId = sessions[0].sessionId
1989
+ label = t('ds.action.openSession')
1990
+ }
1991
+ primary.textContent = label
1992
+ primary.dataset.dsOverviewAction = action
1993
+ primary.dataset.dsOverviewSession = sessionId
1994
+ }
1995
+ $('ds-overview-dsh-version').textContent = state.hostInfo?.version || '—'
1996
+ $('ds-overview-gateway-version').textContent = checks.gateway ? t('ds.online') : t('ds.offlineShort')
1997
+ $('ds-overview-active-sessions').textContent = String(running)
1998
+ $('ds-overview-connection-mode').textContent = state.token ? t(state.streamMode === 'poll' ? 'ds.poll' : 'ds.liveWs') : '—'
1999
+ $('ds-overview-active-count').textContent = running ? t('ds.activeCount', { n: running }) : ''
2000
+ $('ds-overview-session-list').innerHTML = sessions.length ? sessions.map(s => `<button type="button" class="ds-overview-session-item ${s.running ? 'running' : ''}" data-ds-overview-session="${esc(s.sessionId)}">
2001
+ <span class="ds-overview-mark">${s.running ? '●' : '○'}</span><span class="ds-overview-copy"><span class="ds-overview-item-title">${esc(titleOf(s))}</span><span class="ds-overview-item-desc">${s.running ? esc(t('ds.running')) + ' · ' : ''}${esc(fmtTime(s.updatedAt))}</span></span><span class="ds-overview-arrow">›</span>
2002
+ </button>`).join('') : `<div class="ds-overview-empty">${t('ds.noSessions')}</div>`
2003
+ $('ds-overview-session-list').querySelectorAll('[data-ds-overview-session]').forEach(btn => btn.addEventListener('click', () => openSession(btn.dataset.dsOverviewSession)))
2004
+ }
2005
+
1659
2006
  function showView(id) {
1660
2007
  state.view = id
1661
- for (const v of ['view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
2008
+ for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
1662
2009
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
1663
- const titles = { 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
2010
+ const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
1664
2011
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
1665
2012
  else $('ds-title').textContent = t(titles[id])
2013
+ if (id === 'view-overview') renderOverviewDesktop()
1666
2014
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
1667
2015
  if (id === 'view-settings') showSettingsHome()
1668
2016
  }
@@ -1748,10 +2096,53 @@ function bindUi() {
1748
2096
  list.style.display = list.style.display === 'none' ? 'flex' : 'none'
1749
2097
  })
1750
2098
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
2099
+ $('ds-overview-refresh').addEventListener('click', async () => {
2100
+ toast(t('ds.loading'))
2101
+ if (state.token) {
2102
+ await refreshSessions()
2103
+ const host = await safeRpc('host.describe', {}, '')
2104
+ if (host) state.hostInfo = host
2105
+ }
2106
+ renderOverviewDesktop()
2107
+ })
2108
+ $('ds-overview-primary-action').addEventListener('click', () => {
2109
+ const button = $('ds-overview-primary-action')
2110
+ const action = button.dataset.dsOverviewAction
2111
+ if (action === 'session' && button.dataset.dsOverviewSession) return openSession(button.dataset.dsOverviewSession)
2112
+ if (action === 'new') return $('btn-new-session').click()
2113
+ if (action === 'settings') return showView('view-settings')
2114
+ if (action === 'refresh') return $('ds-overview-refresh').click()
2115
+ const first = document.querySelector('.ds-overview-attention-item')
2116
+ if (first) {
2117
+ first.scrollIntoView({ behavior: 'smooth', block: 'center' })
2118
+ if (first.matches('button')) first.focus({ preventScroll: true })
2119
+ }
2120
+ })
1751
2121
  $('session-list').addEventListener('click', (e) => {
2122
+ if (e.target.closest('[data-archived-toggle]')) {
2123
+ LS.set('dsShowArchivedV1', LS.get('dsShowArchivedV1', '0') === '1' ? '0' : '1')
2124
+ renderSessions()
2125
+ return
2126
+ }
1752
2127
  const item = e.target.closest('[data-id]')
1753
2128
  if (item) openSession(item.dataset.id)
1754
2129
  })
2130
+ $('btn-wb-bind').addEventListener('click', openWorkbenchModal)
2131
+ $('btn-wb-bind-manual').addEventListener('click', () => bindWorkbench($('wb-path-input').value))
2132
+ $('wb-path-input').addEventListener('keydown', e => {
2133
+ if (e.key === 'Enter' && !e.isComposing) { e.preventDefault(); bindWorkbench($('wb-path-input').value) }
2134
+ })
2135
+ $('wb-fs-up').addEventListener('click', wbFsUp)
2136
+ $('wb-fs-home').addEventListener('click', () => wbFsLoad(wbFs.initial || null))
2137
+ $('btn-wb-modal-close').addEventListener('click', closeWorkbenchModal)
2138
+ $('modal-workbench').addEventListener('click', e => { if (e.target === $('modal-workbench')) closeWorkbenchModal() })
2139
+ $('wb-head').addEventListener('click', () => {
2140
+ state.wb.expanded = !state.wb.expanded
2141
+ if (state.wb.expanded && !state.wb.projects) refreshWorkbench({ silent: false })
2142
+ else renderWorkbench()
2143
+ })
2144
+ $('btn-wb-path').addEventListener('click', () => { if (state.wb.path) toast(t('wb.boundPath', { path: state.wb.path }), 'ok') })
2145
+ $('btn-wb-unbind').addEventListener('click', unbindWorkbench)
1755
2146
  $('btn-send').addEventListener('click', sendMessage)
1756
2147
  $('composer').addEventListener('keydown', (e) => {
1757
2148
  if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
@@ -1864,7 +2255,7 @@ function bindUi() {
1864
2255
  $('btn-lang').addEventListener('click', () => {
1865
2256
  I18N.setLang(I18N.lang === 'zh' ? 'en' : 'zh')
1866
2257
  $('btn-lang').textContent = I18N.lang === 'zh' ? 'EN' : '中文'
1867
- renderServers(); renderSessions(); updateConn(); themeApply()
2258
+ renderServers(); renderSessions(); renderNotifStack(); renderOverviewDesktop(); updateConn(); themeApply()
1868
2259
  })
1869
2260
  $('fs-up').addEventListener('click', fsUp)
1870
2261
  $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
@@ -1876,7 +2267,7 @@ function bindUi() {
1876
2267
  async function start() {
1877
2268
  loadServers()
1878
2269
  renderServers()
1879
- showView('view-sessions')
2270
+ showView('view-overview')
1880
2271
  const urlToken = new URLSearchParams(location.search).get('token')
1881
2272
  if (urlToken) { state.token = urlToken; LS.set('token', urlToken); history.replaceState(null, '', location.pathname) }
1882
2273
  if (!state.token) {
@@ -1885,13 +2276,18 @@ async function start() {
1885
2276
  }
1886
2277
  $('token-desc').textContent = state.token ? '● ' + state.token.slice(0, 12) + '…' : t('ds.toastAuth')
1887
2278
  bindUi()
2279
+ renderWorkbench()
1888
2280
  updateConn()
1889
2281
  checkNotesOnStart()
1890
2282
  if (state.token) {
1891
2283
  if (state.servers.length) await selectFastestServer({ silent: true, reconnect: false })
1892
2284
  openStreams()
1893
- refreshSessions()
2285
+ await refreshSessions()
2286
+ const host = await safeRpc('host.describe', {}, '')
2287
+ if (host) state.hostInfo = host
2288
+ refreshWorkbench({ silent: true })
1894
2289
  }
2290
+ renderOverviewDesktop()
1895
2291
  }
1896
2292
 
1897
2293
  start()
@@ -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
  }