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.
package/public/app.js CHANGED
@@ -46,11 +46,13 @@ const state = {
46
46
  serverLatency: {}, // url -> 最近一次 /health 测速毫秒数
47
47
  selectingServer: false, // 防重入: 测速/切换中
48
48
  sessions: [],
49
+ sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
49
50
  byId: new Map(),
50
51
  current: null, // 当前打开的 sessionId
51
52
  hostInfo: null,
52
53
  localVersion: '',
53
54
  updateInfo: null,
55
+ announcement: null,
54
56
  approvals: [], // 待处理审批
55
57
  questions: [], // 待处理提问
56
58
  queues: {}, // sessionId -> queue items
@@ -61,7 +63,12 @@ const state = {
61
63
  pollSeq: { mux: 0, host: 0 },
62
64
  refreshTimer: null,
63
65
  fs: { path: null, initial: null, loaded: false, upload: null },
64
- models: { loaded: false, loading: false, groups: [], current: null, failures: [] }
66
+ models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
67
+ wb: null,
68
+ wbProjects: [],
69
+ wbArchived: [],
70
+ wbOpen: false,
71
+ wbOpenProjects: {}
65
72
  }
66
73
 
67
74
  const $ = (id) => document.getElementById(id)
@@ -183,6 +190,24 @@ function apiUrl(path) {
183
190
  return (state.server || '') + path
184
191
  }
185
192
 
193
+ function updateBase() {
194
+ const configured = String(state.server || '').replace(/\/+$/, '')
195
+ if (configured) return configured
196
+ if (!/^https?:$/.test(location.protocol)) return ''
197
+ const origin = location.origin.replace(/\/+$/, '')
198
+ return location.pathname === '/remote' || location.pathname.startsWith('/remote/')
199
+ ? origin + '/remote'
200
+ : origin
201
+ }
202
+
203
+ function adminApiUrl(path) {
204
+ const base = String(state.server || '').replace(/\/+$/, '')
205
+ if (base) return base + path
206
+ // DSH 抽屉页面同源运行在 /remote/ 前缀下,管理接口也必须带此前缀;
207
+ // 独立网关根页面则继续使用 /admin/api/*。
208
+ return location.pathname.startsWith('/remote/') ? '/remote' + path : path
209
+ }
210
+
186
211
  function fmtCost(n) {
187
212
  return '¥' + (Number(n) || 0).toFixed(2)
188
213
  }
@@ -268,14 +293,14 @@ function renderStats(days) {
268
293
  </div>`
269
294
  }).join('')
270
295
  }
271
- async function rpc(method, payload = {}) {
296
+ async function rpc(method, payload = {}, timeoutMs = 45000) {
272
297
  const opts = {
273
298
  method: 'POST',
274
299
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
275
300
  body: JSON.stringify({ type: 'client-request', rpcId: uuid(), method, payload })
276
301
  }
277
302
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
278
- opts.signal = AbortSignal.timeout(45000)
303
+ opts.signal = AbortSignal.timeout(timeoutMs)
279
304
  }
280
305
  const res = await fetch(apiUrl('/api/' + method), opts)
281
306
  if (res.status === 401) throw new Error('AUTH')
@@ -290,11 +315,15 @@ async function rpc(method, payload = {}) {
290
315
  }
291
316
 
292
317
  async function respond(rpcId, value) {
293
- const res = await fetch(apiUrl('/api/respond'), {
318
+ const opts = {
294
319
  method: 'POST',
295
320
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' },
296
321
  body: JSON.stringify({ type: 'client-response', rpcId, result: { ok: true, value } })
297
- })
322
+ }
323
+ if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
324
+ opts.signal = AbortSignal.timeout(15000)
325
+ }
326
+ const res = await fetch(apiUrl('/api/respond'), opts)
298
327
  if (res.status === 401) throw new Error('AUTH')
299
328
  const receipt = await res.json()
300
329
  return receipt?.accepted === true
@@ -430,7 +459,8 @@ async function selectFastestServer({ silent = false, reconnect = true } = {}) {
430
459
  let ms = Infinity
431
460
 
432
461
  if (state.autoSelect[state.activeGroup] !== false) {
433
- for (const u of candidates) state.serverLatency[u] = await pingServer(u)
462
+ const measured = await Promise.all(candidates.map(async (u) => [u, await pingServer(u)]))
463
+ for (const [u, latency] of measured) state.serverLatency[u] = latency
434
464
  best = candidates
435
465
  .filter(u => Number.isFinite(state.serverLatency[u]))
436
466
  .sort((a, b) => state.serverLatency[a] - state.serverLatency[b])[0] || null
@@ -898,8 +928,18 @@ async function pollKind(kind) {
898
928
  let data
899
929
  try { data = await res.json() } catch { return }
900
930
  if (!data || !Array.isArray(data.events)) return
901
- // 网关重启后 seq 会重置:落后就从头拉当前缓冲
902
- if (typeof data.latestSeq === 'number' && data.latestSeq < since) state.pollSeq[kind] = 0
931
+ // 网关重启或客户端离线过久后,游标可能落后于内存环形缓冲;
932
+ // 从当前缓冲重新接收,并刷新权威会话列表,避免静默漏事件。
933
+ const reset = data.truncated === true || (typeof data.latestSeq === 'number' && data.latestSeq < since)
934
+ if (reset) {
935
+ state.pollSeq[kind] = 0
936
+ if (kind === 'mux') {
937
+ state.approvals = []
938
+ state.questions = []
939
+ renderPending()
940
+ }
941
+ scheduleRefresh()
942
+ }
903
943
  for (const item of data.events) {
904
944
  if (item.seq > (state.pollSeq[kind] || 0)) {
905
945
  state.pollSeq[kind] = item.seq
@@ -1056,6 +1096,7 @@ async function refreshSessions() {
1056
1096
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1057
1097
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1058
1098
  renderSessions()
1099
+ refreshWorkbench()
1059
1100
  }
1060
1101
 
1061
1102
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
@@ -1070,7 +1111,8 @@ function applyProjection(sessionId, key, value, seq) {
1070
1111
  if (state.current === sessionId) { renderSessionTitle(); renderSessionCards() }
1071
1112
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) scheduleRefresh()
1072
1113
  else renderSessions()
1073
- }function titleOf(s) { return proj(s, 'title') || short(s.sessionId) }
1114
+ }
1115
+ function titleOf(s) { return proj(s, 'title') || (s?.sessionId ? short(s.sessionId) : t('session.unknown')) }
1074
1116
  function short(id) { return '…' + String(id).slice(-8) }
1075
1117
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1076
1118
  function isGoalTerminal(goal) {
@@ -1088,37 +1130,182 @@ function updatePendingBadge() {
1088
1130
  if (pending) $('nav-pending').textContent = pending
1089
1131
  }
1090
1132
 
1091
- function renderSessions() {
1092
- const list = $('session-list')
1093
- const items = [...state.sessions].sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1094
- list.innerHTML = items.map(s => {
1095
- const title = titleOf(s)
1096
- const goal = goalOf(s)
1097
- const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
1098
- const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
1099
- const dots = []
1100
- if (s.running) dots.push('running')
1101
- if (pending) dots.push('pending')
1102
- const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1103
- const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1104
- return `<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1105
- <div class="sc-title">${esc(title)}</div>
1106
- <div class="sc-meta">
1107
- <span class="sc-dot ${dots.join(' ')}"></span>
1108
- <span>${fmtTime(s.updatedAt)}</span>
1109
- ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1110
- ${badge}${queueBadge}
1111
- </div>
1112
- <span class="sc-arrow">›</span>
1133
+ /* ---------------- 工作台与归档会话 ---------------- */
1134
+ function wbPathKey(p) {
1135
+ let value = String(p || '').replace(/\\/g, '/').replace(/\/+/g, '/')
1136
+ if (value.length > 1) value = value.replace(/\/+$/, '')
1137
+ const windows = /^[A-Za-z]:\//.test(value) || /Windows/i.test(navigator.platform || navigator.userAgent || '')
1138
+ return windows ? value.toLowerCase() : value
1139
+ }
1140
+ function wbBaseName(p) {
1141
+ const value = String(p || '').replace(/[\\/]+$/, '')
1142
+ return value.split(/[\\/]/).pop() || value
1143
+ }
1144
+ function wbStrictInside(pathValue, rootValue) {
1145
+ const pathKey = wbPathKey(pathValue)
1146
+ const rootKey = wbPathKey(rootValue)
1147
+ if (!pathKey || !rootKey || pathKey === rootKey) return false
1148
+ return pathKey.startsWith(rootKey.endsWith('/') ? rootKey : rootKey + '/')
1149
+ }
1150
+ function wbJoin(root, name) {
1151
+ const raw = String(root || '')
1152
+ const separator = raw.includes('\\') ? '\\' : '/'
1153
+ return raw.replace(/[\\/]+$/, '') + separator + String(name || '')
1154
+ }
1155
+ function workbenchRoot() {
1156
+ return state.wb?.bound && state.wb.path ? state.wb.path : ''
1157
+ }
1158
+ async function refreshWorkbench() {
1159
+ if (!state.token) return
1160
+ try {
1161
+ const res = await fetch(apiUrl('/workbench'), {
1162
+ headers: { authorization: 'Bearer ' + state.token, 'x-dsh-remote-client': CAP?.isNativePlatform?.() ? 'app' : 'web' }
1163
+ })
1164
+ if (res.ok) {
1165
+ const value = await res.json().catch(() => null)
1166
+ if (value && typeof value.bound === 'boolean') state.wb = value
1167
+ }
1168
+ } catch {}
1169
+ try {
1170
+ const value = await rpc('workspace.list', {})
1171
+ state.wbProjects = Array.isArray(value?.items) ? value.items : []
1172
+ state.wbArchived = Array.isArray(value?.archivedSessionIds) ? value.archivedSessionIds : []
1173
+ } catch {
1174
+ state.wbProjects = []
1175
+ state.wbArchived = []
1176
+ }
1177
+ renderWorkbench()
1178
+ renderSessions()
1179
+ }
1180
+ function renderWorkbench() {
1181
+ const bar = $('workbench-bar')
1182
+ if (!bar) return
1183
+ const bound = !!state.wb?.bound && !!state.wb.path
1184
+ const toggle = $('wb-toggle')
1185
+ const panel = $('wb-panel')
1186
+ bar.classList.toggle('bound', bound)
1187
+ bar.classList.toggle('unbound', !bound)
1188
+ if (!bound) {
1189
+ $('wb-label').textContent = t('wb.unbound')
1190
+ toggle.setAttribute('aria-expanded', 'false')
1191
+ panel.classList.add('hidden')
1192
+ panel.innerHTML = ''
1193
+ return
1194
+ }
1195
+ $('wb-label').textContent = t('wb.bound', { title: state.wb.title || wbBaseName(state.wb.path) })
1196
+ toggle.setAttribute('aria-expanded', state.wbOpen ? 'true' : 'false')
1197
+ panel.classList.toggle('hidden', !state.wbOpen)
1198
+ if (!state.wbOpen) { panel.innerHTML = ''; return }
1199
+ const projects = state.wbProjects.filter(w => wbStrictInside(w.path, workbenchRoot()))
1200
+ if (!projects.length) {
1201
+ panel.innerHTML = `<div class="wb-empty">${esc(t('wb.noProjects'))}</div>`
1202
+ return
1203
+ }
1204
+ panel.innerHTML = projects.map(w => {
1205
+ const id = String(w.workspaceId || '')
1206
+ const open = !!state.wbOpenProjects[id]
1207
+ const sessions = (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(Boolean)
1208
+ const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
1209
+ <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}">
1210
+ <span class="wb-session-title">${esc(titleOf(s))}</span>
1211
+ <span class="wb-session-meta">${s.running ? esc(t('sessions.running')) : esc(fmtTime(s.updatedAt))}</span>
1212
+ </button>`).join('') : `<div class="wb-empty">${esc(t('wb.noSessions'))}</div>`}</div>` : ''
1213
+ return `<div class="wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}">
1214
+ <div class="wb-project-head">
1215
+ <span class="wb-chevron" aria-hidden="true">${open ? '▾' : '▸'}</span>
1216
+ <span class="wb-project-title">${esc(w.title || wbBaseName(w.path) || w.path)}</span>
1217
+ <button class="mini-btn wb-new" type="button" data-wb-new="${esc(id)}">${esc(t('wb.newSession'))}</button>
1218
+ </div>${body}
1113
1219
  </div>`
1114
1220
  }).join('')
1115
- $('home-empty').classList.toggle('hidden', items.length > 0)
1221
+ }
1222
+
1223
+ function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1224
+ function sessionWorkspaceLabel(s) {
1225
+ const cwd = sessionCwd(s)
1226
+ return cwd || t('sessions.workspaceUnknown')
1227
+ }
1228
+ function workspaceDisplayName(label) {
1229
+ const value = String(label || '').trim()
1230
+ if (!value || value === t('sessions.workspaceUnknown')) return value || t('sessions.workspaceUnknown')
1231
+ const clean = value.replace(/[\\/]+$/, '')
1232
+ const parts = clean.split(/[\\/]/).filter(Boolean)
1233
+ return parts[parts.length - 1] || value
1234
+ }
1235
+ function sortedSessions() {
1236
+ const items = [...state.sessions]
1237
+ if (state.sessionSort === 'workspace') {
1238
+ return items.sort((a, b) => {
1239
+ const aw = sessionCwd(a) || '\uffff'
1240
+ const bw = sessionCwd(b) || '\uffff'
1241
+ const byWorkspace = aw.localeCompare(bw, undefined, { numeric: true, sensitivity: 'base' })
1242
+ return byWorkspace || ((b.updatedAt || 0) - (a.updatedAt || 0))
1243
+ })
1244
+ }
1245
+ return items.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
1246
+ }
1247
+ function renderSessions() {
1248
+ const list = $('session-list')
1249
+ const allItems = sortedSessions()
1250
+ const wbIds = new Set()
1251
+ if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1252
+ const root = workbenchRoot()
1253
+ const visible = allItems.filter(s => !(state.wb?.bound && (wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))))
1254
+ const archivedSet = new Set(state.wbArchived || [])
1255
+ const archived = visible.filter(s => archivedSet.has(s.sessionId))
1256
+ const main = visible.filter(s => !archivedSet.has(s.sessionId))
1257
+ const showArchived = LS.get('showArchivedV1', '0') === '1'
1258
+ const renderItems = (items) => {
1259
+ let lastWorkspace = null
1260
+ const rows = []
1261
+ for (const s of items) {
1262
+ const workspace = sessionWorkspaceLabel(s)
1263
+ const workspaceName = workspaceDisplayName(workspace)
1264
+ if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1265
+ rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1266
+ lastWorkspace = workspace
1267
+ }
1268
+ const title = titleOf(s)
1269
+ const goal = goalOf(s)
1270
+ const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
1271
+ const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
1272
+ const dots = []
1273
+ if (s.running) dots.push('running')
1274
+ if (pending) dots.push('pending')
1275
+ const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1276
+ const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1277
+ const archiveButton = archivedSet.has(s.sessionId) ? '' : `<button type="button" class="sc-archive-btn" data-archive-session="${esc(s.sessionId)}">${esc(t('session.archive'))}</button>`
1278
+ rows.push(`<div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
1279
+ <div class="session-card ${state.current === s.sessionId ? 'current' : ''}">
1280
+ <div class="sc-title">${esc(title)}</div>
1281
+ <div class="sc-meta">
1282
+ <span class="sc-dot ${dots.join(' ')}"></span>
1283
+ <span>${fmtTime(s.updatedAt)}</span>
1284
+ ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1285
+ ${badge}${queueBadge}
1286
+ </div>
1287
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1288
+ <span class="sc-arrow">›</span>
1289
+ </div>
1290
+ ${archiveButton}
1291
+ </div>`)
1292
+ }
1293
+ return rows.join('')
1294
+ }
1295
+ const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1296
+ const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1297
+ const hiddenByWorkbench = allItems.length - visible.length
1298
+ list.innerHTML = rows || `<div class="empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('home.empty'))}</div>`
1299
+ list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1300
+ const sort = $('session-sort')
1301
+ if (sort) sort.value = state.sessionSort
1302
+ $('home-empty').classList.toggle('hidden', visible.length > 0)
1116
1303
  const running = state.sessions.filter(s => s.running).length
1117
1304
  const pending = state.approvals.length + state.questions.length
1118
1305
  $('stat-strip').innerHTML = `
1119
1306
  <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
1120
1307
  <div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
1121
- <div class="stat ctx"><div class="v">${items.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
1308
+ <div class="stat ctx"><div class="v">${visible.length}</div><div class="k">${t('sessions.statTotal')}</div></div>`
1122
1309
  updatePendingBadge()
1123
1310
  }
1124
1311
 
@@ -1151,7 +1338,7 @@ function bindNativeBack() {
1151
1338
  try {
1152
1339
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1153
1340
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1154
- if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else openModal.classList.add('hidden'); return } // 先关弹窗
1341
+ if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1155
1342
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1156
1343
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1157
1344
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1245,7 +1432,7 @@ function restoreCachedHistory() {
1245
1432
  if (!cached?.events?.length) return false
1246
1433
  const h = emptyHistory()
1247
1434
  for (const e of cached.events) {
1248
- if (!e?.seq) continue
1435
+ if (e?.seq == null) continue
1249
1436
  h.seqs.add(e.seq)
1250
1437
  h.visible.push(e)
1251
1438
  }
@@ -1613,7 +1800,8 @@ async function goalAction(kind) {
1613
1800
  if (!method) return
1614
1801
  if (kind === 'clear' && !confirm(t('goal.confirmClear'))) return
1615
1802
  if (kind === 'complete' && !confirm(t('goal.confirmComplete'))) return
1616
- await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1803
+ const result = await safeRpc(method, { sessionId: state.current, ref }, t('goal.actionFailed'))
1804
+ if (result == null) return
1617
1805
  if (kind === 'complete') setGoalPhaseLocal('complete')
1618
1806
  if (kind === 'clear') setGoalPhaseLocal('cleared')
1619
1807
  toast(t('goal.actionSubmitted'), 'ok')
@@ -1622,7 +1810,8 @@ async function goalAction(kind) {
1622
1810
 
1623
1811
  async function interruptSubagent(childId) {
1624
1812
  if (!confirm(t('subagent.confirmInterrupt'))) return
1625
- await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1813
+ const result = await safeRpc('subagent.interrupt', { parentSessionId: state.current, childSessionId: childId, mode: 'continuable' }, t('subagent.interruptFailed'))
1814
+ if (result == null) return
1626
1815
  toast(t('subagent.interruptSubmitted'), 'ok')
1627
1816
  setTimeout(renderSessionCards, 600)
1628
1817
  }
@@ -1678,6 +1867,7 @@ async function sendMessage() {
1678
1867
  function hideComposerMenu() {
1679
1868
  $('composer-menu').classList.add('hidden')
1680
1869
  $('btn-plus').classList.remove('active')
1870
+ $('permission-submenu')?.classList.add('hidden')
1681
1871
  }
1682
1872
 
1683
1873
  function toggleComposerMenu() {
@@ -1688,6 +1878,13 @@ function toggleComposerMenu() {
1688
1878
  if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
1689
1879
  }
1690
1880
 
1881
+ function isMobileDevice() {
1882
+ return !!CAP?.isNativePlatform?.() || /Android|iPhone|iPad|iPod|Mobile|Windows Phone/i.test(navigator.userAgent || '')
1883
+ }
1884
+ function mobileEnterAction() {
1885
+ return LS.get('mobileEnterAction', 'newline') === 'send' ? 'send' : 'newline'
1886
+ }
1887
+
1691
1888
  async function loadSessionModels() {
1692
1889
  if (!state.current || state.models.loading) return
1693
1890
  state.models.loading = true
@@ -1784,13 +1981,103 @@ async function cancelSession() {
1784
1981
  }
1785
1982
 
1786
1983
  async function newSession() {
1787
- const v = await safeRpc('session.create', {}, t('home.createFailed'))
1984
+ let payload = {}
1985
+ // DSH 的 host.describe 返回当前工作目录。每次创建前短暂刷新一次,
1986
+ // 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
1987
+ try {
1988
+ const host = await rpc('host.describe', {}, 5000)
1989
+ const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
1990
+ if (cwd) {
1991
+ state.hostInfo = host
1992
+ payload = { cwd }
1993
+ }
1994
+ } catch {}
1995
+ const v = await safeRpc('session.create', payload, t('home.createFailed'))
1788
1996
  if (!v?.sessionId) return
1789
1997
  toast(t('home.created'), 'ok')
1790
1998
  await refreshSessions()
1791
1999
  openSession(v.sessionId)
1792
2000
  }
1793
2001
 
2002
+ let archivePendingSessionId = null
2003
+ function archiveSession(sessionId) {
2004
+ const session = state.byId.get(sessionId)
2005
+ if (!session) return
2006
+ archivePendingSessionId = sessionId
2007
+ $('archive-session-title').textContent = titleOf(session)
2008
+ $('archive-session-workspace').textContent = sessionWorkspaceLabel(session)
2009
+ $('modal-archive').classList.remove('hidden')
2010
+ }
2011
+ function closeArchiveConfirm() {
2012
+ archivePendingSessionId = null
2013
+ $('modal-archive').classList.add('hidden')
2014
+ }
2015
+ async function confirmArchiveSession() {
2016
+ const sessionId = archivePendingSessionId
2017
+ if (!sessionId) return
2018
+ const button = $('archive-confirm')
2019
+ button.disabled = true
2020
+ try {
2021
+ const value = await safeRpc('workspace.archiveSession', { sessionId }, t('session.archiveFailed', { msg: '' }))
2022
+ if (value == null) return
2023
+ closeArchiveConfirm()
2024
+ toast(t('session.archived'), 'ok')
2025
+ await refreshSessions()
2026
+ } finally {
2027
+ button.disabled = false
2028
+ }
2029
+ }
2030
+
2031
+ let swipeTracking = null
2032
+ let swipeSuppressClickUntil = 0
2033
+ function closeRevealedSwipes(except = null) {
2034
+ document.querySelectorAll('#session-list .session-swipe.revealed').forEach(row => {
2035
+ if (row !== except) row.classList.remove('revealed')
2036
+ })
2037
+ }
2038
+ function bindSessionSwipe() {
2039
+ const list = $('session-list')
2040
+ list.addEventListener('touchstart', e => {
2041
+ if (e.touches.length !== 1) return
2042
+ const row = e.target.closest('[data-session-swipe]')
2043
+ if (!row) return
2044
+ closeRevealedSwipes(row)
2045
+ const touch = e.touches[0]
2046
+ swipeTracking = {
2047
+ row,
2048
+ startX: touch.clientX,
2049
+ startY: touch.clientY,
2050
+ offset: row.classList.contains('revealed') ? -92 : 0,
2051
+ axis: null
2052
+ }
2053
+ }, { passive: true })
2054
+ list.addEventListener('touchmove', e => {
2055
+ if (!swipeTracking || e.touches.length !== 1) return
2056
+ const touch = e.touches[0]
2057
+ const dx = touch.clientX - swipeTracking.startX
2058
+ const dy = touch.clientY - swipeTracking.startY
2059
+ if (!swipeTracking.axis && Math.max(Math.abs(dx), Math.abs(dy)) >= 8) {
2060
+ swipeTracking.axis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
2061
+ }
2062
+ if (swipeTracking.axis !== 'x') return
2063
+ e.preventDefault()
2064
+ const offset = Math.max(-92, Math.min(0, swipeTracking.offset + dx))
2065
+ swipeTracking.row.style.setProperty('--swipe-x', offset + 'px')
2066
+ }, { passive: false })
2067
+ list.addEventListener('touchend', () => {
2068
+ if (!swipeTracking) return
2069
+ if (swipeTracking.axis === 'x') {
2070
+ const row = swipeTracking.row
2071
+ const offset = parseFloat(row.style.getPropertyValue('--swipe-x') || swipeTracking.offset)
2072
+ row.classList.toggle('revealed', offset <= -46)
2073
+ row.style.removeProperty('--swipe-x')
2074
+ swipeSuppressClickUntil = Date.now() + 350
2075
+ }
2076
+ swipeTracking = null
2077
+ }, { passive: true })
2078
+ list.addEventListener('touchcancel', () => { swipeTracking = null }, { passive: true })
2079
+ }
2080
+
1794
2081
  /* ---------------- 待办 ---------------- */
1795
2082
  function renderPending() {
1796
2083
  const list = $('pending-list')
@@ -1831,7 +2118,14 @@ function renderPending() {
1831
2118
  async function approveApproval(id, allow) {
1832
2119
  const a = state.approvals.find(x => x.approvalId === id)
1833
2120
  if (!a) return
1834
- const ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
2121
+ let ok
2122
+ try {
2123
+ ok = await respond(a.rpcId, { sessionId: a.sessionId, approvalId: a.approvalId, outcome: allow ? 'allowed-once' : 'rejected' })
2124
+ } catch (e) {
2125
+ if (e.message === 'AUTH') authFailure()
2126
+ else toast(t('pending.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
2127
+ return
2128
+ }
1835
2129
  toast(ok ? (allow ? t('pending.allowed') : t('pending.rejected')) : t('pending.stale'), ok ? 'ok' : 'err')
1836
2130
  state.approvals = state.approvals.filter(x => x.approvalId !== id)
1837
2131
  renderPending()
@@ -1862,7 +2156,14 @@ async function submitQuestion() {
1862
2156
  return ans
1863
2157
  }).filter(Boolean)
1864
2158
  if (!answers.length) return toast(t('question.needAnswer'), 'err')
1865
- const ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
2159
+ let ok
2160
+ try {
2161
+ ok = await respond(q.rpcId, { sessionId: q.sessionId, answer: { answers } })
2162
+ } catch (e) {
2163
+ if (e.message === 'AUTH') authFailure()
2164
+ else toast(t('question.submitFailed', { msg: e.message || t('fs.networkError') }), 'err')
2165
+ return
2166
+ }
1866
2167
  if (ok) { toast(t('question.submitted'), 'ok'); $('modal-question').classList.add('hidden'); state.questions = state.questions.filter(x => x.rpcId !== q.rpcId); renderPending() }
1867
2168
  else toast(t('question.stale'), 'err')
1868
2169
  }
@@ -1910,6 +2211,50 @@ function fsParent(p) {
1910
2211
  return clean.slice(0, idx)
1911
2212
  }
1912
2213
 
2214
+ async function openWorkspaceModal() {
2215
+ if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
2216
+ if (!state.fs.path) await loadFs(null, { silent: true })
2217
+ $('workspace-parent-path').textContent = state.fs.path || '~'
2218
+ $('workspace-name').value = ''
2219
+ $('modal-workspace').classList.remove('hidden')
2220
+ setTimeout(() => $('workspace-name').focus(), 50)
2221
+ }
2222
+ function closeWorkspaceModal() { $('modal-workspace').classList.add('hidden') }
2223
+ async function createWorkspace() {
2224
+ if (createWorkspace.busy) return
2225
+ const name = $('workspace-name').value.trim()
2226
+ if (!name) { toast(t('workspace.nameRequired'), 'err'); $('workspace-name').focus(); return }
2227
+ createWorkspace.busy = true
2228
+ const parent = state.fs.path || ''
2229
+ const button = $('workspace-create')
2230
+ button.disabled = true
2231
+ try {
2232
+ const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
2233
+ if (res.status === 401) { fsAuthError(401); return }
2234
+ const data = await res.json().catch(() => ({}))
2235
+ if (!res.ok) {
2236
+ const msg = data.error === 'exists' ? t('workspace.exists') : data.error === 'bad-name' ? t('workspace.invalidName') : data.error || ('HTTP ' + res.status)
2237
+ throw new Error(msg)
2238
+ }
2239
+ closeWorkspaceModal()
2240
+ await loadFs(parent || null, { silent: true })
2241
+ const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
2242
+ await refreshSessions()
2243
+ if (v?.sessionId) {
2244
+ toast(t('workspace.created'), 'ok')
2245
+ openSession(v.sessionId)
2246
+ } else {
2247
+ toast(t('workspace.createdNoSession'), 'ok')
2248
+ }
2249
+ } catch (e) {
2250
+ if (e.message === 'AUTH') fsAuthError(401)
2251
+ else toast(t('workspace.createFailed', { msg: e.message || t('fs.networkError') }), 'err')
2252
+ } finally {
2253
+ createWorkspace.busy = false
2254
+ button.disabled = false
2255
+ }
2256
+ }
2257
+
1913
2258
  function fsApiUrl(sub, params = {}) {
1914
2259
  const u = new URL(apiUrl('/fs' + sub), location.href)
1915
2260
  for (const [k, v] of Object.entries(params)) {
@@ -2333,7 +2678,8 @@ async function submitGoalEdit() {
2333
2678
  if (!goal) return
2334
2679
  const objective = $('goal-edit-text')?.value?.trim()
2335
2680
  if (!objective) return toast(t('goal.cannotEmpty'), 'err')
2336
- await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
2681
+ const result = await safeRpc('goal.edit', { sessionId: state.current, ref: { id: goal.id, revision: goal.revision }, objective }, t('goal.updateFailed'))
2682
+ if (result == null) return
2337
2683
  $('modal-goal').classList.add('hidden')
2338
2684
  toast(t('goal.updated'), 'ok')
2339
2685
  scheduleRefresh()
@@ -2393,6 +2739,105 @@ async function loadLocalVersion() {
2393
2739
  $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
2394
2740
  }
2395
2741
 
2742
+ /* ---------------- 远程公告 ----------------
2743
+ * 与 update.json 放在同一台服务器上,格式见 README/发布说明。
2744
+ * 公告只读取文本并用 textContent/转义后的换行渲染,不执行服务端下发的 HTML/脚本。
2745
+ */
2746
+ const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
2747
+ function readSeenAnnouncements() {
2748
+ try {
2749
+ const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
2750
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
2751
+ } catch { return {} }
2752
+ }
2753
+ function markAnnouncementSeen(id) {
2754
+ if (!id) return
2755
+ const seen = readSeenAnnouncements()
2756
+ seen[id] = Date.now()
2757
+ const keys = Object.keys(seen)
2758
+ if (keys.length > 100) {
2759
+ keys.sort((a, b) => Number(seen[a]) - Number(seen[b]))
2760
+ for (const key of keys.slice(0, keys.length - 100)) delete seen[key]
2761
+ }
2762
+ LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
2763
+ }
2764
+ function announcementVersionMatch(item) {
2765
+ const min = String(item.minVersion || item.minAppVersion || '').trim()
2766
+ const max = String(item.maxVersion || item.maxAppVersion || '').trim()
2767
+ if (!state.localVersion) return false
2768
+ if (min && cmpVersion(state.localVersion, min) < 0) return false
2769
+ if (max && cmpVersion(state.localVersion, max) > 0) return false
2770
+ return true
2771
+ }
2772
+ function normalizeAnnouncement(item, base) {
2773
+ if (!item || typeof item !== 'object') return null
2774
+ const id = String(item.id || '').trim().slice(0, 120)
2775
+ const title = String(item.title || '').trim().slice(0, 160)
2776
+ const content = String(item.content ?? item.body ?? '').trim().slice(0, 20000)
2777
+ if (!id || !title || !content || !announcementVersionMatch(item)) return null
2778
+ const now = Date.now()
2779
+ const startsAt = Date.parse(item.publishedAt || item.startsAt || '')
2780
+ const expiresAt = Date.parse(item.expiresAt || '')
2781
+ if (Number.isFinite(startsAt) && startsAt > now) return null
2782
+ if (Number.isFinite(expiresAt) && expiresAt <= now) return null
2783
+ let actionUrl = String(item.actionUrl || item.url || '').trim()
2784
+ if (actionUrl) {
2785
+ try {
2786
+ const parsed = new URL(actionUrl, base + '/')
2787
+ if (!['http:', 'https:'].includes(parsed.protocol)) actionUrl = ''
2788
+ else actionUrl = parsed.href
2789
+ } catch { actionUrl = '' }
2790
+ }
2791
+ return {
2792
+ id, title, content, actionUrl,
2793
+ actionText: String(item.actionText || '').trim().slice(0, 80),
2794
+ publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
2795
+ force: item.force === true
2796
+ }
2797
+ }
2798
+ function openAnnouncementModal(item) {
2799
+ state.announcement = item
2800
+ $('announcement-title').textContent = item.title
2801
+ $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
2802
+ const action = $('announcement-action')
2803
+ if (item.actionUrl) {
2804
+ action.href = item.actionUrl
2805
+ action.textContent = item.actionText || t('announcement.open')
2806
+ action.classList.remove('hidden')
2807
+ } else {
2808
+ action.removeAttribute('href')
2809
+ action.textContent = ''
2810
+ action.classList.add('hidden')
2811
+ }
2812
+ $('announcement-later').classList.toggle('hidden', item.force)
2813
+ $('modal-announcement').classList.remove('hidden')
2814
+ }
2815
+ function closeAnnouncement(markSeen) {
2816
+ if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
2817
+ state.announcement = null
2818
+ $('modal-announcement').classList.add('hidden')
2819
+ }
2820
+ async function checkAnnouncements() {
2821
+ const base = updateBase()
2822
+ if (!base || !state.localVersion) return false
2823
+ try {
2824
+ const signal = typeof AbortSignal?.timeout === 'function' ? AbortSignal.timeout(8000) : undefined
2825
+ const url = base + '/announcements.json?t=' + Date.now()
2826
+ const res = signal ? await fetch(url, { cache: 'no-store', signal }) : await fetch(url, { cache: 'no-store' })
2827
+ if (!res.ok) return false
2828
+ const raw = await res.text()
2829
+ if (raw.length > 512 * 1024) return false
2830
+ const data = JSON.parse(raw)
2831
+ const source = Array.isArray(data) ? data : (Array.isArray(data?.items) ? data.items : [data])
2832
+ const seen = readSeenAnnouncements()
2833
+ const items = source.map(item => normalizeAnnouncement(item, base)).filter(item => item && !seen[item.id])
2834
+ .sort((a, b) => b.publishedAt - a.publishedAt)
2835
+ if (!items.length) return false
2836
+ openAnnouncementModal(items[0])
2837
+ return true
2838
+ } catch { return false }
2839
+ }
2840
+
2396
2841
  /* ---------------- 更新内容弹窗 ---------------- */
2397
2842
  const NOTES_KEY = 'seenNotesVersion'
2398
2843
  let notesVersion = ''
@@ -2477,7 +2922,7 @@ async function checkUpdate(silent) {
2477
2922
  if (!silent) toast(t('update.noVersion'), 'err')
2478
2923
  return
2479
2924
  }
2480
- const base = state.server
2925
+ const base = updateBase()
2481
2926
  if (!base) {
2482
2927
  if (!silent) toast(t('update.needServer'), 'err')
2483
2928
  $('update-desc').textContent = state.localVersion ? `${t('update.currentV', { version: state.localVersion })} · ${t('update.needServer')}` : t('update.needServer')
@@ -2554,7 +2999,7 @@ async function verifyUpdateApk(info, url) {
2554
2999
  async function downloadUpdate() {
2555
3000
  const info = state.updateInfo
2556
3001
  if (!info) return
2557
- const base = state.server || ''
3002
+ const base = updateBase()
2558
3003
  let url
2559
3004
  try { url = new URL(info.apkUrl || 'dsh-remote.apk', base + '/').href }
2560
3005
  catch { url = base + '/' + (info.apkUrl || 'dsh-remote.apk') }
@@ -2769,8 +3214,7 @@ async function schedulePeakReminders() {
2769
3214
  const b = bgBridge()
2770
3215
  if (!b?.startPeakReminder) return false
2771
3216
  try {
2772
- b.startPeakReminder()
2773
- return true
3217
+ return b.startPeakReminder() !== false
2774
3218
  } catch { return false }
2775
3219
  }
2776
3220
 
@@ -2779,8 +3223,7 @@ async function cancelPeakReminders() {
2779
3223
  const b = bgBridge()
2780
3224
  if (!b?.stopPeakReminder) return false
2781
3225
  try {
2782
- b.stopPeakReminder()
2783
- return true
3226
+ return b.stopPeakReminder() !== false
2784
3227
  } catch { return false }
2785
3228
  }
2786
3229
 
@@ -2973,6 +3416,55 @@ function initToken() {
2973
3416
  $('server-desc').textContent = state.server || t('servers.defaultDesc')
2974
3417
  }
2975
3418
 
3419
+ function renderDshControlStatus(v) {
3420
+ const desc = $('dsh-control-desc')
3421
+ if (!desc || !v) return
3422
+ const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3423
+ if (v.supported === false) {
3424
+ desc.textContent = v.message || t('settings.dshUnsupported')
3425
+ buttons.forEach(b => { b.disabled = true; b.classList.add('hidden') })
3426
+ return
3427
+ }
3428
+ buttons.forEach(b => { b.disabled = false; b.classList.remove('hidden') })
3429
+ desc.textContent = `${t(v.running ? 'settings.dshRunning' : 'settings.dshStopped')} · ${v.service || 'dsh-web'}`
3430
+ }
3431
+
3432
+ async function loadDshControl() {
3433
+ if (!state.token || !$('dsh-control-desc')) return
3434
+ try {
3435
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), { headers: { authorization: 'Bearer ' + state.token } })
3436
+ if (res.status === 401) return authFailure()
3437
+ renderDshControlStatus(await res.json())
3438
+ } catch {
3439
+ $('dsh-control-desc').textContent = t('settings.dshFailed', { msg: t('conn.off') })
3440
+ }
3441
+ }
3442
+
3443
+ async function controlDsh(action) {
3444
+ const label = action === 'start' ? t('settings.dshStart') : t('settings.dshRestart')
3445
+ const buttons = [$('btn-dsh-start'), $('btn-dsh-restart')].filter(Boolean)
3446
+ buttons.forEach(b => { b.disabled = true })
3447
+ const desc = $('dsh-control-desc')
3448
+ if (desc) desc.textContent = t('settings.dshStarting', { action: label })
3449
+ try {
3450
+ const res = await fetch(adminApiUrl('/admin/api/dsh'), {
3451
+ method: 'POST',
3452
+ headers: { authorization: 'Bearer ' + state.token, 'content-type': 'application/json' },
3453
+ body: JSON.stringify({ action }),
3454
+ })
3455
+ if (res.status === 401) return authFailure()
3456
+ const v = await res.json().catch(() => ({}))
3457
+ if (!res.ok || v.ok === false) throw new Error(v.error || v.message || `HTTP ${res.status}`)
3458
+ renderDshControlStatus(v)
3459
+ toast(t('settings.dshStarted', { action: label }), 'ok')
3460
+ } catch (e) {
3461
+ if (desc) desc.textContent = t('settings.dshFailed', { msg: e?.message || e })
3462
+ toast(t('settings.dshFailed', { msg: e?.message || e }), 'err')
3463
+ } finally {
3464
+ buttons.forEach(b => { b.disabled = false })
3465
+ }
3466
+ }
3467
+
2976
3468
  function renderLangBtn() {
2977
3469
  const btn = $('btn-lang')
2978
3470
  if (btn) btn.textContent = I18N.lang === 'zh' ? 'EN' : '中文'
@@ -3029,6 +3521,7 @@ function bindUi() {
3029
3521
  renderUpdateExpandBtn()
3030
3522
  renderServers()
3031
3523
  renderSessions()
3524
+ renderWorkbench()
3032
3525
  renderPending(); renderQueue(); renderJobs()
3033
3526
  updateConn()
3034
3527
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -3056,9 +3549,53 @@ function bindUi() {
3056
3549
  b.addEventListener('click', () => showView(b.dataset.view)))
3057
3550
  // 会话列表点击
3058
3551
  $('session-list').addEventListener('click', (e) => {
3552
+ if (swipeSuppressClickUntil > Date.now()) { swipeSuppressClickUntil = 0; return }
3553
+ if (e.target.closest('[data-archived-toggle]')) {
3554
+ LS.set('showArchivedV1', LS.get('showArchivedV1', '0') === '1' ? '0' : '1')
3555
+ renderSessions()
3556
+ return
3557
+ }
3558
+ const archive = e.target.closest('[data-archive-session]')
3559
+ if (archive) {
3560
+ e.stopPropagation()
3561
+ archiveSession(archive.dataset.archiveSession)
3562
+ return
3563
+ }
3564
+ const swipeRow = e.target.closest('[data-session-swipe]')
3565
+ if (swipeRow?.classList.contains('revealed')) {
3566
+ swipeRow.classList.remove('revealed')
3567
+ return
3568
+ }
3059
3569
  const card = e.target.closest('[data-id]')
3060
3570
  if (card) openSession(card.dataset.id)
3061
3571
  })
3572
+ bindSessionSwipe()
3573
+ $('wb-toggle').addEventListener('click', () => {
3574
+ if (!state.wb?.bound) return
3575
+ state.wbOpen = !state.wbOpen
3576
+ renderWorkbench()
3577
+ })
3578
+ $('wb-panel').addEventListener('click', (e) => {
3579
+ const newButton = e.target.closest('[data-wb-new]')
3580
+ if (newButton) {
3581
+ safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
3582
+ if (!v?.sessionId) return
3583
+ toast(t('home.created'), 'ok')
3584
+ await refreshSessions()
3585
+ openSession(v.sessionId)
3586
+ })
3587
+ return
3588
+ }
3589
+ const head = e.target.closest('.wb-project-head')
3590
+ if (head) {
3591
+ const project = head.closest('[data-wb-project]')
3592
+ const id = project?.dataset.wbProject
3593
+ if (id) { state.wbOpenProjects[id] = !state.wbOpenProjects[id]; renderWorkbench() }
3594
+ return
3595
+ }
3596
+ const session = e.target.closest('[data-wb-session]')
3597
+ if (session) openSession(session.dataset.wbSession)
3598
+ })
3062
3599
  $('btn-back').addEventListener('click', closeSession)
3063
3600
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
3064
3601
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
@@ -3089,12 +3626,24 @@ function bindUi() {
3089
3626
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
3090
3627
  })
3091
3628
  $('btn-new-session').addEventListener('click', newSession)
3629
+ $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
3630
+ $('session-sort')?.addEventListener('change', (e) => {
3631
+ state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
3632
+ LS.set('sessionSort', state.sessionSort)
3633
+ renderSessions()
3634
+ })
3092
3635
  $('btn-cancel').addEventListener('click', cancelSession)
3093
3636
  $('btn-send').addEventListener('click', sendMessage)
3094
3637
  $('btn-plus').addEventListener('click', toggleComposerMenu)
3095
3638
  $('composer-menu').addEventListener('click', async (e) => {
3096
3639
  const chip = e.target.closest('[data-cmd]')
3097
3640
  if (chip) {
3641
+ if (chip.dataset.cmd === '/permission') {
3642
+ const submenu = $('permission-submenu')
3643
+ submenu?.classList.toggle('hidden')
3644
+ return
3645
+ }
3646
+ $('permission-submenu')?.classList.add('hidden')
3098
3647
  const input = $('composer-input')
3099
3648
  input.value = chip.dataset.cmd + ' '
3100
3649
  input.focus()
@@ -3102,6 +3651,15 @@ function bindUi() {
3102
3651
  hideComposerMenu()
3103
3652
  return
3104
3653
  }
3654
+ const perm = e.target.closest('[data-perm]')
3655
+ if (perm) {
3656
+ const input = $('composer-input')
3657
+ input.value = '/permission ' + perm.dataset.perm + ' '
3658
+ input.focus()
3659
+ autosize(input)
3660
+ hideComposerMenu()
3661
+ return
3662
+ }
3105
3663
  const preset = e.target.closest('[data-preset]')
3106
3664
  if (preset) {
3107
3665
  const found = readPresets().find(x => x.id === preset.dataset.preset)
@@ -3118,7 +3676,9 @@ function bindUi() {
3118
3676
  const input = $('composer-input')
3119
3677
  input.addEventListener('input', () => autosize(input))
3120
3678
  input.addEventListener('keydown', (e) => {
3121
- if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); sendMessage() }
3679
+ if (e.key !== 'Enter' || e.isComposing) return
3680
+ if (isMobileDevice() && mobileEnterAction() !== 'send') return
3681
+ if (!e.shiftKey) { e.preventDefault(); sendMessage() }
3122
3682
  })
3123
3683
 
3124
3684
  // 审批
@@ -3136,6 +3696,18 @@ function bindUi() {
3136
3696
  // goal
3137
3697
  $('goal-close').addEventListener('click', () => $('modal-goal').classList.add('hidden'))
3138
3698
  $('goal-edit').addEventListener('click', submitGoalEdit)
3699
+ $('workspace-cancel').addEventListener('click', closeWorkspaceModal)
3700
+ $('workspace-create').addEventListener('click', createWorkspace)
3701
+ $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
3702
+ $('modal-workspace').addEventListener('click', (e) => { if (e.target === $('modal-workspace')) closeWorkspaceModal() })
3703
+ $('archive-cancel').addEventListener('click', closeArchiveConfirm)
3704
+ $('archive-confirm').addEventListener('click', confirmArchiveSession)
3705
+ $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
3706
+ $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
3707
+ $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
3708
+ $('modal-announcement').addEventListener('click', (e) => {
3709
+ if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
3710
+ })
3139
3711
  // 设置
3140
3712
  $('view-settings').addEventListener('click', (e) => {
3141
3713
  const group = e.target.closest('[data-settings-group]')
@@ -3165,15 +3737,23 @@ function bindUi() {
3165
3737
  $('host-desc').textContent = t('settings.hostDesc', { version: v.version, cwd: v.cwd, n: v.attachedSessions })
3166
3738
  }
3167
3739
  })
3740
+ $('btn-dsh-start')?.addEventListener('click', () => controlDsh('start'))
3741
+ $('btn-dsh-restart')?.addEventListener('click', () => controlDsh('restart'))
3168
3742
  $('btn-check-update').addEventListener('click', () => checkUpdate(false))
3169
3743
  $('btn-download-update').addEventListener('click', downloadUpdate)
3170
3744
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
3171
3745
  $('btn-reset').addEventListener('click', () => {
3172
3746
  if (!confirm(t('settings.confirmReset'))) return
3173
- LS.del('token'); LS.del('notify'); LS.del('server')
3747
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
3174
3748
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
3175
3749
  location.reload()
3176
3750
  })
3751
+ $('mobile-enter-action').value = mobileEnterAction()
3752
+ $('mobile-enter-action').addEventListener('change', (e) => {
3753
+ const action = e.target.value === 'send' ? 'send' : 'newline'
3754
+ LS.set('mobileEnterAction', action)
3755
+ toast(t(action === 'send' ? 'settings.mobileEnterSend' : 'settings.mobileEnterNewline'), 'ok')
3756
+ })
3177
3757
  $('opt-notify').checked = LS.get('notify', '0') === '1'
3178
3758
  $('opt-notify').addEventListener('change', async (e) => {
3179
3759
  if (e.target.checked) {
@@ -3191,10 +3771,18 @@ function bindUi() {
3191
3771
  }
3192
3772
  const ok = await ensureNotify()
3193
3773
  if (!ok) { e.target.checked = false; return toast(t('settings.notifyDenied')) }
3194
- await schedulePeakReminders()
3774
+ const started = await schedulePeakReminders()
3775
+ if (!started) {
3776
+ e.target.checked = false
3777
+ return toast(t('peakRemind.failed'), 'err')
3778
+ }
3195
3779
  toast(t('peakRemind.on'), 'ok')
3196
3780
  } else {
3197
- await cancelPeakReminders()
3781
+ const stopped = await cancelPeakReminders()
3782
+ if (!stopped) {
3783
+ e.target.checked = true
3784
+ return toast(t('peakRemind.failed'), 'err')
3785
+ }
3198
3786
  toast(t('peakRemind.off'), 'ok')
3199
3787
  }
3200
3788
  LS.set('peakRemind', e.target.checked ? '1' : '0')
@@ -3240,6 +3828,7 @@ function bindUi() {
3240
3828
 
3241
3829
  // 文件页
3242
3830
  $('fs-up').addEventListener('click', fsUp)
3831
+ $('fs-new-workspace').addEventListener('click', openWorkspaceModal)
3243
3832
  $('fs-refresh').addEventListener('click', () => { toast(t('common.refreshing')); loadFs() })
3244
3833
  $('fs-upload-btn').addEventListener('click', () => $('fs-file-input').click())
3245
3834
  $('fs-file-input').addEventListener('change', (e) => {
@@ -3295,7 +3884,7 @@ async function boot() {
3295
3884
  bindNativeLinks()
3296
3885
  applyNativeInsets()
3297
3886
  updateConn()
3298
- loadLocalVersion()
3887
+ await loadLocalVersion()
3299
3888
  if (!state.token) {
3300
3889
  showView('view-settings')
3301
3890
  $('token-desc').textContent = t('token.notSetHint')
@@ -3306,9 +3895,13 @@ async function boot() {
3306
3895
  await refreshAll()
3307
3896
  const host = await safeRpc('host.describe', {}, '')
3308
3897
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
3309
- // 启动后自动检查一次更新(静默)
3310
- setTimeout(() => checkUpdate(true), 4000)
3898
+ loadDshControl()
3311
3899
  }
3900
+ // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
3901
+ setTimeout(async () => {
3902
+ const shown = await checkAnnouncements()
3903
+ if (!shown && state.token) checkUpdate(true)
3904
+ }, 4000)
3312
3905
  renderPending()
3313
3906
  }
3314
3907