dsh-remote-plugin 0.7.1 → 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.
@@ -52,7 +52,7 @@ themeApply()
52
52
 
53
53
  /* ---------------- 状态 ---------------- */
54
54
  function emptyDesktopHistory() {
55
- return { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity, partialReasoning: new Map() }
55
+ return { seqs: new Set(), visible: [], hasMore: false, loading: false, minSeq: Infinity, partialReasoning: new Map(), loaded:false, cached:false, tailEvicted:false }
56
56
  }
57
57
  const state = {
58
58
  token: LS.get('token', ''),
@@ -102,6 +102,41 @@ const state = {
102
102
  view: 'sessions',
103
103
  subagentExpandedSession: ''
104
104
  }
105
+
106
+ // Increment on every identity transition, including A -> B -> A and token edits.
107
+ let connectionGeneration = 0, sessionsRequest = 0, filesRequest = 0, workbenchRequest = 0
108
+ for (const key of ['server', 'token']) {
109
+ let value = state[key]
110
+ Object.defineProperty(state, key, {
111
+ enumerable: true,
112
+ get: () => value,
113
+ set(next) {
114
+ if (next === value) return
115
+ value = next; connectionGeneration++
116
+ state.sessions = []; state.byId.clear(); state.pendingProjections.clear()
117
+ state.hostInfo = null
118
+ if(typeof closeStream==='function'&&typeof streamMeta!=='undefined'){closeStream('mux');closeStream('host')}
119
+ state.current = null; state.history = emptyDesktopHistory()
120
+ state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
121
+ state.workspaces = []; state.archivedIds = []; state.wb = { bound: false, path: '', projects: null }
122
+ window.DshPluginCenter?.close()
123
+ window.DshInsights?.close()
124
+ if(typeof document!=='undefined') {
125
+ const identityGeneration=connectionGeneration
126
+ queueMicrotask(()=>{if(identityGeneration===connectionGeneration&&typeof startOverviewDetection==='function')startOverviewDetection(true,true)})
127
+ document.getElementById('history')?.replaceChildren?.()
128
+ const generation=connectionGeneration
129
+ queueMicrotask(()=>{if(generation!==connectionGeneration)return;if(typeof conversationView!=='undefined')conversationView?.reset();restoreCachedSessionList()})
130
+ }
131
+ if (typeof document !== 'undefined') document.getElementById('modal-workspace')?.classList.add('hidden')
132
+ },
133
+ })
134
+ }
135
+ function captureConnection() {
136
+ const generation = connectionGeneration, server = state.server, token = state.token
137
+ return { server, token, valid: () => generation === connectionGeneration && server === state.server && token === state.token }
138
+ }
139
+
105
140
  const streams = {}
106
141
  let pollTimer = null
107
142
  let wsRetryTimer = null
@@ -276,7 +311,7 @@ function renderModelMenu() {
276
311
  if (state.models.loading) { box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${t('models.loading')}</span>`; return }
277
312
  const groups = state.models.groups || []
278
313
  if (!groups.length) {
279
- box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${(state.models.failures || []).map(f => f.name + ' ' + t('models.unavailable')).join(';') || t('models.none')}</span>`
314
+ box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${(state.models.failures || []).map(f => esc(f.name) + ' ' + t('models.unavailable')).join(';') || t('models.none')}</span>`
280
315
  return
281
316
  }
282
317
  const cur = state.models.current
@@ -388,6 +423,9 @@ function openFeedbackModal() {
388
423
  $('fb-msg').value = ''
389
424
  $('fb-contact').value = ''
390
425
  $('fb-include-diagnostics').checked = false
426
+ $('fb-include-crashes').checked = false
427
+ $('fb-crash-details').open = false
428
+ void loadCrashPreview()
391
429
  $('modal-feedback').classList.remove('hidden')
392
430
  setTimeout(() => $('fb-msg').focus(), 50)
393
431
  }
@@ -508,11 +546,22 @@ async function checkNotesOnStart() {
508
546
  openNotesModal(info)
509
547
  } catch {}
510
548
  }
549
+ async function loadCrashPreview() {
550
+ const connection=captureConnection(),preview=$('fb-crash-preview')
551
+ preview.textContent=t('ds.feedbackCrashLoading')
552
+ try {
553
+ const response=await fetch(apiUrl('/crash-logs'),{headers:{authorization:'Bearer '+connection.token},signal:AbortSignal.timeout(5000),cache:'no-store'})
554
+ if(!response.ok)throw new Error('unavailable')
555
+ const data=await response.json()
556
+ if(connection.valid())preview.textContent=JSON.stringify(data.upload,null,2)
557
+ }catch{if(connection.valid())preview.textContent=t('ds.feedbackCrashUnavailable')}
558
+ }
511
559
  async function submitFeedback() {
512
560
  const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
513
561
  const message = $('fb-msg').value.trim()
514
562
  const contact = $('fb-contact').value.trim()
515
563
  const includeDiagnostics = $('fb-include-diagnostics').checked
564
+ const includeCrashLogs = $('fb-include-crashes').checked
516
565
  if (!message) { toast(t('ds.feedbackEmpty'), 'err'); return }
517
566
  if (message.length > 2000) { toast(t('ds.feedbackTooLong'), 'err'); return }
518
567
  const btn = $('fb-submit')
@@ -521,7 +570,7 @@ async function submitFeedback() {
521
570
  const res = await fetch(apiUrl('/feedback'), {
522
571
  method: 'POST',
523
572
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
524
- body: JSON.stringify({ type, message, contact, appVersion: '', includeDiagnostics })
573
+ body: JSON.stringify({ type, message, contact, appVersion: '', includeDiagnostics, includeCrashLogs })
525
574
  })
526
575
  let json = {}
527
576
  try { json = await res.json() } catch {}
@@ -607,14 +656,17 @@ async function respond(rpcId, value) {
607
656
  return receipt?.accepted === true
608
657
  }
609
658
  async function safeRpc(method, payload, errText) {
659
+ const connection = captureConnection()
610
660
  try { return await rpc(method, payload) }
611
661
  catch (e) {
662
+ if (!connection.valid()) return null
612
663
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return null }
613
664
  toast(errText ? `${errText}:${e.message}` : e.message, 'err')
614
665
  return null
615
666
  }
616
667
  }
617
668
  let hostDescribePromise = null
669
+ let hostDescribeConnection = null
618
670
  let hostDescribeRetryTimer = null
619
671
  let hostDescribeFailures = 0
620
672
 
@@ -630,12 +682,13 @@ function scheduleHostDescribeRetryDesktop() {
630
682
 
631
683
  async function refreshHostDescriptionDesktop({ notify = false } = {}) {
632
684
  if (!state.token) return null
633
- if (hostDescribePromise) return hostDescribePromise
634
- const server = state.server
635
- hostDescribePromise = (async () => {
685
+ if (hostDescribePromise && hostDescribeConnection?.valid()) return hostDescribePromise
686
+ const connection = captureConnection()
687
+ hostDescribeConnection = connection
688
+ const job = (async () => {
636
689
  try {
637
690
  const host = await rpc('host.describe', {}, 5000)
638
- if (server !== state.server) return null
691
+ if (!connection.valid()) return null
639
692
  state.hostInfo = host
640
693
  const health = activeGatewayHealth()
641
694
  if (health) health.upstreamReachable = true
@@ -645,17 +698,17 @@ async function refreshHostDescriptionDesktop({ notify = false } = {}) {
645
698
  renderOverviewDesktop()
646
699
  return host
647
700
  } catch (error) {
648
- if (server !== state.server) return null
701
+ if (!connection.valid()) return null
649
702
  hostDescribeFailures++
650
703
  scheduleHostDescribeRetryDesktop()
651
704
  if (notify) toast(error.message === 'AUTH' ? t('ds.toastAuth') : error.message, 'err')
652
705
  return null
653
706
  } finally {
654
- hostDescribePromise = null
655
- if (server !== state.server) scheduleHostDescribeRetryDesktop()
707
+ if (hostDescribePromise === job) { hostDescribePromise = null; hostDescribeConnection = null }
656
708
  }
657
709
  })()
658
- return hostDescribePromise
710
+ hostDescribePromise = job
711
+ return job
659
712
  }
660
713
  function uuid() {
661
714
  try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
@@ -1164,12 +1217,12 @@ function openStreams() {
1164
1217
  function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1165
1218
  if (!state.token) return
1166
1219
  if (ticket === null) {
1167
- const token = state.token
1220
+ const token = state.token, connection=captureConnection()
1168
1221
  void getWsTicket().then((value) => {
1169
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
1222
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, value)
1170
1223
  }).catch(() => {
1171
1224
  // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
1172
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
1225
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, '')
1173
1226
  })
1174
1227
  return
1175
1228
  }
@@ -1350,7 +1403,7 @@ window.addEventListener('online', () => {
1350
1403
  document.addEventListener('visibilitychange', () => {
1351
1404
  if (document.visibilityState === 'visible' && state.token &&
1352
1405
  (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
1353
- openStreams()
1406
+ repairMissingStreams()
1354
1407
  }
1355
1408
  })
1356
1409
 
@@ -1512,6 +1565,7 @@ function applyProjection(sessionId, key, value, seq) {
1512
1565
  s.projections.asOfSeq = Math.max(currentSeq, seq || 0)
1513
1566
  if (state.current === sessionId) {
1514
1567
  renderSessions()
1568
+ window.DshInsights?.update()
1515
1569
  if (['goal', 'todos'].includes(key)) renderSessionCards()
1516
1570
  }
1517
1571
  if (['title', 'goal', 'todos', 'plan', 'sessionListMetadata'].includes(key)) refreshSessions()
@@ -1538,6 +1592,10 @@ function resyncAfterStreamOpen() {
1538
1592
  void refreshSessions().then(() => resyncCurrentSession())
1539
1593
  }
1540
1594
  function proj(s, key, d) { return s?.projections?.values?.[key] ?? d }
1595
+ function openInsights(tab = 'context') {
1596
+ window.DshInsights?.open({ tab, connection: captureConnection(), url: apiUrl,
1597
+ getSession: () => state.byId.get(state.current), getTitle: () => titleOf(state.byId.get(state.current)) })
1598
+ }
1541
1599
  function sessionTitleValue(s) {
1542
1600
  const value = proj(s, 'title', '')
1543
1601
  return value == null ? '' : String(value).trim()
@@ -1552,7 +1610,11 @@ function sessionLabelOf(s) {
1552
1610
  function isTopLevelSession(session) {
1553
1611
  return !!session && !session.parentSessionId && session.origin !== 'subagent'
1554
1612
  }
1555
- function topLevelSessions() { return state.sessions.filter(isTopLevelSession) }
1613
+ // DSH marks sessions created via "new session" but never prompted as blank and hides
1614
+ // them in its own UI; keep the same semantics here so they do not occupy the list,
1615
+ // the workspace tree or the home stats.
1616
+ function isVisibleSession(session) { return isTopLevelSession(session) && !session?.blank }
1617
+ function topLevelSessions() { return state.sessions.filter(isVisibleSession) }
1556
1618
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1557
1619
  function isGoalTerminal(goal) {
1558
1620
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1587,6 +1649,7 @@ function onSessionEvent(sessionId, event) {
1587
1649
  renderSessions()
1588
1650
  }
1589
1651
  const session = state.byId.get(sessionId)
1652
+ if((event?.type==='session/title'||event?.type==='title')&&event.data?.title&&session){session.projections??={values:{}};session.projections.values??={};session.projections.values.title=event.data.title;if(state.current===sessionId)updateSessionActions()}
1590
1653
  if (event?.type === 'agent/status' && session) {
1591
1654
  session.running = !!event.data?.running
1592
1655
  if (state.current === sessionId) { renderQueue(); updateComposerStatus() }
@@ -1603,7 +1666,9 @@ function onSessionEvent(sessionId, event) {
1603
1666
  h.seqs.add(seq)
1604
1667
  h.visible.push({ seq, event })
1605
1668
  h.visible.sort((a, b) => a.seq - b.seq)
1669
+ trimVisible()
1606
1670
  renderHistory()
1671
+ scheduleHistoryCacheSave()
1607
1672
  }
1608
1673
  if (String(event.type || '').startsWith('goal/') || String(event.type || '').startsWith('todo/')) renderSessionCards()
1609
1674
  }
@@ -1611,11 +1676,15 @@ function onSessionEvent(sessionId, event) {
1611
1676
 
1612
1677
  /* ---------------- 会话 ---------------- */
1613
1678
  async function refreshSessions() {
1679
+ if(typeof restoreCachedSessionList==='function')restoreCachedSessionList()
1680
+ const connection = captureConnection(), request = ++sessionsRequest
1614
1681
  const v = await safeRpc('session.list', {}, '')
1682
+ if (!connection.valid() || request !== sessionsRequest) return
1615
1683
  if (!v) { renderSessions(); renderOverviewDesktop(); return }
1616
1684
  state.sessions = v.items || []
1617
1685
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1618
1686
  applyPendingProjections()
1687
+ try{LS.set(historyCacheScope()+':sessions',JSON.stringify(state.sessions.slice(0,80)))}catch{}
1619
1688
  renderSessions()
1620
1689
  scheduleWorkbenchRefresh()
1621
1690
  renderOverviewDesktop()
@@ -1736,9 +1805,9 @@ function renderSessions() {
1736
1805
  }
1737
1806
 
1738
1807
  const emptySessionCleanup = new Set()
1739
- async function archiveEmptySessionOnLeave(sessionId) {
1740
- const base = state.server
1741
- const protectedSession = () => state.server !== base || state.current !== sessionId
1808
+ async function archiveEmptySessionOnLeave(sessionId, leaving = false) {
1809
+ const base = state.server, connection=captureConnection()
1810
+ const protectedSession = () => !connection.valid() || state.server !== base || (!leaving && state.current !== sessionId)
1742
1811
  || !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
1743
1812
  || state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
1744
1813
  || (state.queues[sessionId] || []).length > 0
@@ -1759,14 +1828,16 @@ async function archiveEmptySessionOnLeave(sessionId) {
1759
1828
  }
1760
1829
 
1761
1830
  async function openSession(id) {
1762
- if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
1831
+ if (state.current && state.current !== id) void archiveEmptySessionOnLeave(state.current,true)
1763
1832
  state.current = id
1764
1833
  setSessionRecovery('loading')
1765
1834
  state.history = emptyDesktopHistory()
1835
+ const openingHistory=state.history, openingConnection=captureConnection()
1766
1836
  state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
1767
1837
  showView('view-chat')
1768
1838
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
1769
1839
  updateSessionActions()
1840
+ getConversationView().reset()
1770
1841
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
1771
1842
  renderSessions()
1772
1843
  renderSessionCards()
@@ -1774,6 +1845,8 @@ async function openSession(id) {
1774
1845
  renderSessionPendingDesktop()
1775
1846
  updateComposerStatus()
1776
1847
  void refreshCompactionStatus(id)
1848
+ await restoreCachedHistory()
1849
+ if(state.current!==id||state.history!==openingHistory||!openingConnection.valid())return
1777
1850
  await loadHistory()
1778
1851
  }
1779
1852
  async function closeSession() {
@@ -1791,52 +1864,112 @@ async function closeSession() {
1791
1864
  updateSessionActions()
1792
1865
  showView('view-sessions')
1793
1866
  }
1794
- async function loadHistory() {
1795
- const id = state.current
1796
- if (!id || state.history.loading) return
1797
- const history = state.history
1798
- const reasoningVersion = history.reasoningVersion || 0
1799
- state.history.loading = true
1800
- setSessionRecovery('loading')
1867
+
1868
+ function restoreCachedSessionList() {
1869
+ if(state.sessions.length)return
1870
+ try{const items=JSON.parse(LS.get(historyCacheScope()+':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{}
1871
+ }
1872
+
1873
+ let conversationView = null
1874
+ function historyCacheScope() { const hash=new SHA256();hash.update(new TextEncoder().encode(JSON.stringify([state.server||location.origin,state.token])));return 'historyCacheV2:'+hash.hex() }
1875
+ function historySource(value) {
1876
+ state.history.source=value
1877
+ const el=$('history-source')
1878
+ if(el)el.textContent=t(({cache:'ds.historyCached',syncing:'ds.historySyncing',synced:'ds.historySynced',saved:'ds.historySaved',offline:'ds.historyOffline',saveFailed:'ds.historyCacheFailed'})[value]||'ds.historySynced')
1879
+ }
1880
+ function getConversationView() {
1881
+ if(!conversationView)conversationView=new window.DshHistory.HistoryView($('history'),{
1882
+ html:entry=>eventHtml(entry),
1883
+ older:()=>loadHistory(false),olderLabel:()=>t('ds.historyEarlier'),loadingLabel:()=>t('ds.historyLoadingEarlier'),live:partialReasoningHtml,
1884
+ 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);},
1885
+ scrolled:()=>{}
1886
+ })
1887
+ return conversationView
1888
+ }
1889
+ function scheduleHistoryCacheSave() {
1890
+ clearTimeout(scheduleHistoryCacheSave._t)
1891
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
1892
+ scheduleHistoryCacheSave._t=setTimeout(()=>{if(connection.valid()&&state.current===id&&state.history===history)void saveHistoryCache(scope,id,history)},500)
1893
+ }
1894
+ async function saveHistoryCache(scope=historyCacheScope(),id=state.current,history=state.history) {
1895
+ if(!id||!history.visible.length||history.tailEvicted)return
1896
+ 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()]})
1897
+ if(!saved&&state.current===id&&state.history===history&&scope===historyCacheScope())historySource('saveFailed')
1898
+ if(saved&&state.current===id&&state.history===history&&scope===historyCacheScope()&&['synced','saveFailed'].includes(history.source))historySource('saved')
1899
+ }
1900
+ async function restoreCachedHistory() {
1901
+ const connection=captureConnection(),id=state.current,history=state.history,scope=historyCacheScope()
1902
+ if(!id)return false
1903
+ const cached=await window.DshHistory.cacheLoad(scope,id,legacyId=>window.DshHistory.decodeLegacy(LS.get(scope,''),legacyId))
1904
+ if(!connection.valid()||state.current!==id||state.history!==history||!cached?.events?.length)return false
1905
+ history.visible=cached.events.slice(-250).filter(entry=>entry?.seq!=null&&shouldShowEvent(entry.event?.type,entry.event)).sort((a,b)=>a.seq-b.seq)
1906
+ 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
1907
+ if(cached.title&&!hasSessionTitle(state.byId.get(id)))hydrateSessionProjections(id,{values:{title:cached.title},asOfSeq:0})
1908
+ applyReasoningBaseline(cached.partialReasoning||[]);historySource('cache');setSessionRecovery('cached');renderHistory(true)
1909
+ return true
1910
+ }
1911
+ function trimVisible(direction='tail') {
1912
+ const h=state.history,excess=h.visible.length-5000
1913
+ if(excess<=0)return
1914
+ const older=direction==='older'||conversationView?.reading
1915
+ const removed=older?h.visible.splice(5000):h.visible.splice(0,excess)
1916
+ for(const entry of removed)h.seqs.delete(entry.seq)
1917
+ if(older)h.tailEvicted=true
1918
+ else{h.minSeq=h.visible[0]?.seq??Infinity;h.headEvicted=true;h.hasMore=true}
1919
+ }
1920
+
1921
+ async function loadHistory(reset = true) {
1922
+ const connection=captureConnection(),id=state.current,history=state.history
1923
+ if(!id||history.loading||(!reset&&Date.now()<(history.retryAfter||0)))return
1924
+ const reasoningVersion=history.reasoningVersion||0,hadContent=history.visible.length>0
1925
+ history.loading=true
1926
+ if(reset){if(hadContent)historySource('syncing');else setSessionRecovery('loading')}
1927
+ const payload={sessionId:id,maxMessages:60}
1928
+ if(!reset&&Number.isFinite(history.minSeq))payload.beforeSeq=history.minSeq
1929
+ getConversationView().more.disabled=true
1930
+ getConversationView().more.textContent=getConversationView().options.loadingLabel()
1801
1931
  let v
1802
- try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1803
- catch (e) {
1804
- if (state.current !== id || state.history !== history) return
1805
- state.history.loading = false
1806
- if (e.message === 'AUTH') return
1807
- setSessionRecovery('error', e.message)
1808
- $('history').innerHTML = `<div class="ds-empty">${e.message}</div>`
1932
+ try{v=await rpc('session.history',payload)}catch(error){
1933
+ if(!connection.valid()||state.current!==id||state.history!==history)return
1934
+ history.loading=false;history.retryAfter=Date.now()+1500
1935
+ if(error.message==='AUTH'){return}
1936
+ if(hadContent){historySource('offline');setSessionRecovery('cached',error.message);renderHistory(false,'fixed');return}
1937
+ setSessionRecovery('error',error.message)
1938
+ $('history').innerHTML='<div class="history-empty">'+esc(error.message)+'</div>'
1939
+ const retry=document.createElement('button');retry.type='button';retry.className='history-page-control';retry.textContent=t('ds.historyRetry');retry.addEventListener('click',()=>{void loadHistory(true)});$('history').append(retry)
1809
1940
  return
1810
1941
  }
1811
- if (state.current !== id || state.history !== history) return
1812
- const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
1813
- hydrateSessionProjections(id, v.projections)
1942
+ if(!connection.valid()||state.current!==id||state.history!==history)return
1943
+ const liveReasoning=(history.reasoningVersion||0)!==reasoningVersion?new Map(history.partialReasoning):null
1944
+ hydrateSessionProjections(id,v.projections);history.loaded=true
1945
+ const incoming=v.events||[],prepared=[]
1814
1946
  for (const entry of v.events || []) {
1815
- const ev = entry?.event
1816
- const seq = ev?.seq
1817
- if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1947
+ const ev=entry?.event
1818
1948
  applyReasoningStreamEvent(ev)
1819
- if (seq == null || state.history.seqs.has(seq)) continue
1820
- if (!shouldShowEvent(ev.type, ev)) continue
1821
- state.history.seqs.add(seq)
1822
- state.history.visible.push({ seq, event: ev })
1823
- }
1824
- state.history.visible.sort((a, b) => a.seq - b.seq)
1825
- if (liveReasoning) history.partialReasoning = liveReasoning
1826
- else applyReasoningBaseline(v.partialReasoning)
1827
- state.history.hasMore = !!v.hasMore
1828
- state.history.loading = false
1829
- setSessionRecovery('ready')
1830
- updateSessionActions()
1831
- renderHistory()
1949
+ if(ev?.type==='turn/start'||ev?.type==='turn/end')noteSessionTurnTime(id,ev)
1950
+ if(ev?.seq==null||!shouldShowEvent(ev.type,ev))continue
1951
+ prepared.push({seq:ev.seq,event:ev,view:entry.view})
1952
+ }
1953
+ const rawSeqs=incoming.map(entry=>entry?.event?.seq).filter(Number.isFinite),floor=rawSeqs.length?Math.min(...rawSeqs):Infinity
1954
+ 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}
1955
+ else{history.visible=window.DshHistory.reconcile(history.visible,prepared,floor,true,payload.beforeSeq);history.minSeq=Math.min(history.minSeq,floor)}
1956
+ history.seqs=new Set(history.visible.map(entry=>entry.seq));history.headEvicted=false;trimVisible(reset?'tail':'older')
1957
+ if(liveReasoning)history.partialReasoning=liveReasoning;else applyReasoningBaseline(v.partialReasoning)
1958
+ history.hasMore=(!!v.hasMore||(reset&&history.headEvicted)) && rawSeqs.length>0 && (reset || floor<payload.beforeSeq)
1959
+ history.loading=false;history.cached=false;historySource('synced');setSessionRecovery('ready')
1960
+ updateSessionActions();renderSessionCards()
1961
+ renderHistory(!hadContent,reset?'fixed':'keep')
1962
+
1963
+ scheduleHistoryCacheSave()
1832
1964
  }
1833
1965
 
1966
+
1834
1967
  const INTERESTING_EVENTS = new Set([
1835
1968
  'user/message', 'assistant/message', 'tool/call', 'tool/result',
1836
1969
  'agent/status', 'checkpoint/created', 'compaction/complete', 'compaction/summary',
1837
1970
  'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
1838
1971
  'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
1839
- 'approval/asked', 'approval/resolved', 'session/title', 'title'
1972
+ 'approval/asked', 'approval/resolved'
1840
1973
  ])
1841
1974
  function messageSource(data) {
1842
1975
  const source = data?.source ?? data?.message?.source
@@ -1961,17 +2094,15 @@ function eventHtml(entry) {
1961
2094
  if (type === 'question/asked') return `<div class="ds-tool">❓ ${esc(data.question || '')}</div>`
1962
2095
  return `<div class="ds-tool">${esc(type)}</div>`
1963
2096
  }
1964
- function renderHistory() {
1965
- const box = $('history')
1966
- const items = state.history.visible
1967
- const html = items.map(eventHtml).join('') + partialReasoningHtml()
1968
- box.innerHTML = html || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
1969
- box.scrollTop = box.scrollHeight
2097
+ function renderHistory(reset=false, mode='auto') {
2098
+ getConversationView().set(state.history.visible,{reset,follow:mode==='bottom',live:partialReasoningHtml(),hasMore:state.history.hasMore,loading:state.history.loading,emptyLabel:t('ds.historyEmpty')})
1970
2099
  }
1971
2100
 
1972
2101
  /* ---------------- 会话信息卡(goal / todo / 子代理) ---------------- */
1973
2102
  let sessionCardsRenderGeneration = 0
1974
2103
  async function renderSessionCards() {
2104
+ window.DshInsights?.update()
2105
+ $('btn-insights')?.classList.toggle('hidden', !state.current)
1975
2106
  const renderGeneration = ++sessionCardsRenderGeneration
1976
2107
  const sessionId = state.current
1977
2108
  const box = $('session-cards')
@@ -2514,8 +2645,10 @@ function resetFsForServerDesktop() {
2514
2645
  if (select) select.classList.add('hidden')
2515
2646
  }
2516
2647
  async function openWorkspaceModal() {
2648
+ const connection = captureConnection()
2517
2649
  if (!state.token) { toast(t('ds.toastAuth'), 'err'); showView('view-settings'); return }
2518
2650
  if (!state.fs.path) await loadFs(null, true)
2651
+ if (!connection.valid()) return
2519
2652
  $('workspace-parent-path').textContent = state.fs.path || '~'
2520
2653
  $('workspace-name').value = ''
2521
2654
  $('modal-workspace').classList.remove('hidden')
@@ -2526,31 +2659,39 @@ async function createWorkspace() {
2526
2659
  if (createWorkspace.busy) return
2527
2660
  const name = $('workspace-name').value.trim()
2528
2661
  if (!name) { toast(t('ds.workspaceNameRequired'), 'err'); $('workspace-name').focus(); return }
2662
+ const connection = captureConnection()
2529
2663
  createWorkspace.busy = true
2530
2664
  const parent = state.fs.path || ''
2531
2665
  const button = $('workspace-create')
2532
2666
  button.disabled = true
2533
2667
  try {
2534
2668
  const res = await fetch(fsApiUrl('/mkdir', { path: parent, name }), { method: 'POST', headers: fsHeaders() })
2669
+ if (!connection.valid()) return
2535
2670
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2536
2671
  const data = await res.json().catch(() => ({}))
2672
+ if (!connection.valid()) return
2537
2673
  if (!res.ok) {
2538
2674
  const msg = data.error === 'exists' ? t('ds.workspaceExists') : data.error === 'bad-name' ? t('ds.workspaceInvalidName') : data.error || ('HTTP ' + res.status)
2539
2675
  throw new Error(msg)
2540
2676
  }
2541
2677
  closeWorkspaceModal()
2542
2678
  await loadFs(parent || null, true)
2679
+ if (!connection.valid()) return
2543
2680
  let workspace = null
2544
2681
  try {
2545
2682
  const created = await rpc('workspace.create', { path: data.path })
2683
+ if (!connection.valid()) return
2546
2684
  workspace = created?.workspace || null
2547
2685
  } catch {}
2686
+ if (!connection.valid()) return
2548
2687
  if (workspace?.workspaceId) {
2549
2688
  state.workspaces = [...state.workspaces.filter(item => item.workspaceId !== workspace.workspaceId), workspace]
2550
2689
  }
2551
2690
  const sessionPayload = workspace?.workspaceId ? { workspaceId: workspace.workspaceId } : { cwd: data.path }
2552
2691
  const v = await safeRpc('session.create', sessionPayload, t('ds.toastOpFailed'))
2692
+ if (!connection.valid()) return
2553
2693
  await refreshSessions()
2694
+ if (!connection.valid()) return
2554
2695
  if (v?.sessionId) {
2555
2696
  if (workspace?.workspaceId) LS.set('lastNewSessionWorkspaceV1', workspace.workspaceId)
2556
2697
  toast(t('ds.workspaceCreated'), 'ok')
@@ -2559,6 +2700,7 @@ async function createWorkspace() {
2559
2700
  toast(t('ds.workspaceCreatedNoSession'), 'ok')
2560
2701
  }
2561
2702
  } catch (e) {
2703
+ if (!connection.valid()) return
2562
2704
  toast(`${t('ds.workspaceCreateFailed')}:${e.message || t('ds.feedbackNetworkError')}`, 'err')
2563
2705
  } finally {
2564
2706
  createWorkspace.busy = false
@@ -2566,6 +2708,8 @@ async function createWorkspace() {
2566
2708
  }
2567
2709
  }
2568
2710
  async function loadFs(dir, silent) {
2711
+ const connection = captureConnection(), request = ++filesRequest
2712
+ const valid = () => connection.valid() && request === filesRequest
2569
2713
  if (!state.token) {
2570
2714
  $('fs-path').textContent = t('ds.toastAuth')
2571
2715
  $('fs-list').innerHTML = `<div class="ds-empty">${t('ds.toastAuth')}</div>`
@@ -2578,8 +2722,10 @@ async function loadFs(dir, silent) {
2578
2722
  }
2579
2723
  try {
2580
2724
  const res = await fetch(fsApiUrl('/list', target ? { path: target } : {}), { headers: fsHeaders() })
2725
+ if (!valid()) return
2581
2726
  if (res.status === 401) { toast(t('ds.toastAuth'), 'err'); return }
2582
2727
  const data = await res.json().catch(() => ({}))
2728
+ if (!valid()) return
2583
2729
  if (!res.ok || !Array.isArray(data.entries)) throw new Error(data.error || ('HTTP ' + res.status))
2584
2730
  state.fs.path = data.path
2585
2731
  if (!state.fs.initial) state.fs.initial = data.path
@@ -2601,6 +2747,7 @@ async function loadFs(dir, silent) {
2601
2747
  else window.open(fsApiUrl('/file', { path: row.dataset.fsPath, token: state.token }), '_blank')
2602
2748
  }))
2603
2749
  } catch (e) {
2750
+ if (!valid()) return
2604
2751
  $('fs-path').textContent = target || '~'
2605
2752
  $('fs-list').innerHTML = `<div class="ds-empty">${esc(e.message || t('ds.toastConnFailed'))}</div>`
2606
2753
  }
@@ -2742,17 +2889,22 @@ function commitWorkspaceSessionOrder(workspaceId, order) {
2742
2889
  toast(t('ds.wbOrderSaved'), 'ok')
2743
2890
  }
2744
2891
  async function refreshWorkbench({ silent = false } = {}) {
2892
+ const connection = captureConnection(), request = ++workbenchRequest
2893
+ const valid = () => connection.valid() && request === workbenchRequest
2745
2894
  if (!state.token) { renderWorkbench(); return }
2746
2895
  let wb = null
2747
2896
  try {
2748
2897
  wb = await wbGateway('GET', '/workbench')
2898
+ if (!valid()) return
2749
2899
  state.wb.apiMissing = false
2750
2900
  } catch (e) {
2901
+ if (!valid()) return
2751
2902
  if (e.message === 'AUTH') { toast(t('ds.toastAuth'), 'err'); return }
2752
2903
  if (!silent) toast(t('wb.loadFailed', { msg: e.message }), 'err')
2753
2904
  if (!state.wb.bound && /404/.test(e.message)) state.wb.apiMissing = true
2754
2905
  }
2755
2906
  const wl = await safeRpc('workspace.list', {}, '')
2907
+ if (!valid()) return
2756
2908
  state.archivedIds = wl && Array.isArray(wl.archivedSessionIds) ? wl.archivedSessionIds : []
2757
2909
  state.workspaces = wl && Array.isArray(wl.items) ? wl.items.slice() : []
2758
2910
  if (!wb) { renderWorkbench(); renderSessions(); return }
@@ -2769,8 +2921,10 @@ async function refreshWorkbench({ silent = false } = {}) {
2769
2921
  const items = Array.isArray(wl.items) ? wl.items.slice() : []
2770
2922
  try {
2771
2923
  const listRes = await fetch(fsApiUrl('/list', { path: state.wb.path }), { headers: fsHeaders() })
2924
+ if (!valid()) return
2772
2925
  if (listRes.ok) {
2773
2926
  const listData = await listRes.json().catch(() => ({}))
2927
+ if (!valid()) return
2774
2928
  if (Array.isArray(listData.entries)) {
2775
2929
  const diskDirs = new Set(listData.entries.filter(e => e.type === 'dir').map(e => wbPathKey(wbJoin(state.wb.path, e.name))))
2776
2930
  for (let i = items.length - 1; i >= 0; i--) {
@@ -2783,16 +2937,19 @@ async function refreshWorkbench({ silent = false } = {}) {
2783
2937
  if (have.has(wbPathKey(projectPath))) continue
2784
2938
  try {
2785
2939
  const created = await rpc('workspace.create', { path: projectPath })
2940
+ if (!valid()) return
2786
2941
  if (created?.workspace) {
2787
2942
  items.push(created.workspace)
2788
2943
  state.workspaces.push(created.workspace)
2789
2944
  have.add(wbPathKey(projectPath))
2790
2945
  }
2791
2946
  } catch {}
2947
+ if (!valid()) return
2792
2948
  }
2793
2949
  }
2794
2950
  }
2795
2951
  } catch {}
2952
+ if (!valid()) return
2796
2953
  state.wb.projects = orderedWorkspaceItems(items
2797
2954
  .filter(w => wbStrictInside(w.path, state.wb.path))
2798
2955
  .sort((a, b) => String(a.title || wbBaseName(a.path)).localeCompare(String(b.title || wbBaseName(b.path)), 'zh-CN', { numeric: true })))
@@ -2824,7 +2981,7 @@ function renderWorkbench() {
2824
2981
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
2825
2982
  html += projects.length ? projects.map(w => {
2826
2983
  const id = String(w.workspaceId || '')
2827
- const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isTopLevelSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a)))
2984
+ const sessions = orderedWorkspaceSessions(id, (w.sessionIds || []).map(sid => state.byId.get(sid)).filter(isVisibleSession).filter(s => !archivedSet.has(s.sessionId)).sort((a, b) => sessionSortTime(b) - sessionSortTime(a)))
2828
2985
  const open = state.wb.open === id
2829
2986
  return `<div class="ds-wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
2830
2987
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
@@ -3069,35 +3226,88 @@ function renderStats(days) {
3069
3226
  }
3070
3227
 
3071
3228
  /* ---------------- 视图与连接状态 ---------------- */
3229
+ let overviewDetector = null
3230
+ let overviewPulse = null
3231
+ function setOverviewText(id,value){const node=$(id);if(overviewPulse)overviewPulse.text(node,value);else if(node)node.textContent=value}
3232
+ let overviewStarting = true
3233
+ function overviewChecks() {
3234
+ const confirmed=overviewDetector?.model.gateway===true,health=activeGatewayHealth()
3235
+ return {
3236
+ gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)) && confirmed,
3237
+ dsh: dshReachable() && confirmed,
3238
+ mux: !!state.streamsOk?.mux && confirmed && health?.events?.mux?.connected!==false,
3239
+ host: !!state.streamsOk?.host && confirmed && health?.events?.host?.connected!==false
3240
+ }
3241
+ }
3242
+ function repairMissingStreams() {
3243
+ if(!state.token||!navigator.onLine||state.selectingServer||overviewStarting)return
3244
+ if(state.streamMode==='poll'){tryRestoreWs();return}
3245
+ for(const [kind,handler,refresh] of [['mux',onMuxFrame,true],['host',onHostFrame,false]]) {
3246
+ const ws=streams[kind]
3247
+ if((!ws||ws.readyState===WebSocket.CLOSED)&&!streamMeta[kind].retryTimer)openStream(kind,handler,refresh)
3248
+ }
3249
+ }
3250
+ async function probeOverviewLinks(signal) {
3251
+ const connection=captureConnection(),base=String(connection.server||location.origin).replace(/\/+$/,'')
3252
+ let timer
3253
+ const controller=new AbortController(),cancel=()=>controller.abort()
3254
+ signal.addEventListener('abort',cancel,{once:true});if(signal.aborted)controller.abort()
3255
+ timer=setTimeout(cancel,4000)
3256
+ const healthRequest=fetch(base+'/health?t='+Date.now(),{signal:controller.signal,cache:'no-store'}).then(async response=>{
3257
+ if(!response.ok)throw Error('health')
3258
+ const health=await response.json();if(health?.ok!==true)throw Error('health')
3259
+ return health
3260
+ }).catch(()=>null).finally(()=>{clearTimeout(timer);signal.removeEventListener('abort',cancel)})
3261
+ const [health,host]=await Promise.all([healthRequest,refreshHostDescriptionDesktop()])
3262
+ if(!connection.valid()||signal.aborted)throw Error('stale check')
3263
+ if(health){if(host)health.upstreamReachable=true;state.gatewayHealth[base]=health}
3264
+ else delete state.gatewayHealth[base]
3265
+ return {gateway:!!health}
3266
+ }
3267
+ function startOverviewDetection(force=false,identityChanged=false) {
3268
+ if(identityChanged)overviewDetector?.pause()
3269
+ if(!window.DshLinkCheck)return
3270
+ if(!overviewDetector)overviewDetector=new window.DshLinkCheck.LinkCheck({
3271
+ active:()=>document.visibilityState==='visible'&&!$('view-overview').classList.contains('hidden'),
3272
+ configured:()=>!!state.token&&(!!state.server||/^https?:$/.test(location.protocol)),online:()=>navigator.onLine,
3273
+ snapshot:overviewChecks,repair:repairMissingStreams,probe:probeOverviewLinks,changed:renderOverviewDesktop
3274
+ })
3275
+ overviewDetector.start(force)
3276
+ }
3277
+ window.addEventListener('online',()=>startOverviewDetection(true))
3278
+ window.addEventListener('offline',()=>startOverviewDetection(true))
3279
+ document.addEventListener('visibilitychange',()=>{if(document.visibilityState==='visible')startOverviewDetection(true);else {overviewDetector?.pause();overviewPulse?.pause()}})
3280
+
3072
3281
  function renderOverviewDesktop() {
3073
3282
  const ring = $('ds-overview-pulse-ring')
3074
3283
  if (!ring) return
3075
- const checks = {
3076
- // 桌面独立页面默认使用当前 origin,state.server 为空不代表网关离线。
3077
- gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
3078
- dsh: dshReachable(),
3079
- mux: !!state.streamsOk?.mux,
3080
- host: !!state.streamsOk?.host
3081
- }
3284
+ const checks = overviewChecks()
3285
+ const detection=overviewDetector?.model,checking=detection?.phase==='checking'||(detection?.phase==='degraded'&&detection?.probing===true)
3082
3286
  const online = Object.values(checks).filter(Boolean).length
3287
+ $('ds-overview-refresh').disabled=detection?.probing===true
3083
3288
  const status = online === 4 ? 'Nominal' : online > 0 ? 'Degraded' : 'Offline'
3084
3289
  const pulseCard = document.querySelector('.ds-overview-pulse-card')
3085
3290
  if (pulseCard) {
3291
+ if(!overviewPulse&&window.DshLinkCheck?.PulseVisual)overviewPulse=new window.DshLinkCheck.PulseVisual({card:pulseCard,active:()=>document.visibilityState==='visible'&&!$('view-overview').classList.contains('hidden')})
3292
+ overviewPulse?.setChecking(checking)
3293
+ overviewPulse?.setLinks(checks)
3086
3294
  pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
3295
+ pulseCard.classList.toggle('status-checking',checking)
3296
+ pulseCard.setAttribute('aria-busy',String(checking))
3087
3297
  pulseCard.classList.add('status-' + status.toLowerCase())
3088
3298
  }
3089
- ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
3090
- $('ds-overview-health').textContent = online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore')
3091
- $('ds-overview-health-caption').textContent = online === 4 ? t('ds.allLinked') : online ? t('ds.components', { n: online }) : t('ds.offlineShort')
3092
- $('ds-overview-status').textContent = t(`ds.system${status}`)
3093
- $('ds-overview-status-desc').textContent = t('ds.components', { n: online })
3299
+ setOverviewText('ds-overview-health',checking ? t('ds.checking') : online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore'))
3300
+ setOverviewText('ds-overview-health-caption',checking ? `${online}/4` : online === 4 ? t('ds.allLinked') : online ? t('ds.confirmedShort') : t('ds.offlineShort'))
3301
+ setOverviewText('ds-overview-status',checking ? t('ds.checking') : t(`ds.system${status}`))
3302
+ setOverviewText('ds-overview-status-desc',checking ? t('ds.autoChecking',{n:online}) : detection?.phase==='degraded' ? t('ds.autoRetry',{n:online}) : t('ds.components',{n:online}))
3094
3303
  for (const [name, ok] of Object.entries(checks)) {
3095
3304
  const item = document.querySelector(`[data-ds-overview-link="${name}"]`)
3096
3305
  if (!item) continue
3097
3306
  item.classList.toggle('ok', ok)
3098
- item.classList.toggle('off', !ok)
3307
+ item.classList.toggle('off', !ok&&!checking)
3308
+ item.classList.toggle('pending',!ok&&checking)
3099
3309
  const value = item.querySelector('b')
3100
- if (value) value.textContent = ok ? t('ds.online') : t('ds.offlineShort')
3310
+ if (value) setOverviewText(value.id || (value.id=`ds-overview-link-${name}`), ok ? t('ds.online') : checking ? t('ds.checkingShort') : t('ds.offlineShort'))
3101
3311
  }
3102
3312
 
3103
3313
  const pending = [
@@ -3169,19 +3379,28 @@ function setMobileSidebar(open) {
3169
3379
  $('ds-app')?.classList.toggle('mobile-nav-open', visible)
3170
3380
  }
3171
3381
 
3382
+ function openDesktopPlugins() {
3383
+ const connection=captureConnection()
3384
+ if(!connection.token){$('desktop-plugin-page').textContent=t('ds.toastAuth');return}
3385
+ window.DshPluginCenter.mount({url:path=>connection.server+path,headers:{authorization:'Bearer '+connection.token,'x-dsh-remote-client':'web',...clientIdHeaders()},valid:connection.valid},$('desktop-plugin-page'))
3386
+ }
3387
+
3172
3388
  function showView(id) {
3173
3389
  if (state.view === 'view-chat' && id !== 'view-chat' && state.current) void archiveEmptySessionOnLeave(state.current)
3174
3390
  state.view = id
3175
- for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
3391
+ for (const v of ['view-overview', 'view-sessions', 'view-chat', 'view-files', 'view-plugins', 'view-settings']) $(v).classList.toggle('hidden', v !== id)
3176
3392
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
3177
3393
  if (narrowSidebarQuery.matches) setMobileSidebar(false)
3178
3394
  window.DshMotion?.view($(id))
3179
- const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings' }
3395
+ const titles = { 'view-overview': 'ds.overview', 'view-sessions': 'ds.sessions', 'view-chat': 'ds.sessions', 'view-files': 'ds.files', 'view-settings': 'ds.settings', 'view-plugins': 'ds.plugins' }
3180
3396
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
3181
3397
  else $('ds-title').textContent = t(titles[id])
3182
- if (id === 'view-overview') renderOverviewDesktop()
3398
+ if (id === 'view-overview') {startOverviewDetection(true);renderOverviewDesktop()}
3399
+ else {overviewDetector?.pause();overviewPulse?.pause()}
3183
3400
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
3184
3401
  if (id === 'view-settings') showSettingsHome()
3402
+ if (id === 'view-plugins') openDesktopPlugins()
3403
+ else window.DshPluginCenter?.close()
3185
3404
  updateSessionActions()
3186
3405
  }
3187
3406
 
@@ -3190,9 +3409,14 @@ function updateSessionActions() {
3190
3409
  $('btn-rename-session')?.classList.toggle('hidden', !active)
3191
3410
  $('btn-archive-session')?.classList.toggle('hidden', !active || state.archivedIds.includes(state.current))
3192
3411
  $('ds-session-status')?.classList.toggle('hidden', !active || !recoveryLabel())
3412
+ if (!active) $('ds-workspace')?.classList.add('hidden')
3193
3413
  if (active) {
3194
3414
  const s = state.byId.get(state.current)
3195
3415
  $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions')
3416
+ const workspace = s?.cwd || ''
3417
+ $('ds-workspace').textContent = '📁 ' + (workspace.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || t('ds.noWorkspace')) + ' ›'
3418
+ $('ds-workspace').title = workspace
3419
+ $('ds-workspace').classList.remove('hidden')
3196
3420
  $('ds-session-status').textContent = recoveryLabel()
3197
3421
  }
3198
3422
  const running = !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
@@ -3214,6 +3438,7 @@ function showSettingsPage(name) {
3214
3438
  if (name === 'general') renderPresetSummary()
3215
3439
  }
3216
3440
  function updateConn() {
3441
+ overviewDetector?.observe()
3217
3442
  const el = $('conn-badge')
3218
3443
  // 双实时通道可能按任意顺序打开,连接刷新必须同时刷新系统总览。
3219
3444
  renderOverviewDesktop()
@@ -3259,7 +3484,23 @@ function updateConn() {
3259
3484
  }
3260
3485
 
3261
3486
  /* ---------------- 初始化 ---------------- */
3487
+
3488
+ function bindSessionDialogs() {
3489
+ const actions=$('session-actions'), info=$('session-info');
3490
+ $('btn-session-more').addEventListener('click',()=>actions.showModal());
3491
+ actions.addEventListener('click',e=>{if(e.target===actions)actions.close();else if(e.target.closest('button'))actions.close()},true);
3492
+ 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('ds.noWorkspace');$('session-info-id').textContent=s.sessionId;$('btn-copy-session-workspace').disabled=!s.cwd;info.showModal()};
3493
+ $('ds-title').addEventListener('click',openInfo);
3494
+ $('ds-workspace').addEventListener('click',openInfo);
3495
+ $('btn-session-info').addEventListener('click',openInfo);
3496
+ info.addEventListener('click',e=>{if(e.target===info||e.target.closest('[data-session-dialog-close]'))info.close()});
3497
+ $('btn-copy-session-workspace').addEventListener('click',async()=>{const value=state.byId.get(state.current)?.cwd;if(value&&await copyText(value))toast(t('ds.copied'),'ok')});
3498
+ }
3499
+
3262
3500
  function bindUi() {
3501
+ bindSessionDialogs()
3502
+ getConversationView()
3503
+ $('btn-history-latest').addEventListener('click',()=>{getConversationView().latest();if(state.history.tailEvicted)void loadHistory(true)})
3263
3504
  $('btn-new-session').addEventListener('click', openNewSessionModal)
3264
3505
  $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
3265
3506
  $('session-sort')?.addEventListener('change', (e) => {
@@ -3277,14 +3518,7 @@ function bindUi() {
3277
3518
  }
3278
3519
  })
3279
3520
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
3280
- $('ds-overview-refresh').addEventListener('click', async () => {
3281
- toast(t('ds.loading'))
3282
- if (state.token) {
3283
- await refreshSessions()
3284
- await refreshHostDescriptionDesktop({ notify: true })
3285
- }
3286
- renderOverviewDesktop()
3287
- })
3521
+ $('ds-overview-refresh').addEventListener('click',()=>{startOverviewDetection(true);if(state.token)void refreshSessions()})
3288
3522
  $('ds-overview-primary-action').addEventListener('click', () => {
3289
3523
  const button = $('ds-overview-primary-action')
3290
3524
  const action = button.dataset.dsOverviewAction
@@ -3369,6 +3603,7 @@ function bindUi() {
3369
3603
  if (!e.target.closest('#model-menu') && !e.target.closest('#btn-model')) $('model-menu')?.classList.add('hidden')
3370
3604
  })
3371
3605
  $('btn-stats-top').addEventListener('click', toggleStatsDrawer)
3606
+ $('btn-insights').addEventListener('click', () => openInsights())
3372
3607
  $('stats-drawer-close').addEventListener('click', toggleStatsDrawer)
3373
3608
  // 赞赏支持
3374
3609
  $('btn-donate').addEventListener('click', openDonateModal)
@@ -3511,23 +3746,13 @@ async function start() {
3511
3746
  checkNotesOnStart()
3512
3747
  if (state.token) {
3513
3748
  await selectFastestServer({ silent: true, reconnect: false })
3749
+ overviewStarting=false
3514
3750
  openStreams()
3515
- await refreshSessions()
3516
- await refreshHostDescriptionDesktop()
3751
+ await Promise.all([refreshSessions(),refreshHostDescriptionDesktop()])
3517
3752
  refreshWorkbench({ silent: true })
3518
3753
  }
3754
+ overviewStarting=false
3519
3755
  renderOverviewDesktop()
3520
3756
  }
3521
3757
 
3522
3758
  start()
3523
-
3524
- // Capture the connection so switching servers cannot redirect a plugin mutation.
3525
- document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
3526
- const server = state.server || ''
3527
- const token = state.token
3528
- window.DshPluginCenter.open({
3529
- url: path => server + path,
3530
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
3531
- valid: () => (state.server || '') === server && state.token === token,
3532
- })
3533
- })