dsh-remote-plugin 0.6.6 → 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
@@ -52,6 +52,7 @@ const state = {
52
52
  hostInfo: null,
53
53
  localVersion: '',
54
54
  updateInfo: null,
55
+ announcement: null,
55
56
  approvals: [], // 待处理审批
56
57
  questions: [], // 待处理提问
57
58
  queues: {}, // sessionId -> queue items
@@ -62,7 +63,12 @@ const state = {
62
63
  pollSeq: { mux: 0, host: 0 },
63
64
  refreshTimer: null,
64
65
  fs: { path: null, initial: null, loaded: false, upload: null },
65
- 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: {}
66
72
  }
67
73
 
68
74
  const $ = (id) => document.getElementById(id)
@@ -1090,6 +1096,7 @@ async function refreshSessions() {
1090
1096
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1091
1097
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1092
1098
  renderSessions()
1099
+ refreshWorkbench()
1093
1100
  }
1094
1101
 
1095
1102
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
@@ -1123,6 +1130,96 @@ function updatePendingBadge() {
1123
1130
  if (pending) $('nav-pending').textContent = pending
1124
1131
  }
1125
1132
 
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}
1219
+ </div>`
1220
+ }).join('')
1221
+ }
1222
+
1126
1223
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1127
1224
  function sessionWorkspaceLabel(s) {
1128
1225
  const cwd = sessionCwd(s)
@@ -1149,48 +1246,66 @@ function sortedSessions() {
1149
1246
  }
1150
1247
  function renderSessions() {
1151
1248
  const list = $('session-list')
1152
- const items = sortedSessions()
1153
- let lastWorkspace = null
1154
- const rows = []
1155
- for (const s of items) {
1156
- const workspace = sessionWorkspaceLabel(s)
1157
- const workspaceName = workspaceDisplayName(workspace)
1158
- if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1159
- rows.push(`<div class="session-group-label" title="${esc(workspace)}"><span class="session-group-icon" aria-hidden="true">⌂</span><span class="session-group-name">${esc(workspaceName)}</span></div>`)
1160
- lastWorkspace = workspace
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>`)
1161
1292
  }
1162
- const title = titleOf(s)
1163
- const goal = goalOf(s)
1164
- const pending = (state.approvals.some(a => a.sessionId === s.sessionId) || state.questions.some(q => q.sessionId === s.sessionId)) ? 'pending' : ''
1165
- const queueN = (state.queues[s.sessionId] || []).filter(i => i.placement === 'queued').length
1166
- const dots = []
1167
- if (s.running) dots.push('running')
1168
- if (pending) dots.push('pending')
1169
- const badge = goal ? `<span class="sc-badge ${goal.phase === 'active' ? 'goal-active' : ''}">${esc(t('sessions.goalBadge', { phase: goal.phase || '?' }))}</span>` : ''
1170
- const queueBadge = queueN ? `<span class="sc-badge">${esc(t('sessions.queueBadge', { n: queueN }))}</span>` : ''
1171
- rows.push(`<div class="session-card ${state.current === s.sessionId ? 'current' : ''}" data-id="${esc(s.sessionId)}">
1172
- <div class="sc-title">${esc(title)}</div>
1173
- <div class="sc-meta">
1174
- <span class="sc-dot ${dots.join(' ')}"></span>
1175
- <span>${fmtTime(s.updatedAt)}</span>
1176
- ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1177
- ${badge}${queueBadge}
1178
- </div>
1179
- <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1180
- <span class="sc-arrow">›</span>
1181
- </div>`)
1293
+ return rows.join('')
1182
1294
  }
1183
- list.innerHTML = rows.join('')
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>`
1184
1299
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1185
1300
  const sort = $('session-sort')
1186
1301
  if (sort) sort.value = state.sessionSort
1187
- $('home-empty').classList.toggle('hidden', items.length > 0)
1302
+ $('home-empty').classList.toggle('hidden', visible.length > 0)
1188
1303
  const running = state.sessions.filter(s => s.running).length
1189
1304
  const pending = state.approvals.length + state.questions.length
1190
1305
  $('stat-strip').innerHTML = `
1191
1306
  <div class="stat running"><div class="v">${running}</div><div class="k">${t('sessions.statRunning')}</div></div>
1192
1307
  <div class="stat pending"><div class="v">${pending}</div><div class="k">${t('sessions.statPending')}</div></div>
1193
- <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>`
1194
1309
  updatePendingBadge()
1195
1310
  }
1196
1311
 
@@ -1223,7 +1338,7 @@ function bindNativeBack() {
1223
1338
  try {
1224
1339
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1225
1340
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1226
- 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 } // 先关弹窗
1227
1342
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
1228
1343
  if (!$('view-files').classList.contains('hidden')) { // 文件页 → 上级目录 → 主页
1229
1344
  if (state.fs.path && state.fs.initial && state.fs.path !== state.fs.initial) { fsUp(); return }
@@ -1752,6 +1867,7 @@ async function sendMessage() {
1752
1867
  function hideComposerMenu() {
1753
1868
  $('composer-menu').classList.add('hidden')
1754
1869
  $('btn-plus').classList.remove('active')
1870
+ $('permission-submenu')?.classList.add('hidden')
1755
1871
  }
1756
1872
 
1757
1873
  function toggleComposerMenu() {
@@ -1762,6 +1878,13 @@ function toggleComposerMenu() {
1762
1878
  if (show && !state.models.loaded && !state.models.loading) loadSessionModels()
1763
1879
  }
1764
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
+
1765
1888
  async function loadSessionModels() {
1766
1889
  if (!state.current || state.models.loading) return
1767
1890
  state.models.loading = true
@@ -1876,6 +1999,85 @@ async function newSession() {
1876
1999
  openSession(v.sessionId)
1877
2000
  }
1878
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
+
1879
2081
  /* ---------------- 待办 ---------------- */
1880
2082
  function renderPending() {
1881
2083
  const list = $('pending-list')
@@ -2537,6 +2739,105 @@ async function loadLocalVersion() {
2537
2739
  $('update-desc').textContent = state.localVersion ? t('update.currentV', { version: state.localVersion }) : t('update.noVersion')
2538
2740
  }
2539
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
+
2540
2841
  /* ---------------- 更新内容弹窗 ---------------- */
2541
2842
  const NOTES_KEY = 'seenNotesVersion'
2542
2843
  let notesVersion = ''
@@ -3220,6 +3521,7 @@ function bindUi() {
3220
3521
  renderUpdateExpandBtn()
3221
3522
  renderServers()
3222
3523
  renderSessions()
3524
+ renderWorkbench()
3223
3525
  renderPending(); renderQueue(); renderJobs()
3224
3526
  updateConn()
3225
3527
  if (state.current) { renderSessionTitle(); renderSessionSub(); renderSessionCards(); renderHistory(true) }
@@ -3247,9 +3549,53 @@ function bindUi() {
3247
3549
  b.addEventListener('click', () => showView(b.dataset.view)))
3248
3550
  // 会话列表点击
3249
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
+ }
3250
3569
  const card = e.target.closest('[data-id]')
3251
3570
  if (card) openSession(card.dataset.id)
3252
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
+ })
3253
3599
  $('btn-back').addEventListener('click', closeSession)
3254
3600
  $('btn-stats').addEventListener('click', () => { renderSessionCards(); $('modal-stats').classList.remove('hidden') })
3255
3601
  $('stats-close').addEventListener('click', () => $('modal-stats').classList.add('hidden'))
@@ -3292,6 +3638,12 @@ function bindUi() {
3292
3638
  $('composer-menu').addEventListener('click', async (e) => {
3293
3639
  const chip = e.target.closest('[data-cmd]')
3294
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')
3295
3647
  const input = $('composer-input')
3296
3648
  input.value = chip.dataset.cmd + ' '
3297
3649
  input.focus()
@@ -3299,6 +3651,15 @@ function bindUi() {
3299
3651
  hideComposerMenu()
3300
3652
  return
3301
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
+ }
3302
3663
  const preset = e.target.closest('[data-preset]')
3303
3664
  if (preset) {
3304
3665
  const found = readPresets().find(x => x.id === preset.dataset.preset)
@@ -3315,7 +3676,9 @@ function bindUi() {
3315
3676
  const input = $('composer-input')
3316
3677
  input.addEventListener('input', () => autosize(input))
3317
3678
  input.addEventListener('keydown', (e) => {
3318
- 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() }
3319
3682
  })
3320
3683
 
3321
3684
  // 审批
@@ -3337,6 +3700,14 @@ function bindUi() {
3337
3700
  $('workspace-create').addEventListener('click', createWorkspace)
3338
3701
  $('workspace-name').addEventListener('keydown', (e) => { if (e.key === 'Enter') createWorkspace() })
3339
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
+ })
3340
3711
  // 设置
3341
3712
  $('view-settings').addEventListener('click', (e) => {
3342
3713
  const group = e.target.closest('[data-settings-group]')
@@ -3373,10 +3744,16 @@ function bindUi() {
3373
3744
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
3374
3745
  $('btn-reset').addEventListener('click', () => {
3375
3746
  if (!confirm(t('settings.confirmReset'))) return
3376
- LS.del('token'); LS.del('notify'); LS.del('server')
3747
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction')
3377
3748
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
3378
3749
  location.reload()
3379
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
+ })
3380
3757
  $('opt-notify').checked = LS.get('notify', '0') === '1'
3381
3758
  $('opt-notify').addEventListener('change', async (e) => {
3382
3759
  if (e.target.checked) {
@@ -3507,7 +3884,7 @@ async function boot() {
3507
3884
  bindNativeLinks()
3508
3885
  applyNativeInsets()
3509
3886
  updateConn()
3510
- loadLocalVersion()
3887
+ await loadLocalVersion()
3511
3888
  if (!state.token) {
3512
3889
  showView('view-settings')
3513
3890
  $('token-desc').textContent = t('token.notSetHint')
@@ -3519,9 +3896,12 @@ async function boot() {
3519
3896
  const host = await safeRpc('host.describe', {}, '')
3520
3897
  if (host) { state.hostInfo = host; $('host-desc').textContent = t('settings.hostDesc', { version: host.version, cwd: host.cwd, n: host.attachedSessions }) }
3521
3898
  loadDshControl()
3522
- // 启动后自动检查一次更新(静默)
3523
- setTimeout(() => checkUpdate(true), 4000)
3524
3899
  }
3900
+ // 公告与更新共用当前服务器;公告优先,避免启动时两个弹窗重叠。
3901
+ setTimeout(async () => {
3902
+ const shown = await checkAnnouncements()
3903
+ if (!shown && state.token) checkUpdate(true)
3904
+ }, 4000)
3525
3905
  renderPending()
3526
3906
  }
3527
3907