dsh-remote-plugin 0.7.2 → 0.7.3

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
@@ -27,7 +27,7 @@ const CLIENT_ID = (() => {
27
27
  })()
28
28
  function clientIdHeaders() { return CLIENT_ID ? { 'x-dsh-remote-client-id': CLIENT_ID } : {} }
29
29
 
30
- /* 离线缓存: 会话列表 + 每会话聊天记录。只在网络失败时兜底展示, 不会替代线上数据。 */
30
+ /* 按连接身份隔离:本地缓存先显示,远端历史在后台校验与合并。 */
31
31
  const CACHE = {
32
32
  get sessions() { return connectionCacheKey('sessions') },
33
33
  get history() { return connectionCacheKey('history') }
@@ -43,18 +43,6 @@ function cacheRead(key, d = null) {
43
43
  function cacheWrite(key, value) {
44
44
  try { LS.set(key, JSON.stringify(value)) } catch { LS.del(key) }
45
45
  }
46
- function readHistoryCache() { return cacheRead(CACHE.history, {}) || {} }
47
- function writeHistoryCache(cache) {
48
- try { LS.set(CACHE.history, JSON.stringify(cache)); return }
49
- catch {
50
- // localStorage 配额不足: 每会话只留最近 50 条再试一次
51
- try {
52
- for (const k of Object.keys(cache)) cache[k].events = (cache[k].events || []).slice(-50)
53
- LS.set(CACHE.history, JSON.stringify(cache))
54
- } catch { LS.del(CACHE.history) }
55
- }
56
- }
57
-
58
46
  const state = {
59
47
  token: '',
60
48
  wsTicket: { token: '', server: '', value: '', expiresAt: 0 },
@@ -128,11 +116,20 @@ for (const key of ['server', 'token']) {
128
116
  if (next === value) return
129
117
  value = next; connectionGeneration++
130
118
  state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
119
+ state.hostInfo = null
120
+ if(typeof closeStream==='function'&&typeof streamMeta!=='undefined'){closeStream('mux');closeStream('host')}
131
121
  state.current = null; state.history = emptyHistory()
132
122
  state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
133
123
  state.wb = null; state.wbProjects = []; state.wbArchived = []; state.workspaceFilter = ''; state.fs.workspaceId = ''; clearTimeout(scheduleHistoryCacheSave._t)
134
124
  window.DshPluginCenter?.close()
135
125
  window.DshInsights?.close()
126
+ if(typeof document!=='undefined') {
127
+ const identityGeneration=connectionGeneration
128
+ queueMicrotask(()=>{if(identityGeneration===connectionGeneration&&typeof startOverviewDetection==='function')startOverviewDetection(true,true)})
129
+ document.getElementById('history')?.replaceChildren?.()
130
+ const generation=connectionGeneration
131
+ queueMicrotask(()=>{if(generation!==connectionGeneration)return;if(typeof conversationView!=='undefined')conversationView?.reset();restoreCachedSessionList()})
132
+ }
136
133
  if (typeof document !== 'undefined') {
137
134
  document.getElementById('modal-workspace')?.classList.add('hidden')
138
135
  const generation = connectionGeneration
@@ -331,6 +328,9 @@ function openFeedbackModal() {
331
328
  $('fb-msg').value = ''
332
329
  $('fb-contact').value = ''
333
330
  $('fb-include-diagnostics').checked = false
331
+ $('fb-include-crashes').checked = false
332
+ $('fb-crash-details').open = false
333
+ void loadCrashPreview()
334
334
  $('modal-feedback').classList.remove('hidden')
335
335
  setTimeout(() => $('fb-msg').focus(), 50)
336
336
  }
@@ -343,11 +343,22 @@ function closeFeedbackSuccess() {
343
343
  $('modal-feedback-success').classList.add('hidden')
344
344
  $('btn-feedback')?.focus()
345
345
  }
346
+ async function loadCrashPreview() {
347
+ const connection=captureConnection(),preview=$('fb-crash-preview')
348
+ preview.textContent=t('feedback.crashLoading')
349
+ try {
350
+ const response=await fetch(updateBase()+'/crash-logs',{headers:{authorization:'Bearer '+connection.token},signal:AbortSignal.timeout(5000),cache:'no-store'})
351
+ if(!response.ok)throw new Error('unavailable')
352
+ const data=await response.json()
353
+ if(connection.valid())preview.textContent=JSON.stringify(data.upload,null,2)
354
+ }catch{if(connection.valid())preview.textContent=t('feedback.crashUnavailable')}
355
+ }
346
356
  async function submitFeedback() {
347
357
  const type = state.feedbackType || 'bug'
348
358
  const message = $('fb-msg').value.trim()
349
359
  const contact = $('fb-contact').value.trim()
350
360
  const includeDiagnostics = $('fb-include-diagnostics').checked
361
+ const includeCrashLogs = $('fb-include-crashes').checked
351
362
  if (!message) { toast(t('feedback.empty'), 'err'); return }
352
363
  if (message.length > 2000) { toast(t('feedback.tooLong'), 'err'); return }
353
364
  const btn = $('fb-submit')
@@ -358,7 +369,7 @@ async function submitFeedback() {
358
369
  const res = await fetch(base + '/feedback', {
359
370
  method: 'POST',
360
371
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
361
- body: JSON.stringify({ type, message, contact, appVersion: state.localVersion, includeDiagnostics })
372
+ body: JSON.stringify({ type, message, contact, appVersion: state.localVersion, includeDiagnostics, includeCrashLogs })
362
373
  })
363
374
  let json = {}
364
375
  try { json = await res.json() } catch {}
@@ -524,6 +535,7 @@ async function safeRpc(method, payload, errText) {
524
535
  }
525
536
 
526
537
  let hostDescribePromise = null
538
+ let hostDescribeConnection = null
527
539
  let hostDescribeRetryTimer = null
528
540
  let hostDescribeFailures = 0
529
541
 
@@ -539,12 +551,13 @@ function scheduleHostDescribeRetry() {
539
551
 
540
552
  async function refreshHostDescription({ notify = false } = {}) {
541
553
  if (!state.token) return null
542
- if (hostDescribePromise) return hostDescribePromise
543
- const server = state.server
544
- hostDescribePromise = (async () => {
554
+ if (hostDescribePromise && hostDescribeConnection?.valid()) return hostDescribePromise
555
+ const connection = captureConnection()
556
+ hostDescribeConnection = connection
557
+ const job = (async () => {
545
558
  try {
546
559
  const host = await rpc('host.describe', {}, 5000)
547
- if (server !== state.server) return null
560
+ if (!connection.valid()) return null
548
561
  state.hostInfo = host
549
562
  const health = activeGatewayHealth()
550
563
  if (health) health.upstreamReachable = true
@@ -556,7 +569,7 @@ async function refreshHostDescription({ notify = false } = {}) {
556
569
  renderOverview()
557
570
  return host
558
571
  } catch (error) {
559
- if (server !== state.server) return null
572
+ if (!connection.valid()) return null
560
573
  if (error.message === 'AUTH') authFailure()
561
574
  else {
562
575
  hostDescribeFailures++
@@ -565,11 +578,11 @@ async function refreshHostDescription({ notify = false } = {}) {
565
578
  }
566
579
  return null
567
580
  } finally {
568
- hostDescribePromise = null
569
- if (server !== state.server) scheduleHostDescribeRetry()
581
+ if (hostDescribePromise === job) { hostDescribePromise = null; hostDescribeConnection = null }
570
582
  }
571
583
  })()
572
- return hostDescribePromise
584
+ hostDescribePromise = job
585
+ return job
573
586
  }
574
587
 
575
588
  function authFailure() {
@@ -1210,12 +1223,12 @@ function openStreams() {
1210
1223
  function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1211
1224
  if (!state.token) return
1212
1225
  if (ticket === null) {
1213
- const token = state.token
1226
+ const token = state.token, connection=captureConnection()
1214
1227
  void getWsTicket().then((value) => {
1215
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
1228
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, value)
1216
1229
  }).catch(() => {
1217
1230
  // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
1218
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
1231
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, '')
1219
1232
  })
1220
1233
  return
1221
1234
  }
@@ -1420,13 +1433,13 @@ document.addEventListener('visibilitychange', () => {
1420
1433
  if (document.visibilityState === 'visible') {
1421
1434
  onResume()
1422
1435
  if (state.servers.length) selectFastestServer({ silent: true })
1423
- else if (state.token && (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) openStreams()
1436
+ else if (state.token) repairMissingStreams()
1424
1437
  }
1425
1438
  })
1426
1439
  window.addEventListener('pageshow', onResume)
1427
1440
  setInterval(() => {
1428
1441
  if (document.visibilityState === 'visible' && state.token) {
1429
- if (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN) openStreams()
1442
+ repairMissingStreams()
1430
1443
  }
1431
1444
  }, 15000)
1432
1445
  // 多服务器: 每 5 分钟重测一次延迟, 网络环境变化(离开 Wi-Fi / 挂上 Tailscale)时自动换线
@@ -1628,6 +1641,7 @@ async function refreshAll() {
1628
1641
  }
1629
1642
 
1630
1643
  async function refreshSessions() {
1644
+ if(typeof restoreCachedSessionList==='function')restoreCachedSessionList()
1631
1645
  const connection = captureConnection(), request = ++sessionsRequest
1632
1646
  const v = await safeRpc('session.list', {}, t('err.sessionList'))
1633
1647
  if (!connection.valid() || request !== sessionsRequest) return
@@ -1654,6 +1668,7 @@ async function refreshSessions() {
1654
1668
 
1655
1669
  function removeLocalSessionRecord(sessionId) {
1656
1670
  if (!sessionId) return
1671
+ void window.DshHistory.cacheRemove(historyCacheScope(),sessionId)
1657
1672
  state.sessions = state.sessions.filter(session => session?.sessionId !== sessionId)
1658
1673
  state.byId.delete(sessionId)
1659
1674
  state.pendingProjections.delete(sessionId)
@@ -1661,11 +1676,7 @@ function removeLocalSessionRecord(sessionId) {
1661
1676
  state.pendingPrompts.delete(sessionId)
1662
1677
  delete state.queues[sessionId]
1663
1678
  delete state.jobs[sessionId]
1664
- const historyCache = readHistoryCache()
1665
- if (Object.prototype.hasOwnProperty.call(historyCache, sessionId)) {
1666
- delete historyCache[sessionId]
1667
- writeHistoryCache(historyCache)
1668
- }
1679
+ LS.del(historyCacheScope()) // Also discard the obsolete monolithic cache.
1669
1680
  cacheWrite(CACHE.sessions, state.sessions.slice(0, 80))
1670
1681
  }
1671
1682
 
@@ -1749,7 +1760,11 @@ function short(id) { return '…' + String(id).slice(-8) }
1749
1760
  function isTopLevelSession(session) {
1750
1761
  return !!session && !session.parentSessionId && session.origin !== 'subagent'
1751
1762
  }
1752
- function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1763
+ // DSH marks sessions created via "new session" but never prompted as blank and hides
1764
+ // them in its own UI; keep the same semantics here so they do not occupy the list,
1765
+ // the workspace tree or the home stats.
1766
+ function isVisibleSession(session) { return isTopLevelSession(session) && !session?.blank }
1767
+ function topLevelSessions() { return state.sessions.filter(isVisibleSession) }
1753
1768
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1754
1769
  function isGoalTerminal(goal) {
1755
1770
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -2025,7 +2040,7 @@ function renderWorkbench() {
2025
2040
  const projectHtml = projects.map(w => {
2026
2041
  const id = String(w.workspaceId || '')
2027
2042
  const open = !!state.wbOpenProjects[id]
2028
- const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)))
2043
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isVisibleSession).filter(s => !archivedSet.has(s.sessionId)))
2029
2044
  const body = open ? `<div class="wb-sessions">${sessions.length ? sessions.map(s => `
2030
2045
  <div class="session-swipe" data-session-swipe data-id="${esc(s.sessionId)}">
2031
2046
  <button class="wb-session" type="button" data-wb-session="${esc(s.sessionId)}" data-motion-key="${esc(s.sessionId)}">
@@ -2196,9 +2211,9 @@ function renderSessions() {
2196
2211
 
2197
2212
  /* ---------------- 会话详情 ---------------- */
2198
2213
  const emptySessionCleanup = new Set()
2199
- async function archiveEmptySessionOnLeave(sessionId) {
2200
- const base = state.server
2201
- const protectedSession = () => state.server !== base || state.current !== sessionId
2214
+ async function archiveEmptySessionOnLeave(sessionId, leaving = false) {
2215
+ const base = state.server, connection=captureConnection()
2216
+ const protectedSession = () => !connection.valid() || state.server !== base || (!leaving && state.current !== sessionId)
2202
2217
  || !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
2203
2218
  || state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
2204
2219
  || (state.queues[sessionId] || []).length > 0
@@ -2219,10 +2234,11 @@ async function archiveEmptySessionOnLeave(sessionId) {
2219
2234
  }
2220
2235
 
2221
2236
  async function openSession(id) {
2222
- if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
2237
+ if (state.current && state.current !== id) void archiveEmptySessionOnLeave(state.current,true)
2223
2238
  state.current = id
2224
2239
  setSessionRecovery('loading')
2225
2240
  state.history = emptyHistory()
2241
+ const openingHistory=state.history, openingConnection=captureConnection()
2226
2242
  document.body.classList.add('in-session')
2227
2243
  showView('view-session')
2228
2244
  $('btn-rename-session').classList.remove('hidden')
@@ -2233,7 +2249,9 @@ async function openSession(id) {
2233
2249
  renderQueue()
2234
2250
  renderSessionPending()
2235
2251
  void refreshCompactionStatus(id)
2236
- restoreCachedHistory()
2252
+ getConversationView().reset()
2253
+ await restoreCachedHistory()
2254
+ if(state.current!==id||state.history!==openingHistory||!openingConnection.valid())return
2237
2255
  await loadHistory(true)
2238
2256
  renderSessionCards()
2239
2257
  refreshSessions()
@@ -2312,13 +2330,10 @@ function renderSessionTitle() {
2312
2330
  function renderSessionSub() {
2313
2331
  const s = state.byId.get(state.current)
2314
2332
  if (!s) { $('session-sub').textContent = ''; return }
2315
- const parts = [short(s.sessionId)]
2316
- if (s.cwd) parts.push(s.cwd)
2317
- if (s.running) parts.push(t('session.running'))
2318
- else if (s.error) parts.push(t('session.interrupted'))
2319
- const recovery = recoveryLabel()
2320
- if (recovery) parts.push(recovery)
2321
- $('session-sub').textContent = parts.join(' · ')
2333
+ const workspace = s.cwd || ''
2334
+ const name = workspace.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || workspace
2335
+ $('session-sub').textContent = '📁 ' + (name || t('session.noWorkspace')) + ' ›'
2336
+ $('session-sub').title = workspace || t('session.noWorkspace')
2322
2337
  }
2323
2338
 
2324
2339
  /** 顶栏状态: 运行中=蓝色流动渐变, 中断/出错=橙红渐变, 空闲=原样式 */
@@ -2330,7 +2345,8 @@ function updateSessionStatus() {
2330
2345
  const composerStatus = $('composer-status')
2331
2346
  const compact = activeCompaction()
2332
2347
  if (composerStatus) {
2333
- composerStatus.classList.toggle('hidden', !s?.running && !compact)
2348
+ const queued = (state.queues[state.current] || []).some(i => i.placement !== 'context')
2349
+ composerStatus.classList.toggle('hidden', !s?.running && !compact && !queued)
2334
2350
  composerStatus.classList.toggle('compacting', !!compact)
2335
2351
  }
2336
2352
  const composerText = $('composer-status-text')
@@ -2349,6 +2365,7 @@ function updateCancelBtn() {
2349
2365
  const s = state.byId.get(state.current)
2350
2366
  const running = s?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
2351
2367
  $('btn-cancel').classList.toggle('hidden', !running)
2368
+ $('composer-status')?.classList.toggle('hidden', !running && !activeCompaction())
2352
2369
  }
2353
2370
 
2354
2371
  const HISTORY_MAX_VISIBLE = 5000 // 已加载的可显示事件上限(消息/工具/状态, 不含 chunk)
@@ -2443,137 +2460,102 @@ function partialReasoningHtml() {
2443
2460
  .join('')
2444
2461
  }
2445
2462
 
2446
- function trimVisible() {
2447
- const h = state.history
2448
- if (h.visible.length <= HISTORY_MAX_VISIBLE) return
2449
- const drop = h.visible.splice(0, h.visible.length - HISTORY_MAX_VISIBLE)
2450
- for (const e of drop) h.seqs.delete(e.seq)
2451
- h.renderStart = Math.max(0, h.renderStart - drop.length)
2452
- h.renderEnd = Math.max(h.renderStart, h.renderEnd - drop.length)
2463
+
2464
+ function restoreCachedSessionList() {
2465
+ if(state.sessions.length)return
2466
+ try{const items=JSON.parse(LS.get(CACHE.sessions,''));if(!Array.isArray(items))return;state.sessions=items.filter(item=>typeof item?.sessionId==='string').slice(0,80);state.byId=new Map(state.sessions.map(item=>[item.sessionId,item]));renderSessions()}catch{}
2453
2467
  }
2454
2468
 
2455
- /* 聊天记录本地缓存: 每会话最多 250 条, 全局最多 10 个会话 */
2469
+ let conversationView = null
2470
+ function historyCacheScope() { return CACHE.history }
2471
+ function historySource(value) {
2472
+ state.history.source=value
2473
+ const el=$('history-source')
2474
+ if(el)el.textContent=t(({cache:'history.cached',syncing:'history.syncing',synced:'history.synced',saved:'history.saved',offline:'history.offline',saveFailed:'history.cacheFailed'})[value]||'history.synced')
2475
+ }
2476
+ function getConversationView() {
2477
+ if(!conversationView)conversationView=new window.DshHistory.HistoryView($('history'),{
2478
+ html:entry=>eventHtml(entry, {toolNames:state.history.toolNames}),
2479
+ older:()=>loadHistory(false),olderLabel:()=>t('history.earlier'),loadingLabel:()=>t('history.loadingEarlier'),live:partialReasoningHtml,
2480
+ changed:view=>{state.history.renderStart=view.start;state.history.renderEnd=view.end;$('btn-history-latest')?.classList.toggle('hidden',!view.reading&&view.end>=view.entries.length&&!state.history.tailEvicted);updateRail(view.domChanged)},
2481
+ scrolled:()=>{updateRail()}
2482
+ })
2483
+ return conversationView
2484
+ }
2456
2485
  function scheduleHistoryCacheSave() {
2457
2486
  clearTimeout(scheduleHistoryCacheSave._t)
2458
- const connection = captureConnection()
2459
- scheduleHistoryCacheSave._t = setTimeout(() => { if (connection.valid()) saveHistoryCache() }, 400)
2460
- }
2461
-
2462
- function saveHistoryCache() {
2463
- const id = state.current
2464
- if (!id || !state.history.visible.length) return
2465
- const s = state.byId.get(id)
2466
- const cache = readHistoryCache()
2467
- cache[id] = {
2468
- title: s ? titleOf(s) : '',
2469
- updatedAt: Date.now(),
2470
- events: state.history.visible.slice(-250).map(e => ({ seq: e.seq, event: e.event }))
2471
- }
2472
- const keys = Object.entries(cache)
2473
- .sort((a, b) => (b[1].updatedAt || 0) - (a[1].updatedAt || 0))
2474
- .slice(0, 10)
2475
- .map(([k]) => k)
2476
- const pruned = {}
2477
- for (const k of keys) pruned[k] = cache[k]
2478
- writeHistoryCache(pruned)
2479
- }
2480
-
2481
- /** 网关不可达时回填本地缓存的历史; 返回是否命中。 */
2482
- function restoreCachedHistory() {
2483
- const id = state.current
2484
- if (!id) return false
2485
- const cached = readHistoryCache()[id]
2486
- if (!cached?.events?.length) return false
2487
- if (cached.title) hydrateSessionProjections(id, { values: { title: cached.title }, asOfSeq: 0 })
2488
- const h = emptyHistory()
2489
- for (const e of cached.events) {
2490
- if (e?.seq == null) continue
2491
- h.seqs.add(e.seq)
2492
- h.visible.push(e)
2493
- }
2494
- h.visible.sort((a, b) => a.seq - b.seq)
2495
- h.loaded = true
2496
- state.history = h
2497
- $('history-hint').textContent = t('history.offlineCache', { n: h.visible.length })
2498
- renderHistory(true)
2487
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
2488
+ scheduleHistoryCacheSave._t=setTimeout(()=>{if(connection.valid()&&state.current===id&&state.history===history)void saveHistoryCache(scope,id,history)},500)
2489
+ }
2490
+ async function saveHistoryCache(scope=historyCacheScope(),id=state.current,history=state.history) {
2491
+ if(!id||!history.visible.length||history.tailEvicted)return
2492
+ const saved=await window.DshHistory.cacheSave(scope,id,{title:titleOf(state.byId.get(id)),events:history.visible.slice(-250),minSeq:history.visible.slice(-250)[0]?.seq??history.minSeq,hasMore:history.hasMore||history.visible.length>250,partialReasoning:[...history.partialReasoning.values()]})
2493
+ if(!saved&&state.current===id&&state.history===history&&scope===historyCacheScope())historySource('saveFailed')
2494
+ if(saved&&state.current===id&&state.history===history&&scope===historyCacheScope()&&['synced','saveFailed'].includes(history.source))historySource('saved')
2495
+ }
2496
+ async function restoreCachedHistory() {
2497
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
2498
+ if(!id)return false
2499
+ const cached=await window.DshHistory.cacheLoad(scope,id,legacyId=>window.DshHistory.decodeLegacy(LS.get(scope,''),legacyId))
2500
+ if(!connection.valid()||state.current!==id||state.history!==history||!cached?.events?.length)return false
2501
+ history.visible=cached.events.slice(-250).filter(entry=>entry?.seq!=null&&shouldShowEvent(entry.event?.type,entry.event)).sort((a,b)=>a.seq-b.seq)
2502
+ history.seqs=new Set(history.visible.map(entry=>entry.seq));history.loaded=true;history.cached=true;history.hasMore=cached.events.length>250||cached.hasMore!==false;history.minSeq=cached.events.length>250?(history.visible[0]?.seq??Infinity):Number.isFinite(cached.minSeq)?cached.minSeq:history.visible[0]?.seq??Infinity
2503
+ if(cached.title&&!hasSessionTitle(state.byId.get(id)))hydrateSessionProjections(id,{values:{title:cached.title},asOfSeq:0})
2504
+ applyReasoningBaseline(cached.partialReasoning||[]);historySource('cache');setSessionRecovery('cached');renderHistory(true)
2499
2505
  return true
2500
2506
  }
2501
-
2502
- async function loadHistory(reset) {
2503
- const connection = captureConnection()
2504
- const id = state.current
2505
- if (!id || state.history.loading) return
2506
- const history = state.history
2507
- const reasoningVersion = history.reasoningVersion || 0
2508
- history.loading = true
2509
- if (reset) setSessionRecovery('loading')
2510
- const moreBtn = $('history-more')
2511
- if (moreBtn) moreBtn.classList.add('hidden')
2512
- const payload = { sessionId: id, maxMessages: 60 }
2513
- if (!reset && state.history.minSeq !== Infinity) payload.beforeSeq = state.history.minSeq
2514
-
2507
+ function trimVisible(direction='tail') {
2508
+ const h=state.history,excess=h.visible.length-5000
2509
+ if(excess<=0)return
2510
+ const older=direction==='older'||conversationView?.reading
2511
+ const removed=older?h.visible.splice(5000):h.visible.splice(0,excess)
2512
+ for(const entry of removed)h.seqs.delete(entry.seq)
2513
+ if(older)h.tailEvicted=true
2514
+ else{h.minSeq=h.visible[0]?.seq??Infinity;h.headEvicted=true;h.hasMore=true}
2515
+ }
2516
+
2517
+ async function loadHistory(reset = true) {
2518
+ const connection=captureConnection(),id=state.current,history=state.history
2519
+ if(!id||history.loading||(!reset&&Date.now()<(history.retryAfter||0)))return
2520
+ const reasoningVersion=history.reasoningVersion||0,hadContent=history.visible.length>0
2521
+ history.loading=true
2522
+ if(reset){if(hadContent)historySource('syncing');else setSessionRecovery('loading')}
2523
+ const payload={sessionId:id,maxMessages:60}
2524
+ if(!reset&&Number.isFinite(history.minSeq))payload.beforeSeq=history.minSeq
2525
+ getConversationView().more.disabled=true
2526
+ getConversationView().more.textContent=getConversationView().options.loadingLabel()
2515
2527
  let v
2516
- try {
2517
- v = await rpc('session.history', payload)
2518
- } catch (e) {
2519
- if (!connection.valid() || state.current !== id || state.history !== history) return
2520
- history.loading = false
2521
- if (e.message === 'AUTH') { authFailure(); return }
2522
- if (restoreCachedHistory()) {
2523
- setSessionRecovery('cached', e.message)
2524
- toast(t('history.cacheFallback'), 'ok')
2525
- return
2526
- }
2527
- const msg = e.message || t('err.dshError')
2528
- setSessionRecovery('error', msg)
2529
- const box = $('history')
2530
- if (box && (reset || !state.history.visible.length)) {
2531
- box.innerHTML = `<div class="empty"><div>${esc(t('history.loadFailed', { msg }))}</div><button type="button" class="mini-btn" id="btn-history-retry" style="margin-top:10px">${esc(t('history.retry'))}</button></div>`
2532
- const retry = $('btn-history-retry')
2533
- if (retry) retry.addEventListener('click', () => loadHistory(true))
2534
- } else {
2535
- toast(t('history.loadFailed', { msg }), 'err')
2536
- }
2528
+ try{v=await rpc('session.history',payload)}catch(error){
2529
+ if(!connection.valid()||state.current!==id||state.history!==history)return
2530
+ history.loading=false;history.retryAfter=Date.now()+1500
2531
+ if(error.message==='AUTH'){authFailure();return}
2532
+ if(hadContent){historySource('offline');setSessionRecovery('cached',error.message);renderHistory(false,'fixed');return}
2533
+ setSessionRecovery('error',error.message)
2534
+ $('history').innerHTML='<div class="history-empty">'+esc(error.message)+'</div>'
2535
+ const retry=document.createElement('button');retry.type='button';retry.className='history-page-control';retry.textContent=t('history.retry');retry.addEventListener('click',()=>{void loadHistory(true)});$('history').append(retry)
2537
2536
  return
2538
2537
  }
2539
-
2540
- if (!connection.valid() || state.current !== id || state.history !== history) return
2541
- const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
2542
- hydrateSessionProjections(id, v.projections)
2543
- history.loaded = true
2544
- const incoming = v.events || []
2545
- let added = 0
2546
- if (reset) state.history.partialReasoning.clear()
2538
+ if(!connection.valid()||state.current!==id||state.history!==history)return
2539
+ const liveReasoning=(history.reasoningVersion||0)!==reasoningVersion?new Map(history.partialReasoning):null
2540
+ hydrateSessionProjections(id,v.projections);history.loaded=true
2541
+ const incoming=v.events||[],prepared=[]
2547
2542
  for (const entry of incoming) {
2548
- const ev = entry?.event
2549
- const seq = ev?.seq
2550
- if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
2543
+ const ev=entry?.event
2551
2544
  applyReasoningStreamEvent(ev)
2552
- if (seq == null || state.history.seqs.has(seq)) continue
2553
- if (!shouldShowEvent(ev.type, ev)) continue // chunk 与非用户上下文不保留
2554
- state.history.seqs.add(seq)
2555
- state.history.visible.push({ seq, event: ev, view: entry.view })
2556
- added++
2557
- }
2558
- // 向前翻页游标 = 本页最旧的 raw seq(即使它本身被过滤)
2559
- if (liveReasoning) history.partialReasoning = liveReasoning
2560
- else applyReasoningBaseline(v.partialReasoning)
2561
- const firstSeq = incoming[0]?.event?.seq
2562
- if (firstSeq != null) state.history.minSeq = Math.min(state.history.minSeq, firstSeq)
2563
- state.history.visible.sort((a, b) => a.seq - b.seq)
2564
- trimVisible()
2565
- state.history.hasMore = !!v.hasMore
2566
- history.loading = false
2567
- setSessionRecovery('ready')
2568
- renderSessionTitle(); renderSessionSub(); renderSessionCards()
2569
- try {
2570
- if (reset) renderHistory(true)
2571
- else if (added) renderHistory(false, 'keep')
2572
- } catch (e) {
2573
- console.error('renderHistory failed', e)
2574
- }
2575
- if (moreBtn) moreBtn.classList.toggle('hidden', !state.history.hasMore)
2576
- $('history-hint').textContent = state.history.visible.length ? t('history.count', { n: state.history.visible.length }) : ''
2545
+ if(ev?.type==='turn/start'||ev?.type==='turn/end')noteSessionTurnTime(id,ev)
2546
+ if(ev?.seq==null||!shouldShowEvent(ev.type,ev))continue
2547
+ prepared.push({seq:ev.seq,event:ev,view:entry.view})
2548
+ }
2549
+ const rawSeqs=incoming.map(entry=>entry?.event?.seq).filter(Number.isFinite),floor=rawSeqs.length?Math.min(...rawSeqs):Infinity
2550
+ if(reset){const retainedOlder=!!v.hasMore&&history.visible.some(entry=>entry.seq<floor);const ceiling=rawSeqs.length?Math.max(...rawSeqs)+1:Infinity;history.visible=rawSeqs.length?window.DshHistory.reconcile(history.visible,prepared,floor,!!v.hasMore,ceiling):[];history.minSeq=retainedOlder?Math.min(history.minSeq,floor):floor;history.tailEvicted=false}
2551
+ else{history.visible=window.DshHistory.reconcile(history.visible,prepared,floor,true,payload.beforeSeq);history.minSeq=Math.min(history.minSeq,floor)}
2552
+ history.seqs=new Set(history.visible.map(entry=>entry.seq));history.headEvicted=false;trimVisible(reset?'tail':'older')
2553
+ if(liveReasoning)history.partialReasoning=liveReasoning;else applyReasoningBaseline(v.partialReasoning)
2554
+ history.hasMore=(!!v.hasMore||(reset&&history.headEvicted)) && rawSeqs.length>0 && (reset || floor<payload.beforeSeq)
2555
+ history.loading=false;history.cached=false;historySource('synced');setSessionRecovery('ready')
2556
+ renderSessionTitle();renderSessionSub();renderSessionCards()
2557
+ renderHistory(!hadContent,reset?'fixed':'keep')
2558
+ $('history-hint').textContent=t('history.count',{n:history.visible.length})
2577
2559
  scheduleHistoryCacheSave()
2578
2560
  }
2579
2561
 
@@ -2593,18 +2575,7 @@ function insertLiveEvent(event) {
2593
2575
  h.visible.push({ seq, event })
2594
2576
  h.visible.sort((a, b) => a.seq - b.seq)
2595
2577
  trimVisible()
2596
- const box = $('history')
2597
- const nearBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 240
2598
- if (nearBottom) {
2599
- h.renderEnd = h.visible.length
2600
- h.renderStart = Math.max(0, h.renderEnd - 200)
2601
- renderHistory(false, 'bottom')
2602
- } else {
2603
- // 图片异步撑高历史区后,用户可能瞬间不再满足 nearBottom。新回复仍须
2604
- // 扩展可见窗口,但保持当前阅读位置,不能等到重新进入会话才出现。
2605
- h.renderEnd = h.visible.length
2606
- renderHistory(false, 'fixed')
2607
- }
2578
+ renderHistory(false, 'fixed')
2608
2579
  scheduleHistoryCacheSave()
2609
2580
  }
2610
2581
 
@@ -2617,74 +2588,33 @@ function filteredEntries() {
2617
2588
  return f
2618
2589
  }
2619
2590
 
2620
- function renderHistory(reset, mode = 'bottom') {
2621
- const box = $('history')
2622
- const h = state.history
2623
- const filtered = filteredEntries()
2624
- const len = filtered.length
2625
- const reasoningHtml = partialReasoningHtml()
2626
- if (!len && !reasoningHtml) {
2627
- box.innerHTML = '<div class="empty">' + t('history.empty') + '</div>'
2628
- h.renderStart = 0; h.renderEnd = 0
2629
- updateRail()
2630
- return
2631
- }
2632
- if (reset) {
2633
- h.renderEnd = len
2634
- h.renderStart = Math.max(0, len - 200)
2635
- }
2636
- const start = Math.min(h.renderStart, len)
2637
- const end = Math.min(h.renderEnd, len) || len
2638
- const oldH = box.scrollHeight
2639
- const oldTop = box.scrollTop
2640
- // callId → 工具名, 供 tool/result 折叠标题显示
2641
- const toolNames = new Map()
2642
- for (const e of state.history.visible) {
2643
- if (e.event?.type !== 'tool/call') continue
2644
- const d = e.event.data || {}
2645
- if (d.callId && d.name) toolNames.set(d.callId, d.name)
2646
- }
2647
- box.innerHTML = filtered.slice(start, end).map(e => eventHtml(e, { toolNames })).join('') + reasoningHtml
2648
- if (reset || mode === 'bottom') box.scrollTop = box.scrollHeight
2649
- else if (mode === 'keep') box.scrollTop = Math.max(0, oldTop + (box.scrollHeight - oldH))
2650
- else if (mode === 'fixed') box.scrollTop = oldTop
2651
- updateRail()
2591
+ function renderHistory(reset=false,mode='auto') {
2592
+ const filtered=filteredEntries(),toolNames=new Map()
2593
+ for(const entry of state.history.visible){const d=entry.event?.data;if(entry.event?.type==='tool/call'&&d?.callId&&d.name)toolNames.set(d.callId,d.name)}
2594
+ state.history.toolNames=toolNames
2595
+ $('history-hint').textContent=t('history.count',{n:state.history.visible.length})
2596
+ getConversationView().set(filtered,{reset,follow:mode==='bottom',live:partialReasoningHtml(),hasMore:state.history.hasMore,loading:state.history.loading,emptyLabel:t('history.empty')})
2652
2597
  }
2653
2598
 
2654
2599
  /* 右侧导航条: 用户发言节点 + 拖动快速定位 */
2655
- function updateRail() {
2656
- const box = $('history')
2657
- const thumb = $('rail-thumb')
2658
- const nodesBox = $('rail-nodes')
2659
- if (!box || !thumb || !nodesBox) return
2660
- const sh = box.scrollHeight
2661
- const ch = box.clientHeight
2662
- if (sh <= ch) {
2663
- thumb.style.display = 'none'
2664
- nodesBox.innerHTML = ''
2665
- return
2666
- }
2667
- thumb.style.display = ''
2668
- const trackH = Math.max(1, ch - 8)
2669
- const thumbH = Math.max(32, ch / sh * trackH)
2670
- const maxTop = trackH - thumbH
2671
- const ratio = box.scrollTop / Math.max(1, sh - ch)
2672
- thumb.style.height = thumbH + 'px'
2673
- thumb.style.top = (4 + ratio * maxTop) + 'px'
2674
-
2675
- const boxTop = box.getBoundingClientRect().top
2676
- const userNodes = [...box.querySelectorAll('.msg.user')]
2677
- nodesBox.innerHTML = userNodes.map(el => {
2678
- const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
2679
- const pos = Math.min(4 + trackH, 4 + off / Math.max(1, sh) * trackH)
2680
- return `<div class="rail-node" data-offset="${Math.round(off)}" style="top:${pos}px"></div>`
2681
- }).join('')
2682
- let activeIdx = -1
2683
- userNodes.forEach((el, i) => {
2684
- const off = el.getBoundingClientRect().top - boxTop + box.scrollTop
2685
- if (off <= box.scrollTop + 60) activeIdx = i
2686
- })
2687
- if (activeIdx >= 0) nodesBox.children[activeIdx]?.classList.add('active')
2600
+ let historyRailLayout=null
2601
+ function updateRail(rebuild=false) {
2602
+ const box=$('history'),thumb=$('rail-thumb'),nodesBox=$('rail-nodes')
2603
+ if(!box||!thumb||!nodesBox)return
2604
+ const sh=box.scrollHeight,ch=box.clientHeight
2605
+ if(sh<=ch){thumb.style.display='none';nodesBox.replaceChildren();historyRailLayout=null;return}
2606
+ thumb.style.display=''
2607
+ const trackH=Math.max(1,ch-8),thumbH=Math.max(32,ch/sh*trackH),ratio=box.scrollTop/Math.max(1,sh-ch)
2608
+ thumb.style.height=thumbH+'px';thumb.style.top=(4+ratio*(trackH-thumbH))+'px'
2609
+ if(rebuild||!historyRailLayout||historyRailLayout.height!==sh||historyRailLayout.width!==box.clientWidth){
2610
+ const top=box.getBoundingClientRect().top
2611
+ const allOffsets=[...box.querySelectorAll('.msg.user')].map(el=>el.getBoundingClientRect().top-top+box.scrollTop)
2612
+ const count=Math.min(24,allOffsets.length),offsets=Array.from({length:count},(_,i)=>allOffsets[Math.round(i*(allOffsets.length-1)/Math.max(1,count-1))])
2613
+ nodesBox.innerHTML=offsets.map(off=>'<div class="rail-node" data-offset="'+Math.round(off)+'" style="top:'+Math.min(4+trackH,4+off/Math.max(1,sh)*trackH)+'px"></div>').join('')
2614
+ historyRailLayout={height:sh,width:box.clientWidth,offsets}
2615
+ }
2616
+ let active=-1;historyRailLayout.offsets.forEach((offset,i)=>{if(offset<=box.scrollTop+60)active=i})
2617
+ for(let i=0;i<nodesBox.children.length;i++)nodesBox.children[i].classList.toggle('active',i===active)
2688
2618
  }
2689
2619
 
2690
2620
  function bindRail() {
@@ -2695,6 +2625,8 @@ function bindRail() {
2695
2625
  nodesBox.addEventListener('click', (e) => {
2696
2626
  const node = e.target.closest('.rail-node')
2697
2627
  if (!node) return
2628
+ getConversationView().intentUntil=Date.now()+1500
2629
+ getConversationView().reading=true
2698
2630
  box.scrollTo({ top: Math.max(0, Number(node.dataset.offset) - 10), behavior: 'smooth' })
2699
2631
  })
2700
2632
  let drag = null
@@ -2704,6 +2636,8 @@ function bindRail() {
2704
2636
  })
2705
2637
  thumb.addEventListener('pointermove', (e) => {
2706
2638
  if (!drag) return
2639
+ getConversationView().intentUntil=Date.now()+1500
2640
+ getConversationView().reading=true
2707
2641
  const trackH = Math.max(1, box.clientHeight - 8)
2708
2642
  const delta = (e.clientY - drag.y) / trackH * Math.max(1, box.scrollHeight - box.clientHeight)
2709
2643
  box.scrollTop = Math.max(0, Math.min(box.scrollHeight - box.clientHeight, drag.top + delta))
@@ -2722,8 +2656,7 @@ const INTERESTING_EVENTS = new Set([
2722
2656
  'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
2723
2657
  'todo/updated', 'plan/updated',
2724
2658
  'question/asked', 'question/resolved',
2725
- 'approval/asked', 'approval/resolved',
2726
- 'session/title', 'title'
2659
+ 'approval/asked', 'approval/resolved'
2727
2660
  ])
2728
2661
  function messageSource(data) {
2729
2662
  const source = data?.source ?? data?.message?.source
@@ -3648,36 +3581,88 @@ function bindSessionSwipe() {
3648
3581
  }
3649
3582
 
3650
3583
  /* ---------------- 系统总览 / 待办 ---------------- */
3584
+ let overviewDetector = null
3585
+ let overviewPulse = null
3586
+ function setOverviewText(id,value){const node=$(id);if(overviewPulse)overviewPulse.text(node,value);else if(node)node.textContent=value}
3587
+ let overviewStarting = true
3588
+ function overviewChecks() {
3589
+ const confirmed=overviewDetector?.model.gateway===true,health=activeGatewayHealth()
3590
+ return {
3591
+ gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)) && confirmed,
3592
+ dsh: dshReachable() && confirmed,
3593
+ mux: !!state.streamsOk?.mux && confirmed && health?.events?.mux?.connected!==false,
3594
+ host: !!state.streamsOk?.host && confirmed && health?.events?.host?.connected!==false
3595
+ }
3596
+ }
3597
+ function repairMissingStreams() {
3598
+ if(!state.token||!navigator.onLine||state.selectingServer||overviewStarting)return
3599
+ if(state.streamMode==='poll'){tryRestoreWs();return}
3600
+ for(const [kind,handler,refresh] of [['mux',onMuxFrame,true],['host',onHostFrame,false]]) {
3601
+ const ws=streams[kind]
3602
+ if((!ws||ws.readyState===WebSocket.CLOSED)&&!streamMeta[kind].retryTimer)openStream(kind,handler,refresh)
3603
+ }
3604
+ }
3605
+ async function probeOverviewLinks(signal) {
3606
+ const connection=captureConnection(),base=String(connection.server||location.origin).replace(/\/+$/,'')
3607
+ let timer
3608
+ const controller=new AbortController(),cancel=()=>controller.abort()
3609
+ signal.addEventListener('abort',cancel,{once:true});if(signal.aborted)controller.abort()
3610
+ timer=setTimeout(cancel,4000)
3611
+ const healthRequest=fetch(base+'/health?t='+Date.now(),{signal:controller.signal,cache:'no-store'}).then(async response=>{
3612
+ if(!response.ok)throw Error('health')
3613
+ const health=await response.json();if(health?.ok!==true)throw Error('health')
3614
+ return health
3615
+ }).catch(()=>null).finally(()=>{clearTimeout(timer);signal.removeEventListener('abort',cancel)})
3616
+ const [health,host]=await Promise.all([healthRequest,refreshHostDescription()])
3617
+ if(!connection.valid()||signal.aborted)throw Error('stale check')
3618
+ if(health){if(host)health.upstreamReachable=true;state.gatewayHealth[base]=health}
3619
+ else delete state.gatewayHealth[base]
3620
+ return {gateway:!!health}
3621
+ }
3622
+ function startOverviewDetection(force=false,identityChanged=false) {
3623
+ if(identityChanged)overviewDetector?.pause()
3624
+ if(!window.DshLinkCheck)return
3625
+ if(!overviewDetector)overviewDetector=new window.DshLinkCheck.LinkCheck({
3626
+ active:()=>document.visibilityState==='visible'&&!$('view-activity').classList.contains('hidden'),
3627
+ configured:()=>!!state.token&&(!!state.server||/^https?:$/.test(location.protocol)),online:()=>navigator.onLine,
3628
+ snapshot:overviewChecks,repair:repairMissingStreams,probe:probeOverviewLinks,changed:renderOverview
3629
+ })
3630
+ overviewDetector.start(force)
3631
+ }
3632
+ window.addEventListener('online',()=>startOverviewDetection(true))
3633
+ window.addEventListener('offline',()=>startOverviewDetection(true))
3634
+ document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')startOverviewDetection(true);else {overviewDetector?.pause();overviewPulse?.pause()}})
3635
+
3651
3636
  function renderOverview() {
3652
3637
  const ring = $('overview-pulse-ring')
3653
3638
  if (!ring) return
3654
- const checks = {
3655
- // 独立网关页面默认走同源,此时 state.server 合法地为空;不能因此把
3656
- // 已连接网关误报为离线。Capacitor 等非 HTTP 页面仍要求显式服务器。
3657
- gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
3658
- dsh: dshReachable(),
3659
- mux: !!state.streamsOk?.mux,
3660
- host: !!state.streamsOk?.host
3661
- }
3639
+ const checks = overviewChecks()
3640
+ const detection=overviewDetector?.model,checking=detection?.phase==='checking'||(detection?.phase==='degraded'&&detection?.probing===true)
3662
3641
  const online = Object.values(checks).filter(Boolean).length
3642
+ $('overview-refresh').disabled=detection?.probing===true
3663
3643
  const status = online === 4 ? 'nominal' : online > 0 ? 'degraded' : 'offline'
3664
3644
  const pulseCard = document.querySelector('.overview-pulse-card')
3665
3645
  if (pulseCard) {
3646
+ if(!overviewPulse&&window.DshLinkCheck?.PulseVisual)overviewPulse=new window.DshLinkCheck.PulseVisual({card:pulseCard,active:()=>document.visibilityState==='visible'&&!$('view-activity').classList.contains('hidden')})
3647
+ overviewPulse?.setChecking(checking)
3648
+ overviewPulse?.setLinks(checks)
3666
3649
  pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
3650
+ pulseCard.classList.toggle('status-checking',checking)
3651
+ pulseCard.setAttribute('aria-busy',String(checking))
3667
3652
  pulseCard.classList.add('status-' + status)
3668
3653
  }
3669
- ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
3670
- $('overview-health').textContent = online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore')
3671
- $('overview-health-caption').textContent = online === 4 ? t('overview.allLinked') : online ? t('overview.components', { n: online }) : t('overview.offlineShort')
3672
- $('overview-status').textContent = t(`overview.${status}`)
3673
- $('overview-status-desc').textContent = t('overview.components', { n: online })
3654
+ setOverviewText('overview-health',checking ? t('overview.checking') : online === 4 ? t('overview.live') : online ? `${online}/4` : t('overview.offlineCore'))
3655
+ setOverviewText('overview-health-caption',checking ? `${online}/4` : online === 4 ? t('overview.allLinked') : online ? t('overview.confirmedShort') : t('overview.offlineShort'))
3656
+ setOverviewText('overview-status',checking ? t('overview.checking') : t(`overview.${status}`))
3657
+ setOverviewText('overview-status-desc',checking ? t('overview.autoChecking',{n:online}) : detection?.phase==='degraded' ? t('overview.autoRetry',{n:online}) : t('overview.components',{n:online}))
3674
3658
  for (const [name, ok] of Object.entries(checks)) {
3675
3659
  const item = document.querySelector(`[data-overview-link="${name}"]`)
3676
3660
  if (!item) continue
3677
3661
  item.classList.toggle('ok', ok)
3678
- item.classList.toggle('off', !ok)
3662
+ item.classList.toggle('off', !ok&&!checking)
3663
+ item.classList.toggle('pending',!ok&&checking)
3679
3664
  const value = item.querySelector('b')
3680
- if (value) value.textContent = ok ? t('overview.online') : t('overview.offlineShort')
3665
+ if (value) setOverviewText(value.id || (value.id=`overview-link-${name}`), ok ? t('overview.online') : checking ? t('overview.checkingShort') : t('overview.offlineShort'))
3681
3666
  }
3682
3667
 
3683
3668
  const pending = [
@@ -5266,11 +5251,9 @@ async function verifyUpdateApk(info, url) {
5266
5251
  phase = 'verifying'
5267
5252
  updateDownloadProgress({ phase, received, total: total || received })
5268
5253
  const expected = String(info.sha256 || '').trim().toLowerCase()
5269
- if (expected) {
5270
- if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: false, corrupted: true }
5271
- const actual = await sha256Hex(await blob.arrayBuffer())
5272
- if (actual !== expected) return { ok: false, corrupted: true }
5273
- }
5254
+ if (!/^[0-9a-f]{64}$/.test(expected)) return { ok: false, corrupted: true }
5255
+ const actual = await sha256Hex(await blob.arrayBuffer())
5256
+ if (actual !== expected) return { ok: false, corrupted: true }
5274
5257
  return { ok: true, blob }
5275
5258
  } catch (err) { return { ok: false, network: true, msg: err?.message || '' } }
5276
5259
  finally { clearTimeout(timer); clearInterval(progressTimer) }
@@ -5280,13 +5263,19 @@ async function downloadUpdate() {
5280
5263
  const info = state.updateInfo
5281
5264
  if (!info || updateDownloadBusy) return
5282
5265
  const url = new URL(info.apkUrl || 'dsh-remote.apk', updateBase() + '/').href
5266
+ const sha256 = String(info.sha256 || '').trim().toLowerCase()
5267
+ if (!/^[0-9a-f]{64}$/.test(sha256)) {
5268
+ // 无 SHA-256 的更新包一律不下载、不安装
5269
+ updateDownloadProgress({ phase: 'error', error: t('update.hashMissing') })
5270
+ return
5271
+ }
5283
5272
  updateDownloadBusy = true
5284
5273
  updateDownloadSample = null
5285
5274
  $('btn-download-update').disabled = true
5286
5275
  updateDownloadProgress({ phase: 'downloading', received: 0, total: 0 })
5287
5276
  if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadVerifiedAndInstall && window.NativeUpdate?.getDownloadStatus) {
5288
5277
  try {
5289
- if (!window.NativeUpdate.downloadVerifiedAndInstall(url, String(info.sha256 || '').trim())) throw new Error(t('update.busy'))
5278
+ if (!window.NativeUpdate.downloadVerifiedAndInstall(url, sha256)) throw new Error(t('update.busy'))
5290
5279
  const poll = () => {
5291
5280
  try {
5292
5281
  const value = JSON.parse(window.NativeUpdate.getDownloadStatus())
@@ -5302,11 +5291,8 @@ async function downloadUpdate() {
5302
5291
  try {
5303
5292
  const result = await verifyUpdateApk(info, url)
5304
5293
  if (!result.ok) throw new Error(result.corrupted ? t('update.corrupted') : result.status ? t('update.serverFileMissing') : result.msg || t('fs.networkError'))
5305
- if (CAP?.isNativePlatform?.() && window.NativeUpdate?.downloadAndInstall) {
5306
- // 旧壳不具备进度桥;安装本次新版后即可使用单次下载和完整进度。
5307
- window.NativeUpdate.downloadAndInstall(url)
5308
- updateDownloadProgress({ phase: 'legacy', received: 0, total: 0 })
5309
- } else {
5294
+ {
5295
+ // 无校验的原生安装入口已移除: 新旧壳统一走下方"下载并本地校验"路径。
5310
5296
  const objectUrl = URL.createObjectURL(result.blob)
5311
5297
  const link = document.createElement('a')
5312
5298
  link.href = objectUrl
@@ -6084,6 +6070,8 @@ function showView(id) {
6084
6070
  for (const v of ['view-home', 'view-files', 'view-session', 'view-activity', 'view-plugins', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
6085
6071
  // 离开会话页必须清掉 in-session, 否则其他页面顶栏被 body 样式隐藏
6086
6072
  document.body.classList.toggle('in-session', id === 'view-session')
6073
+ $('session-actions')?.close()
6074
+ $('session-info')?.close()
6087
6075
  document.querySelectorAll('.nav-btn').forEach(b => b.classList.toggle('active', b.dataset.view === id))
6088
6076
  // 主页已有上下文明确的刷新按钮,避免顶栏出现第二个同义入口。
6089
6077
  $('btn-refresh')?.classList.toggle('hidden', id === 'view-activity')
@@ -6096,6 +6084,7 @@ function showView(id) {
6096
6084
  if (id === 'view-plugins') openPluginPage()
6097
6085
  else window.DshPluginCenter?.close()
6098
6086
  if (id === 'view-settings') showSettingsHome()
6087
+ if(id==='view-activity')startOverviewDetection(true);else {overviewDetector?.pause();overviewPulse?.pause()}
6099
6088
  }
6100
6089
 
6101
6090
  const SETTINGS_GROUPS = ['general', 'model', 'tests', 'servers', 'notify', 'theme', 'about']
@@ -6116,6 +6105,7 @@ function showSettingsPage(name) {
6116
6105
  }
6117
6106
 
6118
6107
  function updateConn() {
6108
+ overviewDetector?.observe()
6119
6109
  const el = $('conn-badge')
6120
6110
  // mux/host 的打开顺序不稳定;连接状态改变时同步重绘总览,避免后打开的
6121
6111
  // 通道只更新顶栏、总览却永久停留在 3/4。
@@ -6186,8 +6176,8 @@ function updateComposerFullscreenButton() {
6186
6176
  function setComposerFullscreen(on) {
6187
6177
  const wrap = $('composer-wrap')
6188
6178
  if (!wrap) return
6189
- $('btn-stats')?.classList.toggle('hidden', !!on)
6190
- $('btn-insights')?.classList.toggle('hidden', !!on)
6179
+ $('btn-session-more')?.classList.toggle('hidden', !!on)
6180
+ $('session-actions')?.close()
6191
6181
  $('btn-fs-send')?.classList.toggle('hidden', !on)
6192
6182
  if (on) {
6193
6183
  $('composer-image-menu')?.classList.add('hidden')
@@ -6239,7 +6229,7 @@ function bindComposerFullscreenGesture() {
6239
6229
 
6240
6230
  /* ---------------- 初始化 ---------------- */
6241
6231
  /** 解析 dshremote://pair?token=..&server=.. 配对二维码;server 可重复以携带多个主机地址。 */
6242
- function applyPairUrl(url) {
6232
+ function applyPairUrl(url, options = {}) {
6243
6233
  try {
6244
6234
  const u = new URL(String(url).trim())
6245
6235
  if (u.protocol !== 'dshremote:' || u.hostname !== 'pair') return false
@@ -6248,6 +6238,13 @@ function applyPairUrl(url) {
6248
6238
  .map(value => value.trim().replace(/\/+$/, ''))
6249
6239
  .filter(value => /^https?:\/\//i.test(value)))]
6250
6240
  if (!tok || !servers.length) return false
6241
+ const alreadyPaired = (state.servers || []).some(s => normalizedServerToken(s.token) === tok
6242
+ && servers.includes(String(s.url || '').replace(/\/+$/, '')))
6243
+ if (options.confirmExisting && !alreadyPaired && (state.token || (state.servers || []).length)) {
6244
+ // 深链可被任意应用/网页触发: 已有配置时必须由用户确认,不能静默换服务器和令牌
6245
+ const confirmed = window.confirm(t('scan.pairConfirm', { servers: servers.join('\n') }))
6246
+ if (!confirmed) return false
6247
+ }
6251
6248
  const previousServer = state.server
6252
6249
  const previousToken = state.token
6253
6250
  state.token = tok
@@ -6535,14 +6532,14 @@ function bindNativeLinks() {
6535
6532
  if (!CAP?.isNativePlatform?.()) return
6536
6533
  try {
6537
6534
  CAP.Plugins?.App?.addListener?.('appUrlOpen', (data) => {
6538
- if (data?.url && applyPairUrl(data.url)) {
6535
+ if (data?.url && applyPairUrl(data.url, { confirmExisting: true })) {
6539
6536
  toast(t('scan.pairedLink'), 'ok')
6540
6537
  openStreams()
6541
6538
  refreshAll()
6542
6539
  }
6543
6540
  })
6544
6541
  CAP.Plugins?.App?.getLaunchUrl?.().then((data) => {
6545
- if (data?.url) applyPairUrl(data.url)
6542
+ if (data?.url) applyPairUrl(data.url, { confirmExisting: true })
6546
6543
  }).catch(() => {})
6547
6544
  } catch {}
6548
6545
  }
@@ -6688,7 +6685,10 @@ function renderDshControlStatus(value) {
6688
6685
  return
6689
6686
  }
6690
6687
  dshControlButtonsBusy(false, true)
6688
+ $('btn-dsh-start').disabled = value.running === true || value.canStart === false
6689
+ $('btn-dsh-restart').disabled = value.canRestart === false || value.running === false
6691
6690
  if (box && !dshControlPollPromise) box.classList.add('hidden')
6691
+ if (value.canStart === false && value.code) { desc.textContent=dshControlFailureText(value); return }
6692
6692
  const service = value.service || 'dsh-web'
6693
6693
  const serviceState = `${value.activeState || value.state || 'unknown'}/${value.subState || 'unknown'}`
6694
6694
  desc.textContent = value.running
@@ -6813,7 +6813,31 @@ function openDonateModal() {
6813
6813
  if (m) m.classList.remove('hidden')
6814
6814
  }
6815
6815
 
6816
+
6817
+ function bindSessionDialogs() {
6818
+ if (typeof ResizeObserver !== 'undefined') {
6819
+ const wrap=$('composer-wrap')
6820
+ const observer=new ResizeObserver(()=>{
6821
+ if(!wrap.classList.contains('fs'))document.documentElement.style.setProperty('--composer-height', wrap.getBoundingClientRect().height+'px')
6822
+ if(document.body.classList.contains('in-session'))document.documentElement.style.setProperty('--composer-top', $('session-sub').getBoundingClientRect().bottom+'px')
6823
+ })
6824
+ observer.observe(wrap)
6825
+ observer.observe($('session-head'))
6826
+ observer.observe($('session-sub'))
6827
+ }
6828
+ const actions=$('session-actions'), info=$('session-info');
6829
+ $('btn-session-more').addEventListener('click',()=>actions.showModal());
6830
+ actions.addEventListener('click',e=>{if(e.target===actions)actions.close();else if(e.target.closest('button'))actions.close()},true);
6831
+ const openInfo=()=>{const s=state.byId.get(state.current);if(!s)return; $('session-info-name').textContent=titleOf(s);$('session-info-path').textContent=s.cwd||t('session.noWorkspace');$('session-info-id').textContent=s.sessionId;$('btn-copy-session-workspace').disabled=!s.cwd;info.showModal()};
6832
+ $('session-title').addEventListener('click',openInfo);
6833
+ $('session-sub').addEventListener('click',openInfo);
6834
+ $('btn-session-info').addEventListener('click',openInfo);
6835
+ info.addEventListener('click',e=>{if(e.target===info||e.target.closest('[data-session-dialog-close]'))info.close()});
6836
+ $('btn-copy-session-workspace').addEventListener('click',async()=>{const value=state.byId.get(state.current)?.cwd;if(value&&await copyText(value))toast(t('feedback.copied'),'ok')});
6837
+ }
6838
+
6816
6839
  function bindUi() {
6840
+ bindSessionDialogs()
6817
6841
  renderLangBtn()
6818
6842
  renderThemeBtn()
6819
6843
  $('btn-lang').addEventListener('click', () => {
@@ -6928,7 +6952,7 @@ function bindUi() {
6928
6952
  $('btn-refresh').addEventListener('click', () => { toast(t('common.refreshing')); openStreams(); refreshAll() })
6929
6953
  $('overview-refresh').addEventListener('click', () => {
6930
6954
  toast(t('common.refreshing'))
6931
- openStreams()
6955
+ startOverviewDetection(true)
6932
6956
  void Promise.all([refreshAll(), checkAnnouncements()])
6933
6957
  })
6934
6958
  // 反馈
@@ -7190,8 +7214,10 @@ function bindUi() {
7190
7214
  $('modal-app-version-warning').addEventListener('click', (e) => {
7191
7215
  if (e.target === $('modal-app-version-warning')) closeAppVersionWarning(false)
7192
7216
  })
7193
- $('btn-reset').addEventListener('click', () => {
7217
+ $('btn-reset').addEventListener('click', async () => {
7194
7218
  if (!confirm(t('settings.confirmReset'))) return
7219
+ clearTimeout(scheduleHistoryCacheSave._t)
7220
+ await window.DshHistory.cacheClear()
7195
7221
  LS.del('token'); LS.del('notify'); LS.del('server'); LS.del('mobileEnterAction'); LS.del('steerSendingEnabled'); LS.del('busySendMode'); LS.del(ANNOUNCEMENTS_KEY); LS.del(ANNOUNCEMENT_HISTORY_KEY); LS.del(ANNOUNCEMENT_VOTES_KEY)
7196
7222
  if (bgBridge()?.saveBackgroundConfig) saveBgConfig(false)
7197
7223
  location.reload()
@@ -7305,27 +7331,8 @@ function bindUi() {
7305
7331
 
7306
7332
  initCustomSelects()
7307
7333
 
7308
- // 向上翻历史 / 向下回最新
7309
- $('history').addEventListener('scroll', () => {
7310
- const box = $('history')
7311
- const h = state.history
7312
- updateRail()
7313
- if (!state.current || !h.filtered?.length) return
7314
- if (box.scrollTop < 80) {
7315
- if (h.renderStart > 0) {
7316
- h.renderStart = Math.max(0, h.renderStart - 100)
7317
- renderHistory(false, 'keep')
7318
- } else if (h.hasMore && !h.loading) {
7319
- loadHistory(false)
7320
- }
7321
- } else if (box.scrollHeight - box.scrollTop - box.clientHeight < 240) {
7322
- if (h.renderEnd < h.filtered.length) {
7323
- h.renderEnd = h.filtered.length
7324
- h.renderStart = Math.max(0, h.renderEnd - 200)
7325
- renderHistory(false, 'bottom')
7326
- }
7327
- }
7328
- })
7334
+ $('btn-history-latest').addEventListener('click',()=>{getConversationView().latest();if(state.history.tailEvicted)void loadHistory(true)})
7335
+ $('history').addEventListener('load',()=>updateRail(true),true)
7329
7336
  }
7330
7337
 
7331
7338
  /* App 内真实系统栏 inset(刘海/状态栏/手势条) */
@@ -7352,14 +7359,15 @@ async function boot() {
7352
7359
  await loadLocalVersion()
7353
7360
  if (!state.token) {
7354
7361
  showView('view-activity')
7362
+ overviewStarting=false
7355
7363
  $('token-desc').textContent = t('token.notSetHint')
7356
7364
  } else {
7357
7365
  // 多服务器: 启动时静默测速一次, 选最快的连接(同源页面也参与比较)
7358
7366
  await selectFastestServer({ silent: true, reconnect: false })
7359
7367
  await maybeWarnAppBehindGateway({ probe: true })
7368
+ overviewStarting=false
7360
7369
  openStreams()
7361
- await refreshAll()
7362
- await refreshHostDescription()
7370
+ await Promise.all([refreshAll(),refreshHostDescription()])
7363
7371
  loadDshControl()
7364
7372
  }
7365
7373
  // 网关从中央 HTTPS 公告源读取并在不可达时回退内置文件。前台每 30 秒检查,
@@ -7383,4 +7391,3 @@ function openPluginPage() {
7383
7391
  valid: connection.valid,
7384
7392
  }, $('plugin-page'))
7385
7393
  }
7386
- document.getElementById('btn-plugin-center')?.addEventListener('click', () => showView('view-plugins'))