dsh-remote-plugin 0.6.10-rc.1 → 0.6.11

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
@@ -58,6 +58,7 @@ const state = {
58
58
  selectingServer: false, // 防重入: 测速/切换中
59
59
  sessions: [],
60
60
  sessionSort: LS.get('sessionSort', 'time') === 'workspace' ? 'workspace' : 'time',
61
+ workspaceFilter: LS.get('workspaceFilterV1', ''),
61
62
  byId: new Map(),
62
63
  current: null, // 当前打开的 sessionId
63
64
  hostInfo: null,
@@ -77,7 +78,7 @@ const state = {
77
78
  streamMode: 'ws', // 'ws' | 'poll'
78
79
  pollSeq: { mux: 0, host: 0 },
79
80
  refreshTimer: null,
80
- fs: { path: null, initial: null, loaded: false, upload: null },
81
+ fs: { path: null, initial: null, loaded: false, upload: null, workspaceId: LS.get('fsWorkspaceIdV1', ''), preview: null },
81
82
  composerImages: [], // 当前草稿中的图片附件:只在发送成功后释放
82
83
  models: { loaded: false, loading: false, groups: [], current: null, failures: [] },
83
84
  wb: null,
@@ -101,6 +102,135 @@ function toast(text, kind = '') {
101
102
  toast._t = setTimeout(() => el.classList.add('hidden'), 3200)
102
103
  }
103
104
 
105
+ /* ---------------- 应用内选择抽屉 ---------------- */
106
+ const CUSTOM_SELECT_TITLES = {
107
+ 'session-workspace-filter': 'workspace.select',
108
+ 'session-sort': 'sessions.sortLabel',
109
+ 'fs-workspace': 'workspace.select',
110
+ 'mobile-enter-action': 'settings.mobileEnterTitle',
111
+ 'bg-interval': 'settings.bgIntervalTitle',
112
+ 'new-session-workspace': 'workspace.select'
113
+ }
114
+ let customSelectCurrent = null
115
+
116
+ function syncCustomSelect(select) {
117
+ const trigger = select?._customSelectTrigger
118
+ if (!trigger) return
119
+ const option = select.selectedOptions?.[0] || select.options?.[select.selectedIndex]
120
+ const label = trigger.querySelector('.custom-select-trigger-label')
121
+ if (label) label.textContent = option?.textContent?.trim() || t('select.choose')
122
+ trigger.disabled = !!select.disabled
123
+ trigger.setAttribute('aria-expanded', customSelectCurrent?.select === select ? 'true' : 'false')
124
+ }
125
+
126
+ function closeCustomSelect({ restoreFocus = true } = {}) {
127
+ if (!$('custom-select-sheet')) return
128
+ const previous = customSelectCurrent
129
+ customSelectCurrent = null
130
+ $('custom-select-backdrop').classList.add('hidden')
131
+ $('custom-select-sheet').classList.add('hidden')
132
+ if (previous?.select) syncCustomSelect(previous.select)
133
+ if (restoreFocus) previous?.trigger?.focus?.({ preventScroll: true })
134
+ }
135
+
136
+ function customSelectOptionCopy(option) {
137
+ const copy = document.createElement('span')
138
+ copy.className = 'custom-select-option-copy'
139
+ const raw = option.textContent?.trim() || ''
140
+ const separator = raw.indexOf(' — ')
141
+ const name = document.createElement('span')
142
+ name.className = 'custom-select-option-name'
143
+ name.textContent = separator >= 0 ? raw.slice(0, separator) : raw
144
+ copy.appendChild(name)
145
+ if (separator >= 0) {
146
+ const path = document.createElement('span')
147
+ path.className = 'custom-select-option-path'
148
+ path.textContent = raw.slice(separator + 3)
149
+ copy.appendChild(path)
150
+ }
151
+ return copy
152
+ }
153
+
154
+ function openCustomSelect(select) {
155
+ const trigger = select?._customSelectTrigger
156
+ if (!select || !trigger || select.disabled) return
157
+ closeFeedbackSheet()
158
+ syncCustomSelect(select)
159
+ customSelectCurrent = { select, trigger }
160
+ const titleKey = CUSTOM_SELECT_TITLES[select.id]
161
+ $('custom-select-title').textContent = t(titleKey || 'select.choose')
162
+ const options = $('custom-select-options')
163
+ options.replaceChildren()
164
+ let selectedButton = null
165
+ Array.from(select.options).forEach(option => {
166
+ const button = document.createElement('button')
167
+ button.type = 'button'
168
+ button.className = 'custom-select-option' + (option.selected ? ' current' : '')
169
+ button.setAttribute('role', 'option')
170
+ button.setAttribute('aria-selected', option.selected ? 'true' : 'false')
171
+ button.disabled = option.disabled
172
+ button.appendChild(customSelectOptionCopy(option))
173
+ const check = document.createElement('span')
174
+ check.className = 'custom-select-option-check'
175
+ check.setAttribute('aria-hidden', 'true')
176
+ check.textContent = option.selected ? '✓' : ''
177
+ button.appendChild(check)
178
+ button.addEventListener('click', () => {
179
+ if (option.disabled) return
180
+ select.value = option.value
181
+ select.dispatchEvent(new Event('input', { bubbles: true }))
182
+ select.dispatchEvent(new Event('change', { bubbles: true }))
183
+ syncCustomSelect(select)
184
+ closeCustomSelect()
185
+ })
186
+ options.appendChild(button)
187
+ if (option.selected) selectedButton = button
188
+ })
189
+ $('custom-select-backdrop').classList.remove('hidden')
190
+ $('custom-select-sheet').classList.remove('hidden')
191
+ syncCustomSelect(select)
192
+ setTimeout(() => (selectedButton || options.querySelector('button:not(:disabled)'))?.focus(), 30)
193
+ }
194
+
195
+ function enhanceCustomSelect(select) {
196
+ if (!select || select._customSelectTrigger || select.multiple || Number(select.size) > 1) return
197
+ select.classList.add('custom-select-native')
198
+ select.tabIndex = -1
199
+ select.setAttribute('aria-hidden', 'true')
200
+ const trigger = document.createElement('button')
201
+ trigger.type = 'button'
202
+ trigger.id = `${select.id}-trigger`
203
+ trigger.className = 'custom-select-trigger'
204
+ if (select.classList.contains('workspace-select')) trigger.classList.add('workspace-select-trigger')
205
+ if (select.classList.contains('full')) trigger.classList.add('full')
206
+ if (select.classList.contains('session-sort')) trigger.classList.add('session-sort-trigger')
207
+ if (select.classList.contains('setting-select')) trigger.classList.add('setting-select-trigger')
208
+ trigger.setAttribute('aria-haspopup', 'listbox')
209
+ trigger.setAttribute('aria-expanded', 'false')
210
+ const label = document.createElement('span')
211
+ label.className = 'custom-select-trigger-label'
212
+ trigger.appendChild(label)
213
+ trigger.addEventListener('click', () => openCustomSelect(select))
214
+ select.insertAdjacentElement('afterend', trigger)
215
+ select._customSelectTrigger = trigger
216
+ const associatedLabel = document.querySelector(`label[for="${CSS.escape(select.id)}"]`)
217
+ if (associatedLabel) associatedLabel.htmlFor = trigger.id
218
+ select.addEventListener('change', () => syncCustomSelect(select))
219
+ select._customSelectObserver = new MutationObserver(() => syncCustomSelect(select))
220
+ select._customSelectObserver.observe(select, { childList: true, subtree: true, attributes: true, attributeFilter: ['disabled', 'selected'] })
221
+ syncCustomSelect(select)
222
+ }
223
+
224
+ function initCustomSelects() {
225
+ document.querySelectorAll('select').forEach(enhanceCustomSelect)
226
+ $('custom-select-backdrop').addEventListener('click', () => closeCustomSelect())
227
+ $('custom-select-close').addEventListener('click', () => closeCustomSelect())
228
+ $('custom-select-cancel').addEventListener('click', () => closeCustomSelect())
229
+ document.addEventListener('keydown', (event) => {
230
+ if (event.key === 'Escape' && customSelectCurrent) closeCustomSelect()
231
+ })
232
+ }
233
+
104
234
  /* ---------------- 反馈 ---------------- */
105
235
  const FEEDBACK_LINKS = {
106
236
  githubIssues: 'https://github.com/Blank-not-black/dsh-Remote/issues',
@@ -151,7 +281,8 @@ async function submitFeedback() {
151
281
  const btn = $('fb-submit')
152
282
  btn.disabled = true
153
283
  try {
154
- const base = (state.server || '').replace(/\/+$/, '')
284
+ const base = updateBase()
285
+ if (!base) throw new Error(t('feedback.networkError'))
155
286
  const res = await fetch(base + '/feedback', {
156
287
  method: 'POST',
157
288
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
@@ -1244,6 +1375,58 @@ function wbJoin(root, name) {
1244
1375
  function workbenchRoot() {
1245
1376
  return state.wb?.bound && state.wb.path ? state.wb.path : ''
1246
1377
  }
1378
+ const WORKSPACE_UNGROUPED = '__ungrouped__'
1379
+ function workspaceItems() {
1380
+ return (state.wbProjects || []).filter(w => w && typeof w.workspaceId === 'string' && w.workspaceId && typeof w.path === 'string' && w.path)
1381
+ }
1382
+ function workspaceById(workspaceId) {
1383
+ return workspaceItems().find(w => w.workspaceId === workspaceId) || null
1384
+ }
1385
+ function workspaceForSession(session) {
1386
+ if (!session) return null
1387
+ const byMembership = workspaceItems().find(w => Array.isArray(w.sessionIds) && w.sessionIds.includes(session.sessionId))
1388
+ if (byMembership) return byMembership
1389
+ const cwdKey = wbPathKey(sessionCwd(session))
1390
+ return cwdKey ? workspaceItems().find(w => wbPathKey(w.path) === cwdKey) || null : null
1391
+ }
1392
+ function workspaceName(workspace) {
1393
+ return String(workspace?.title || wbBaseName(workspace?.path) || workspace?.path || '').trim()
1394
+ }
1395
+ function workspaceOptionLabel(workspace) {
1396
+ const name = workspaceName(workspace)
1397
+ return workspace.path && workspace.path !== name ? `${name} — ${workspace.path}` : name
1398
+ }
1399
+ function workspaceOptionsHtml({ all = false, ungrouped = false, root = false, selected = '' } = {}) {
1400
+ const rows = []
1401
+ if (all) rows.push({ id: '', label: t('workspace.all') })
1402
+ if (root) rows.push({ id: '', label: t('fs.root') })
1403
+ for (const workspace of workspaceItems()) rows.push({ id: workspace.workspaceId, label: workspaceOptionLabel(workspace) })
1404
+ if (ungrouped) rows.push({ id: WORKSPACE_UNGROUPED, label: t('workspace.ungrouped') })
1405
+ return rows.map(row => `<option value="${esc(row.id)}"${row.id === selected ? ' selected' : ''}>${esc(row.label)}</option>`).join('')
1406
+ }
1407
+ function renderWorkspaceNavigation() {
1408
+ const items = workspaceItems()
1409
+ if (state.workspaceFilter !== WORKSPACE_UNGROUPED && state.workspaceFilter && !workspaceById(state.workspaceFilter)) {
1410
+ state.workspaceFilter = ''
1411
+ LS.del('workspaceFilterV1')
1412
+ }
1413
+ const sessionSelect = $('session-workspace-filter')
1414
+ if (sessionSelect) sessionSelect.innerHTML = workspaceOptionsHtml({ all: true, ungrouped: true, selected: state.workspaceFilter })
1415
+ const selectedWorkspace = workspaceById(state.workspaceFilter)
1416
+ const pathBox = $('session-workspace-path')
1417
+ if (pathBox) pathBox.textContent = selectedWorkspace?.path || (state.workspaceFilter === WORKSPACE_UNGROUPED ? t('workspace.ungrouped') : t('workspace.allPath'))
1418
+ const filesButton = $('session-workspace-files')
1419
+ if (filesButton) filesButton.disabled = !selectedWorkspace
1420
+
1421
+ if (state.fs.workspaceId && !workspaceById(state.fs.workspaceId)) {
1422
+ state.fs.workspaceId = ''
1423
+ LS.del('fsWorkspaceIdV1')
1424
+ }
1425
+ const fsSelect = $('fs-workspace')
1426
+ if (fsSelect) fsSelect.innerHTML = workspaceOptionsHtml({ root: true, selected: state.fs.workspaceId })
1427
+ if ($('modal-new-session') && !$('modal-new-session').classList.contains('hidden')) renderNewSessionWorkspace()
1428
+ return items
1429
+ }
1247
1430
  async function refreshWorkbench() {
1248
1431
  if (!state.token) return
1249
1432
  try {
@@ -1271,7 +1454,9 @@ async function refreshWorkbench() {
1271
1454
  const listData = await listRes.json().catch(() => ({}))
1272
1455
  if (Array.isArray(listData.entries)) {
1273
1456
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
1274
- state.wbProjects = state.wbProjects.filter(w => diskDirs.has(wbPathKey(w.path)))
1457
+ // 工作台只管理自己根目录下的项目;DSH 中位于其他路径的工作区必须保留,
1458
+ // 否则手机的工作区选择器会因绑定了工作台而丢失条目。
1459
+ state.wbProjects = state.wbProjects.filter(w => !wbStrictInside(w.path, state.wb.path) || diskDirs.has(wbPathKey(w.path)))
1275
1460
  const have = new Set(state.wbProjects.map(w => wbPathKey(w.path)))
1276
1461
  for (const entry of listData.entries) {
1277
1462
  if (entry.type !== 'dir') continue
@@ -1286,6 +1471,7 @@ async function refreshWorkbench() {
1286
1471
  }
1287
1472
  } catch {}
1288
1473
  }
1474
+ renderWorkspaceNavigation()
1289
1475
  renderWorkbench()
1290
1476
  renderSessions()
1291
1477
  }
@@ -1338,8 +1524,12 @@ function renderWorkbench() {
1338
1524
 
1339
1525
  function sessionCwd(s) { return typeof s?.cwd === 'string' ? s.cwd.trim() : '' }
1340
1526
  function sessionWorkspaceLabel(s) {
1341
- const cwd = sessionCwd(s)
1342
- return cwd || t('sessions.workspaceUnknown')
1527
+ const workspace = workspaceForSession(s)
1528
+ return workspace?.path || sessionCwd(s) || t('sessions.workspaceUnknown')
1529
+ }
1530
+ function sessionWorkspaceName(s) {
1531
+ const workspace = workspaceForSession(s)
1532
+ return workspace ? workspaceName(workspace) : workspaceDisplayName(sessionWorkspaceLabel(s))
1343
1533
  }
1344
1534
  function workspaceDisplayName(label) {
1345
1535
  const value = String(label || '').trim()
@@ -1363,14 +1553,12 @@ function sortedSessions() {
1363
1553
  function renderSessions() {
1364
1554
  const list = $('session-list')
1365
1555
  const allItems = sortedSessions()
1366
- const wbIds = new Set()
1367
- if (state.wb?.bound) for (const w of state.wbProjects) for (const id of (w.sessionIds || [])) wbIds.add(id)
1368
- const root = workbenchRoot()
1369
1556
  const archivedSet = new Set(state.wbArchived || [])
1370
1557
  const visible = allItems.filter(s => {
1371
- if (!state.wb?.bound) return true
1372
- if (archivedSet.has(s.sessionId)) return true
1373
- return !(wbIds.has(s.sessionId) || wbStrictInside(s.cwd, root))
1558
+ if (!state.workspaceFilter) return true
1559
+ const workspace = workspaceForSession(s)
1560
+ if (state.workspaceFilter === WORKSPACE_UNGROUPED) return !workspace
1561
+ return workspace?.workspaceId === state.workspaceFilter
1374
1562
  })
1375
1563
  const archived = visible.filter(s => archivedSet.has(s.sessionId))
1376
1564
  const main = visible.filter(s => !archivedSet.has(s.sessionId))
@@ -1380,9 +1568,9 @@ function renderSessions() {
1380
1568
  const rows = []
1381
1569
  for (const s of items) {
1382
1570
  const workspace = sessionWorkspaceLabel(s)
1383
- const workspaceName = workspaceDisplayName(workspace)
1571
+ const workspaceTitle = sessionWorkspaceName(s)
1384
1572
  if (state.sessionSort === 'workspace' && workspace !== lastWorkspace) {
1385
- 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>`)
1573
+ 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(workspaceTitle)}</span></div>`)
1386
1574
  lastWorkspace = workspace
1387
1575
  }
1388
1576
  const title = titleOf(s)
@@ -1404,7 +1592,7 @@ function renderSessions() {
1404
1592
  ${s.running ? '<span>' + t('sessions.running') + '</span>' : ''}
1405
1593
  ${badge}${queueBadge}
1406
1594
  </div>
1407
- <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceName)}</div>
1595
+ <div class="sc-workspace" title="${esc(workspace)}">⌂ ${esc(workspaceTitle)}</div>
1408
1596
  <span class="sc-arrow">›</span>
1409
1597
  </div>
1410
1598
  ${archiveButton}
@@ -1414,11 +1602,10 @@ function renderSessions() {
1414
1602
  }
1415
1603
  const divider = archived.length ? `<button class="archived-toggle" type="button" data-archived-toggle>${esc(showArchived ? t('wb.archivedShown') : t('wb.archivedHidden'))}</button>` : ''
1416
1604
  const rows = renderItems(main) + divider + (showArchived ? renderItems(archived) : '')
1417
- const hiddenByWorkbench = allItems.length - visible.length
1418
- list.innerHTML = rows || `<div class="empty">${esc(hiddenByWorkbench ? t('wb.flatHidden', { n: hiddenByWorkbench }) : t('home.empty'))}</div>`
1605
+ list.innerHTML = rows || `<div class="empty">${esc(t('home.empty'))}</div>`
1419
1606
  list.classList.toggle('workspace-sorted', state.sessionSort === 'workspace')
1420
1607
  const sort = $('session-sort')
1421
- if (sort) sort.value = state.sessionSort
1608
+ if (sort) { sort.value = state.sessionSort; syncCustomSelect(sort) }
1422
1609
  $('home-empty').classList.toggle('hidden', visible.length > 0)
1423
1610
  const running = state.sessions.filter(s => s.running).length
1424
1611
  const pending = state.approvals.length + state.questions.length
@@ -1460,6 +1647,7 @@ function bindNativeBack() {
1460
1647
  try {
1461
1648
  CAP.Plugins?.App?.addListener?.('backButton', () => {
1462
1649
  if ($('composer-wrap')?.classList.contains('fs')) { setComposerFullscreen(false); return }
1650
+ if (customSelectCurrent) { closeCustomSelect(); return }
1463
1651
  const openModal = [...document.querySelectorAll('.modal')].find(m => !m.classList.contains('hidden'))
1464
1652
  if (openModal) { if (openModal.id === 'modal-notes') closeNotesModal(); else if (openModal.id === 'modal-archive') closeArchiveConfirm(); else openModal.classList.add('hidden'); return } // 先关弹窗
1465
1653
  if (document.body.classList.contains('in-session')) { closeSession(); return } // 会话页 → 回主页
@@ -1641,7 +1829,10 @@ function insertLiveEvent(event) {
1641
1829
  h.renderStart = Math.max(0, h.renderEnd - 200)
1642
1830
  renderHistory(false, 'bottom')
1643
1831
  } else {
1644
- renderHistory(false, 'keep')
1832
+ // 图片异步撑高历史区后,用户可能瞬间不再满足 nearBottom。新回复仍须
1833
+ // 扩展可见窗口,但保持当前阅读位置,不能等到重新进入会话才出现。
1834
+ h.renderEnd = h.visible.length
1835
+ renderHistory(false, 'fixed')
1645
1836
  }
1646
1837
  scheduleHistoryCacheSave()
1647
1838
  }
@@ -1684,6 +1875,7 @@ function renderHistory(reset, mode = 'bottom') {
1684
1875
  box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('')
1685
1876
  if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
1686
1877
  else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
1878
+ else if (mode === 'fixed') box.scrollTop = oldTop
1687
1879
  updateRail()
1688
1880
  }
1689
1881
 
@@ -1856,8 +2048,11 @@ function statsHtml(s) {
1856
2048
  return html || '<div class="empty">' + t('stats.empty') + '</div>'
1857
2049
  }
1858
2050
 
2051
+ let sessionCardsRenderGeneration = 0
1859
2052
  async function renderSessionCards() {
1860
- const s = state.byId.get(state.current)
2053
+ const renderGeneration = ++sessionCardsRenderGeneration
2054
+ const sessionId = state.current
2055
+ const s = state.byId.get(sessionId)
1861
2056
  const box = $('session-cards')
1862
2057
  const statsBox = $('stats-body')
1863
2058
  if (!s) { box.innerHTML = ''; if (statsBox) statsBox.innerHTML = ''; return }
@@ -1888,7 +2083,8 @@ async function renderSessionCards() {
1888
2083
  btn.addEventListener('click', () => goalAction(btn.dataset.goal)))
1889
2084
 
1890
2085
  // 子代理
1891
- const sub = await safeRpc('subagent.list', { parentSessionId: state.current })
2086
+ const sub = await safeRpc('subagent.list', { parentSessionId: sessionId })
2087
+ if (renderGeneration !== sessionCardsRenderGeneration || state.current !== sessionId) return
1892
2088
  if (sub?.entries?.length) {
1893
2089
  const rows = sub.entries.map(e => {
1894
2090
  if (e.kind === 'diagnostic') return `<div class="card-row"><span class="k">${t('subagent.diagnostic')}</span><span class="v">${esc(e.reason)}</span></div>`
@@ -2224,19 +2420,41 @@ async function cancelSession() {
2224
2420
  }
2225
2421
 
2226
2422
  async function newSession() {
2227
- let payload = {}
2228
- // DSH 的 host.describe 返回当前工作目录。每次创建前短暂刷新一次,
2229
- // 避免用户在桌面端切换工作区后,手机仍沿用启动时的旧 cwd。
2230
- try {
2231
- const host = await rpc('host.describe', {}, 5000)
2232
- const cwd = typeof host?.cwd === 'string' ? host.cwd.trim() : ''
2233
- if (cwd) {
2234
- state.hostInfo = host
2235
- payload = { cwd }
2236
- }
2237
- } catch {}
2238
- const v = await safeRpc('session.create', payload, t('home.createFailed'))
2423
+ if (!state.token) { showView('view-settings'); return }
2424
+ if (!workspaceItems().length) await refreshWorkbench()
2425
+ const preferred = workspaceById(state.workspaceFilter)?.workspaceId || LS.get('lastNewSessionWorkspaceV1', '')
2426
+ renderNewSessionWorkspace(preferred)
2427
+ $('modal-new-session').classList.remove('hidden')
2428
+ }
2429
+ function renderNewSessionWorkspace(preferredId = '') {
2430
+ const items = workspaceItems()
2431
+ const select = $('new-session-workspace')
2432
+ if (!select) return
2433
+ const current = workspaceById(preferredId || select.value)?.workspaceId || items[0]?.workspaceId || ''
2434
+ select.innerHTML = workspaceOptionsHtml({ selected: current })
2435
+ select.disabled = !items.length
2436
+ const workspace = workspaceById(select.value)
2437
+ $('new-session-workspace-path').textContent = workspace?.path || ''
2438
+ $('new-session-empty').classList.toggle('hidden', !!items.length)
2439
+ $('new-session-create').disabled = !workspace
2440
+ }
2441
+ function closeNewSessionModal() {
2442
+ $('modal-new-session').classList.add('hidden')
2443
+ }
2444
+ async function createSessionInWorkspace() {
2445
+ const workspaceId = $('new-session-workspace').value
2446
+ if (!workspaceById(workspaceId)) return toast(t('newSession.chooseWorkspace'), 'err')
2447
+ const button = $('new-session-create')
2448
+ button.disabled = true
2449
+ const v = await safeRpc('session.create', { workspaceId: workspaceId }, t('home.createFailed'))
2450
+ button.disabled = false
2239
2451
  if (!v?.sessionId) return
2452
+ state.workspaceFilter = workspaceId
2453
+ state.fs.workspaceId = workspaceId
2454
+ LS.set('workspaceFilterV1', workspaceId)
2455
+ LS.set('fsWorkspaceIdV1', workspaceId)
2456
+ LS.set('lastNewSessionWorkspaceV1', workspaceId)
2457
+ closeNewSessionModal()
2240
2458
  toast(t('home.created'), 'ok')
2241
2459
  await refreshSessions()
2242
2460
  openSession(v.sessionId)
@@ -2326,7 +2544,9 @@ function renderOverview() {
2326
2544
  const ring = $('overview-pulse-ring')
2327
2545
  if (!ring) return
2328
2546
  const checks = {
2329
- gateway: !!state.token && !!state.server,
2547
+ // 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
2548
+ // 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
2549
+ gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
2330
2550
  dsh: !!state.hostInfo,
2331
2551
  mux: !!state.streamsOk?.mux,
2332
2552
  host: !!state.streamsOk?.host
@@ -2544,6 +2764,32 @@ function fsParent(p) {
2544
2764
  return clean.slice(0, idx)
2545
2765
  }
2546
2766
 
2767
+ const FS_PREVIEW_EXTENSIONS = new Set([
2768
+ '.txt', '.md', '.markdown', '.log', '.json', '.jsonl', '.js', '.mjs', '.cjs', '.jsx',
2769
+ '.ts', '.tsx', '.py', '.css', '.html', '.htm', '.xml', '.yaml', '.yml', '.toml',
2770
+ '.ini', '.conf', '.env', '.sh', '.bash', '.zsh', '.fish', '.sql', '.java', '.kt',
2771
+ '.kts', '.go', '.rs', '.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.rb', '.vue',
2772
+ '.svelte', '.gradle', '.properties', '.gitignore', '.dockerfile'
2773
+ ])
2774
+ function fsPreviewExtension(name) {
2775
+ const lower = String(name || '').toLowerCase()
2776
+ if (lower === 'dockerfile') return '.dockerfile'
2777
+ const dot = lower.lastIndexOf('.')
2778
+ return dot >= 0 ? lower.slice(dot) : ''
2779
+ }
2780
+ function fsCanPreview(name, size) {
2781
+ return Number(size) <= 1024 * 1024 && FS_PREVIEW_EXTENSIONS.has(fsPreviewExtension(name))
2782
+ }
2783
+ function openWorkspaceFiles(workspaceId) {
2784
+ const workspace = workspaceById(workspaceId)
2785
+ if (!workspace) return
2786
+ state.fs.workspaceId = workspace.workspaceId
2787
+ state.fs.loaded = false
2788
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2789
+ renderWorkspaceNavigation()
2790
+ showView('view-files')
2791
+ }
2792
+
2547
2793
  async function openWorkspaceModal() {
2548
2794
  if (!state.token) { toast(t('fs.noTokenToast'), 'err'); showView('view-settings'); return }
2549
2795
  if (!state.fs.path) await loadFs(null, { silent: true })
@@ -2571,7 +2817,19 @@ async function createWorkspace() {
2571
2817
  }
2572
2818
  closeWorkspaceModal()
2573
2819
  await loadFs(parent || null, { silent: true })
2574
- const v = await safeRpc('session.create', { cwd: data.path }, t('home.createFailed'))
2820
+ let workspace = null
2821
+ try {
2822
+ const created = await rpc('workspace.create', { path: data.path })
2823
+ workspace = created?.workspace || null
2824
+ } catch {}
2825
+ const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2826
+ const v = await safeRpc('session.create', sessionPayload, t('home.createFailed'))
2827
+ if (workspace?.workspaceId) {
2828
+ state.workspaceFilter = workspace.workspaceId
2829
+ state.fs.workspaceId = workspace.workspaceId
2830
+ LS.set('workspaceFilterV1', workspace.workspaceId)
2831
+ LS.set('fsWorkspaceIdV1', workspace.workspaceId)
2832
+ }
2575
2833
  await refreshSessions()
2576
2834
  if (v?.sessionId) {
2577
2835
  toast(t('workspace.created'), 'ok')
@@ -2638,17 +2896,18 @@ function renderFs(data) {
2638
2896
  }
2639
2897
  list.innerHTML = data.entries.map(e => {
2640
2898
  const isDir = e.type === 'dir'
2641
- return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}">
2899
+ const preview = !isDir && fsCanPreview(e.name, e.size)
2900
+ return `<div class="fs-row" data-name="${esc(e.name)}" data-type="${esc(e.type)}" data-size="${Number(e.size) || 0}">
2642
2901
  <span class="fs-ico">${fsIconSvg(isDir)}</span>
2643
2902
  <span class="fs-meta">
2644
2903
  <span class="fs-name">${esc(e.name)}</span>
2645
2904
  <span class="fs-sub">${isDir ? t('fs.dir') : fmtSize(e.size)} · ${fmtFullTime(e.mtimeMs)}</span>
2646
2905
  </span>
2647
- <span class="fs-arrow">${isDir ? '›' : '↓'}</span>
2906
+ <span class="fs-arrow">${isDir || preview ? '›' : '↓'}</span>
2648
2907
  </div>`
2649
2908
  }).join('')
2650
2909
  list.querySelectorAll('.fs-row').forEach(row =>
2651
- row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type)))
2910
+ row.addEventListener('click', () => fsOpenEntry(row.dataset.name, row.dataset.type, Number(row.dataset.size))))
2652
2911
  }
2653
2912
 
2654
2913
  function fsIconSvg(isDir) {
@@ -2657,15 +2916,20 @@ function fsIconSvg(isDir) {
2657
2916
  : '<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>'
2658
2917
  }
2659
2918
 
2660
- function fsOpenEntry(name, type) {
2919
+ function fsOpenEntry(name, type, size = 0) {
2661
2920
  if (!name) return
2662
2921
  const p = fsJoin(state.fs.path, name)
2663
2922
  if (type === 'dir') return loadFs(p)
2923
+ if (fsCanPreview(name, size)) return openFsPreview(p, name)
2664
2924
  downloadFsFile(name)
2665
2925
  }
2666
2926
 
2667
2927
  function downloadFsFile(name) {
2668
2928
  const p = fsJoin(state.fs.path, name)
2929
+ downloadFsPath(p, name)
2930
+ }
2931
+
2932
+ function downloadFsPath(p, name) {
2669
2933
  const url = fsApiUrl('/file', { path: p })
2670
2934
  if (CAP?.isNativePlatform?.()) {
2671
2935
  if (window.NativeFile?.downloadToDownloads) {
@@ -2691,6 +2955,54 @@ function downloadFsFile(name) {
2691
2955
  a.remove()
2692
2956
  }
2693
2957
 
2958
+ let fsPreviewGeneration = 0
2959
+ async function openFsPreview(pathValue, name) {
2960
+ const generation = ++fsPreviewGeneration
2961
+ state.fs.preview = { path: pathValue, name, extension: fsPreviewExtension(name), content: '' }
2962
+ $('file-preview-title').textContent = name
2963
+ $('file-preview-path').textContent = pathValue
2964
+ $('file-preview-loading').textContent = t('fs.previewLoading')
2965
+ $('file-preview-loading').classList.remove('hidden')
2966
+ $('file-preview-source').classList.add('hidden')
2967
+ $('file-preview-rendered').classList.add('hidden')
2968
+ $('file-preview-tabs').classList.add('hidden')
2969
+ $('modal-file-preview').classList.remove('hidden')
2970
+ try {
2971
+ const res = await fetch(fsApiUrl('/preview', { path: pathValue }), { headers: fsHeaders() })
2972
+ if (res.status === 401) { closeFsPreview(); fsAuthError(401); return }
2973
+ const data = await res.json().catch(() => ({}))
2974
+ if (generation !== fsPreviewGeneration) return
2975
+ if (!res.ok) {
2976
+ const message = data.error === 'preview-too-large' ? t('fs.previewTooLarge')
2977
+ : (data.error === 'preview-unsupported' || data.error === 'preview-binary') ? t('fs.previewUnsupported')
2978
+ : t('fs.previewFailed', { msg: data.error || ('HTTP ' + res.status) })
2979
+ throw new Error(message)
2980
+ }
2981
+ state.fs.preview = data
2982
+ $('file-preview-loading').classList.add('hidden')
2983
+ $('file-preview-source').textContent = data.content || ''
2984
+ const markdown = data.extension === '.md' || data.extension === '.markdown'
2985
+ $('file-preview-tabs').classList.toggle('hidden', !markdown)
2986
+ if (markdown) $('file-preview-rendered').innerHTML = window.mdToHtml(data.content || '')
2987
+ showFsPreviewMode(markdown ? 'rendered' : 'source')
2988
+ } catch (e) {
2989
+ if (generation !== fsPreviewGeneration) return
2990
+ $('file-preview-loading').textContent = e.message || t('fs.previewFailed', { msg: t('fs.networkError') })
2991
+ }
2992
+ }
2993
+ function showFsPreviewMode(mode) {
2994
+ const rendered = mode === 'rendered' && !($('file-preview-tabs').classList.contains('hidden'))
2995
+ $('file-preview-source').classList.toggle('hidden', rendered)
2996
+ $('file-preview-rendered').classList.toggle('hidden', !rendered)
2997
+ $('file-preview-source-tab').classList.toggle('current', !rendered)
2998
+ $('file-preview-rendered-tab').classList.toggle('current', rendered)
2999
+ }
3000
+ function closeFsPreview() {
3001
+ fsPreviewGeneration++
3002
+ state.fs.preview = null
3003
+ $('modal-file-preview').classList.add('hidden')
3004
+ }
3005
+
2694
3006
  function showFsProgress(pct, loaded, total) {
2695
3007
  $('fs-progress').classList.remove('hidden')
2696
3008
  $('fs-progress-bar').style.width = Math.max(2, Math.min(100, pct)) + '%'
@@ -3084,6 +3396,7 @@ async function loadLocalVersion() {
3084
3396
  */
3085
3397
  const ANNOUNCEMENTS_KEY = 'seenAnnouncementsV1'
3086
3398
  const ANNOUNCEMENT_HISTORY_KEY = 'announcementHistoryV1'
3399
+ const ANNOUNCEMENT_VOTES_KEY = 'announcementVotesV1'
3087
3400
  function readSeenAnnouncements() {
3088
3401
  try {
3089
3402
  const value = JSON.parse(LS.get(ANNOUNCEMENTS_KEY, '{}'))
@@ -3101,6 +3414,32 @@ function markAnnouncementSeen(id) {
3101
3414
  }
3102
3415
  LS.set(ANNOUNCEMENTS_KEY, JSON.stringify(seen))
3103
3416
  }
3417
+ function readAnnouncementVotes() {
3418
+ try {
3419
+ const value = JSON.parse(LS.get(ANNOUNCEMENT_VOTES_KEY, '{}'))
3420
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
3421
+ } catch { return {} }
3422
+ }
3423
+ function announcementVoteKey(announcementId, pollId) {
3424
+ return `${announcementId}\u0000${pollId}`
3425
+ }
3426
+ function storeAnnouncementVote(announcementId, pollId, optionId) {
3427
+ const votes = readAnnouncementVotes()
3428
+ votes[announcementVoteKey(announcementId, pollId)] = { optionId, votedAt: Date.now() }
3429
+ const keys = Object.keys(votes)
3430
+ if (keys.length > 100) {
3431
+ keys.sort((a, b) => Number(votes[a]?.votedAt || 0) - Number(votes[b]?.votedAt || 0))
3432
+ for (const key of keys.slice(0, keys.length - 100)) delete votes[key]
3433
+ }
3434
+ LS.set(ANNOUNCEMENT_VOTES_KEY, JSON.stringify(votes))
3435
+ }
3436
+ function announcementVote(item) {
3437
+ if (!item?.poll?.id) return null
3438
+ const saved = readAnnouncementVotes()[announcementVoteKey(item.id, item.poll.id)]
3439
+ if (!saved?.optionId) return null
3440
+ const option = item.poll.options.find(entry => entry.id === saved.optionId)
3441
+ return option ? { ...saved, option } : null
3442
+ }
3104
3443
  function readAnnouncementHistory() {
3105
3444
  try {
3106
3445
  const value = JSON.parse(LS.get(ANNOUNCEMENT_HISTORY_KEY, '[]'))
@@ -3125,7 +3464,11 @@ function renderAnnouncementHistory() {
3125
3464
  box.innerHTML = list.map(item => {
3126
3465
  const date = Number(item.publishedAt) > 0 ? fmtFullTime(item.publishedAt) : t('announcement.noDate')
3127
3466
  const action = item.actionUrl ? `<a class="announcement-action" href="${esc(item.actionUrl)}" target="_blank" rel="noopener">${esc(item.actionText || t('announcement.open'))}</a>` : ''
3128
- return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}</div></details>`
3467
+ const vote = announcementVote(item)
3468
+ const pollAction = item.poll ? (vote
3469
+ ? `<div class="announcement-poll-status">${esc(t('announcement.voteThanks', { option: vote.option.label }))}</div>`
3470
+ : `<button class="mini-btn" type="button" data-announcement-poll="${esc(item.id)}">${esc(t('announcement.voteFromHistory'))}</button>`) : ''
3471
+ return `<details class="announcement-history-item"><summary><span>${esc(item.title)}</span><small>${esc(date)}</small></summary><div class="announcement-history-content">${esc(item.content).replace(/\r?\n/g, '<br>')}${action}${pollAction}</div></details>`
3129
3472
  }).join('')
3130
3473
  }
3131
3474
  function openAnnouncementHistory() {
@@ -3143,6 +3486,22 @@ function announcementVersionMatch(item) {
3143
3486
  if (max && cmpVersion(state.localVersion, max) > 0) return false
3144
3487
  return true
3145
3488
  }
3489
+ function normalizeAnnouncementPoll(value, announcementId) {
3490
+ if (!value || typeof value !== 'object') return null
3491
+ const id = String(value.id || announcementId || '').trim().slice(0, 120)
3492
+ const question = String(value.question || '').trim().slice(0, 300)
3493
+ if (!id || !question || !Array.isArray(value.options)) return null
3494
+ const seen = new Set()
3495
+ const options = []
3496
+ for (const raw of value.options.slice(0, 8)) {
3497
+ const optionId = String(raw?.id || '').trim().slice(0, 120)
3498
+ const label = String(raw?.label || '').trim().slice(0, 200)
3499
+ if (!optionId || !label || seen.has(optionId)) continue
3500
+ seen.add(optionId)
3501
+ options.push({ id: optionId, label, description: String(raw?.description || '').trim().slice(0, 500) })
3502
+ }
3503
+ return options.length >= 2 ? { id, question, options } : null
3504
+ }
3146
3505
  function normalizeAnnouncement(item, base) {
3147
3506
  if (!item || typeof item !== 'object') return null
3148
3507
  const id = String(item.id || '').trim().slice(0, 120)
@@ -3166,13 +3525,34 @@ function normalizeAnnouncement(item, base) {
3166
3525
  id, title, content, actionUrl,
3167
3526
  actionText: String(item.actionText || '').trim().slice(0, 80),
3168
3527
  publishedAt: Number.isFinite(startsAt) ? startsAt : 0,
3169
- force: item.force === true
3170
- }
3528
+ force: item.force === true,
3529
+ poll: normalizeAnnouncementPoll(item.poll, id)
3530
+ }
3531
+ }
3532
+ function renderAnnouncementPoll(item) {
3533
+ const panel = $('announcement-poll')
3534
+ const poll = item?.poll
3535
+ panel.classList.toggle('hidden', !poll)
3536
+ if (!poll) return
3537
+ const vote = announcementVote(item)
3538
+ $('announcement-poll-question').textContent = poll.question
3539
+ $('announcement-poll-options').innerHTML = poll.options.map(option => `
3540
+ <label class="announcement-poll-option">
3541
+ <input type="radio" name="announcement-poll-option" value="${esc(option.id)}"${vote?.optionId === option.id ? ' checked' : ''}${vote ? ' disabled' : ''}>
3542
+ <span><strong>${esc(option.label)}</strong>${option.description ? `<small>${esc(option.description)}</small>` : ''}</span>
3543
+ </label>`).join('')
3544
+ const status = $('announcement-poll-status')
3545
+ status.textContent = vote ? t('announcement.voteThanks', { option: vote.option.label }) : ''
3546
+ status.classList.toggle('hidden', !vote)
3547
+ const submit = $('announcement-poll-submit')
3548
+ submit.classList.toggle('hidden', !!vote)
3549
+ submit.disabled = true
3171
3550
  }
3172
3551
  function openAnnouncementModal(item) {
3173
3552
  state.announcement = item
3174
3553
  $('announcement-title').textContent = item.title
3175
3554
  $('announcement-content').innerHTML = esc(item.content).replace(/\r?\n/g, '<br>')
3555
+ renderAnnouncementPoll(item)
3176
3556
  const action = $('announcement-action')
3177
3557
  if (item.actionUrl) {
3178
3558
  action.href = item.actionUrl
@@ -3186,6 +3566,48 @@ function openAnnouncementModal(item) {
3186
3566
  $('announcement-later').classList.toggle('hidden', item.force)
3187
3567
  $('modal-announcement').classList.remove('hidden')
3188
3568
  }
3569
+ async function submitAnnouncementVote() {
3570
+ const item = state.announcement
3571
+ const poll = item?.poll
3572
+ const optionId = document.querySelector('input[name="announcement-poll-option"]:checked')?.value || ''
3573
+ const option = poll?.options.find(entry => entry.id === optionId)
3574
+ if (!poll || !option) return toast(t('announcement.voteChoose'), 'err')
3575
+ const button = $('announcement-poll-submit')
3576
+ button.disabled = true
3577
+ try {
3578
+ const base = updateBase()
3579
+ if (!base) throw new Error(t('announcement.voteNetworkError'))
3580
+ const res = await fetch(base + '/feedback', {
3581
+ method: 'POST',
3582
+ headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
3583
+ body: JSON.stringify({
3584
+ type: 'poll',
3585
+ message: `Poll ${poll.id}: ${option.id}`,
3586
+ announcementId: item.id,
3587
+ pollId: poll.id,
3588
+ optionId: option.id,
3589
+ appVersion: state.localVersion
3590
+ })
3591
+ })
3592
+ const data = await res.json().catch(() => ({}))
3593
+ if (res.ok && data.ok) {
3594
+ storeAnnouncementVote(item.id, poll.id, option.id)
3595
+ markAnnouncementSeen(item.id)
3596
+ renderAnnouncementPoll(item)
3597
+ renderAnnouncementHistory()
3598
+ toast(t('announcement.voteThanks', { option: option.label }), 'ok')
3599
+ } else if (res.status === 429) {
3600
+ toast(t('announcement.voteAgainLater'), 'err')
3601
+ button.disabled = false
3602
+ } else {
3603
+ toast(t('announcement.voteFailed', { msg: data.error || res.status }), 'err')
3604
+ button.disabled = false
3605
+ }
3606
+ } catch (e) {
3607
+ toast(t('announcement.voteFailed', { msg: e.message || t('announcement.voteNetworkError') }), 'err')
3608
+ button.disabled = false
3609
+ }
3610
+ }
3189
3611
  function closeAnnouncement(markSeen) {
3190
3612
  if (markSeen && state.announcement) markAnnouncementSeen(state.announcement.id)
3191
3613
  state.announcement = null
@@ -3492,6 +3914,7 @@ function applyBgConfigFromNative() {
3492
3914
  const v = String(cfg.intervalMin ?? 1)
3493
3915
  const opts = Array.from($('bg-interval')?.options || [])
3494
3916
  if (opts.some(o => o.value === v)) $('bg-interval').value = v
3917
+ syncCustomSelect($('bg-interval'))
3495
3918
  if ($('opt-task-done')) $('opt-task-done').checked = cfg.notifyTaskDone !== false
3496
3919
  $('bg-auth-status')?.classList.toggle('hidden', !cfg.loginExpired)
3497
3920
  } catch {}
@@ -3584,9 +4007,24 @@ function deletePreset(id) {
3584
4007
 
3585
4008
  /* ---------------- 峰谷计费提醒(前台服务进程内定时, 绕开 MIUI 后台限制) ---------------- */
3586
4009
  function peakRemindOn() { return LS.get('peakRemind', '0') === '1' }
4010
+ const LEGACY_PEAK_NOTIFICATION_IDS = [8801, 8802, 8803, 8804]
4011
+
4012
+ async function cancelLegacyPeakNotifications() {
4013
+ if (!CAP?.isNativePlatform?.()) return false
4014
+ const notifications = CAP.Plugins?.LocalNotifications
4015
+ if (!notifications?.cancel) return true
4016
+ try {
4017
+ await notifications.cancel({ notifications: LEGACY_PEAK_NOTIFICATION_IDS.map(id => ({ id })) })
4018
+ return true
4019
+ } catch (error) {
4020
+ console.warn('Failed to cancel legacy peak reminders', error)
4021
+ return false
4022
+ }
4023
+ }
3587
4024
 
3588
- async function schedulePeakReminders() {
4025
+ async function schedulePeakReminders({ legacyCleaned = false } = {}) {
3589
4026
  if (!CAP?.isNativePlatform?.()) return false
4027
+ if (!legacyCleaned && !await cancelLegacyPeakNotifications()) return false
3590
4028
  const b = bgBridge()
3591
4029
  if (!b?.startPeakReminder) return false
3592
4030
  try {
@@ -3599,10 +4037,19 @@ async function cancelPeakReminders() {
3599
4037
  const b = bgBridge()
3600
4038
  if (!b?.stopPeakReminder) return false
3601
4039
  try {
3602
- return b.stopPeakReminder() !== false
4040
+ const stopped = b.stopPeakReminder() !== false
4041
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4042
+ return stopped && legacyCleaned
3603
4043
  } catch { return false }
3604
4044
  }
3605
4045
 
4046
+ async function restorePeakReminders() {
4047
+ if (!CAP?.isNativePlatform?.()) return
4048
+ // 旧版使用 LocalNotifications 每日调度;无论当前开关状态都先清理,防止与前台服务重复提醒。
4049
+ const legacyCleaned = await cancelLegacyPeakNotifications()
4050
+ if (peakRemindOn() && legacyCleaned) await schedulePeakReminders({ legacyCleaned: true })
4051
+ }
4052
+
3606
4053
  /* ---------------- 视图切换 ---------------- */
3607
4054
  function showView(id) {
3608
4055
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-stats', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
@@ -3610,7 +4057,10 @@ function showView(id) {
3610
4057
  document.body.classList.toggle('in-session', id === 'view-session')
3611
4058
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
3612
4059
  window.scrollTo(0, 0)
3613
- if (id === 'view-files' && !state.fs.loaded) loadFs(null, { silent: true })
4060
+ if (id === 'view-files' && !state.fs.loaded) {
4061
+ const workspace = workspaceById(state.fs.workspaceId)
4062
+ loadFs(workspace?.path || null, { silent: true, resetRoot: true })
4063
+ }
3614
4064
  if (id === 'view-stats') loadStats()
3615
4065
  if (id === 'view-settings') showSettingsHome()
3616
4066
  }
@@ -3633,6 +4083,9 @@ function showSettingsPage(name) {
3633
4083
 
3634
4084
  function updateConn() {
3635
4085
  const el = $('conn-badge')
4086
+ // mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
4087
+ // 通道只更新顶栏、总览却永久停留在 3/4。
4088
+ renderOverview()
3636
4089
  const cur = state.servers.find(s => s.url === state.server)
3637
4090
  const ms = state.serverLatency[state.server]
3638
4091
  const curGroup = cur ? cur.group : state.activeGroup
@@ -4051,6 +4504,8 @@ function bindUi() {
4051
4504
  if (newButton) {
4052
4505
  safeRpc('session.create', { workspaceId: newButton.dataset.wbNew }, t('home.createFailed')).then(async v => {
4053
4506
  if (!v?.sessionId) return
4507
+ state.workspaceFilter = newButton.dataset.wbNew
4508
+ LS.set('workspaceFilterV1', state.workspaceFilter)
4054
4509
  toast(t('home.created'), 'ok')
4055
4510
  await refreshSessions()
4056
4511
  openSession(v.sessionId)
@@ -4097,12 +4552,41 @@ function bindUi() {
4097
4552
  if (e.key === 'Escape' && !$('feedback-sheet').classList.contains('hidden')) { closeFeedbackSheet(); $('btn-feedback').focus() }
4098
4553
  })
4099
4554
  $('btn-new-session').addEventListener('click', newSession)
4555
+ $('session-workspace-filter').addEventListener('change', (e) => {
4556
+ state.workspaceFilter = e.target.value
4557
+ if (state.workspaceFilter) LS.set('workspaceFilterV1', state.workspaceFilter)
4558
+ else LS.del('workspaceFilterV1')
4559
+ renderWorkspaceNavigation()
4560
+ renderSessions()
4561
+ })
4562
+ $('session-workspace-files').addEventListener('click', () => openWorkspaceFiles(state.workspaceFilter))
4563
+ $('new-session-workspace').addEventListener('change', (e) => renderNewSessionWorkspace(e.target.value))
4564
+ $('new-session-cancel').addEventListener('click', closeNewSessionModal)
4565
+ $('new-session-create').addEventListener('click', createSessionInWorkspace)
4566
+ $('modal-new-session').addEventListener('click', (e) => { if (e.target === $('modal-new-session')) closeNewSessionModal() })
4100
4567
  $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
4101
4568
  $('session-sort')?.addEventListener('change', (e) => {
4102
4569
  state.sessionSort = e.target.value === 'workspace' ? 'workspace' : 'time'
4103
4570
  LS.set('sessionSort', state.sessionSort)
4104
4571
  renderSessions()
4105
4572
  })
4573
+ $('fs-workspace').addEventListener('change', (e) => {
4574
+ state.fs.workspaceId = e.target.value
4575
+ if (state.fs.workspaceId) LS.set('fsWorkspaceIdV1', state.fs.workspaceId)
4576
+ else LS.del('fsWorkspaceIdV1')
4577
+ state.fs.loaded = false
4578
+ const workspace = workspaceById(state.fs.workspaceId)
4579
+ loadFs(workspace?.path || null, { resetRoot: true })
4580
+ })
4581
+ $('file-preview-close').addEventListener('click', closeFsPreview)
4582
+ $('file-preview-done').addEventListener('click', closeFsPreview)
4583
+ $('file-preview-source-tab').addEventListener('click', () => showFsPreviewMode('source'))
4584
+ $('file-preview-rendered-tab').addEventListener('click', () => showFsPreviewMode('rendered'))
4585
+ $('file-preview-download').addEventListener('click', () => {
4586
+ const preview = state.fs.preview
4587
+ if (preview?.path && preview?.name) downloadFsPath(preview.path, preview.name)
4588
+ })
4589
+ $('modal-file-preview').addEventListener('click', (e) => { if (e.target === $('modal-file-preview')) closeFsPreview() })
4106
4590
  $('btn-cancel').addEventListener('click', cancelSession)
4107
4591
  $('btn-send').addEventListener('click', sendMessage)
4108
4592
  $('btn-fs-send').addEventListener('click', sendMessage)
@@ -4200,10 +4684,18 @@ function bindUi() {
4200
4684
  $('modal-archive').addEventListener('click', (e) => { if (e.target === $('modal-archive')) closeArchiveConfirm() })
4201
4685
  $('announcement-later').addEventListener('click', () => closeAnnouncement(false))
4202
4686
  $('announcement-confirm').addEventListener('click', () => closeAnnouncement(true))
4687
+ $('announcement-poll-options').addEventListener('change', () => { $('announcement-poll-submit').disabled = false })
4688
+ $('announcement-poll-submit').addEventListener('click', submitAnnouncementVote)
4203
4689
  $('modal-announcement').addEventListener('click', (e) => {
4204
4690
  if (e.target === $('modal-announcement') && !state.announcement?.force) closeAnnouncement(false)
4205
4691
  })
4206
4692
  $('announcement-history-close').addEventListener('click', closeAnnouncementHistory)
4693
+ $('announcement-history-list').addEventListener('click', (e) => {
4694
+ const button = e.target.closest('[data-announcement-poll]')
4695
+ if (!button) return
4696
+ const item = readAnnouncementHistory().find(entry => entry.id === button.dataset.announcementPoll)
4697
+ if (item?.poll) { closeAnnouncementHistory(); openAnnouncementModal(item) }
4698
+ })
4207
4699
  $('modal-announcement-history').addEventListener('click', (e) => {
4208
4700
  if (e.target === $('modal-announcement-history')) closeAnnouncementHistory()
4209
4701
  })
@@ -4243,7 +4735,7 @@ function bindUi() {
4243
4735
  $('btn-update-expand').addEventListener('click', toggleUpdateExpand)
4244
4736
  $('btn-reset').addEventListener('click', () => {
4245
4737
  if (!confirm(t('settings.confirmReset'))) return
4246
- LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY)
4738
+ LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
4247
4739
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
4248
4740
  location.reload()
4249
4741
  })
@@ -4288,8 +4780,8 @@ function bindUi() {
4288
4780
  })
4289
4781
  $('btn-test-notify').addEventListener('click', sendTestNotification)
4290
4782
  $('btn-announcement-history').addEventListener('click', openAnnouncementHistory)
4291
- // 已开启则启动时重新调度, 防止系统清理后丢失
4292
- if (peakRemindOn() && CAP?.isNativePlatform?.()) schedulePeakReminders()
4783
+ // 启动时先清理旧版 LocalNotifications,再按当前开关恢复前台提醒服务。
4784
+ restorePeakReminders()
4293
4785
  applyBgConfigFromNative()
4294
4786
  $('opt-bg-poll').addEventListener('change', async (e) => {
4295
4787
  if (e.target.checked) {
@@ -4342,6 +4834,8 @@ function bindUi() {
4342
4834
 
4343
4835
  bindRail()
4344
4836
 
4837
+ initCustomSelects()
4838
+
4345
4839
  // 向上翻历史 / 向下回最新
4346
4840
  $('history').addEventListener('scroll', () => {
4347
4841
  const box = $('history')