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.
@@ -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', ''),
@@ -114,11 +114,20 @@ for (const key of ['server', 'token']) {
114
114
  if (next === value) return
115
115
  value = next; connectionGeneration++
116
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')}
117
119
  state.current = null; state.history = emptyDesktopHistory()
118
120
  state.fs.path = null; state.fs.initial = null; state.fs.loaded = false; state.fs.roots = []; state.fs.rootIndex = 0
119
121
  state.workspaces = []; state.archivedIds = []; state.wb = { bound: false, path: '', projects: null }
120
122
  window.DshPluginCenter?.close()
121
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
+ }
122
131
  if (typeof document !== 'undefined') document.getElementById('modal-workspace')?.classList.add('hidden')
123
132
  },
124
133
  })
@@ -302,7 +311,7 @@ function renderModelMenu() {
302
311
  if (state.models.loading) { box.innerHTML = `<div class="ds-model-head">${t('menu.modelTitle')}</div><span>${t('models.loading')}</span>`; return }
303
312
  const groups = state.models.groups || []
304
313
  if (!groups.length) {
305
- 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>`
306
315
  return
307
316
  }
308
317
  const cur = state.models.current
@@ -414,6 +423,9 @@ function openFeedbackModal() {
414
423
  $('fb-msg').value = ''
415
424
  $('fb-contact').value = ''
416
425
  $('fb-include-diagnostics').checked = false
426
+ $('fb-include-crashes').checked = false
427
+ $('fb-crash-details').open = false
428
+ void loadCrashPreview()
417
429
  $('modal-feedback').classList.remove('hidden')
418
430
  setTimeout(() => $('fb-msg').focus(), 50)
419
431
  }
@@ -534,11 +546,22 @@ async function checkNotesOnStart() {
534
546
  openNotesModal(info)
535
547
  } catch {}
536
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
+ }
537
559
  async function submitFeedback() {
538
560
  const type = document.querySelector('#fb-chips .ds-fb-chip.current')?.dataset.fbType || 'bug'
539
561
  const message = $('fb-msg').value.trim()
540
562
  const contact = $('fb-contact').value.trim()
541
563
  const includeDiagnostics = $('fb-include-diagnostics').checked
564
+ const includeCrashLogs = $('fb-include-crashes').checked
542
565
  if (!message) { toast(t('ds.feedbackEmpty'), 'err'); return }
543
566
  if (message.length > 2000) { toast(t('ds.feedbackTooLong'), 'err'); return }
544
567
  const btn = $('fb-submit')
@@ -547,7 +570,7 @@ async function submitFeedback() {
547
570
  const res = await fetch(apiUrl('/feedback'), {
548
571
  method: 'POST',
549
572
  headers: { 'content-type': 'application/json', authorization: 'Bearer ' + state.token },
550
- body: JSON.stringify({ type, message, contact, appVersion: '', includeDiagnostics })
573
+ body: JSON.stringify({ type, message, contact, appVersion: '', includeDiagnostics, includeCrashLogs })
551
574
  })
552
575
  let json = {}
553
576
  try { json = await res.json() } catch {}
@@ -643,6 +666,7 @@ async function safeRpc(method, payload, errText) {
643
666
  }
644
667
  }
645
668
  let hostDescribePromise = null
669
+ let hostDescribeConnection = null
646
670
  let hostDescribeRetryTimer = null
647
671
  let hostDescribeFailures = 0
648
672
 
@@ -658,12 +682,13 @@ function scheduleHostDescribeRetryDesktop() {
658
682
 
659
683
  async function refreshHostDescriptionDesktop({ notify = false } = {}) {
660
684
  if (!state.token) return null
661
- if (hostDescribePromise) return hostDescribePromise
662
- const server = state.server
663
- hostDescribePromise = (async () => {
685
+ if (hostDescribePromise && hostDescribeConnection?.valid()) return hostDescribePromise
686
+ const connection = captureConnection()
687
+ hostDescribeConnection = connection
688
+ const job = (async () => {
664
689
  try {
665
690
  const host = await rpc('host.describe', {}, 5000)
666
- if (server !== state.server) return null
691
+ if (!connection.valid()) return null
667
692
  state.hostInfo = host
668
693
  const health = activeGatewayHealth()
669
694
  if (health) health.upstreamReachable = true
@@ -673,17 +698,17 @@ async function refreshHostDescriptionDesktop({ notify = false } = {}) {
673
698
  renderOverviewDesktop()
674
699
  return host
675
700
  } catch (error) {
676
- if (server !== state.server) return null
701
+ if (!connection.valid()) return null
677
702
  hostDescribeFailures++
678
703
  scheduleHostDescribeRetryDesktop()
679
704
  if (notify) toast(error.message === 'AUTH' ? t('ds.toastAuth') : error.message, 'err')
680
705
  return null
681
706
  } finally {
682
- hostDescribePromise = null
683
- if (server !== state.server) scheduleHostDescribeRetryDesktop()
707
+ if (hostDescribePromise === job) { hostDescribePromise = null; hostDescribeConnection = null }
684
708
  }
685
709
  })()
686
- return hostDescribePromise
710
+ hostDescribePromise = job
711
+ return job
687
712
  }
688
713
  function uuid() {
689
714
  try { return crypto.randomUUID() } catch { return 'id-' + Date.now() + '-' + Math.random().toString(36).slice(2) }
@@ -1192,12 +1217,12 @@ function openStreams() {
1192
1217
  function openStream(kind, handler, refreshOnOpen, isRestore, ticket = null) {
1193
1218
  if (!state.token) return
1194
1219
  if (ticket === null) {
1195
- const token = state.token
1220
+ const token = state.token, connection=captureConnection()
1196
1221
  void getWsTicket().then((value) => {
1197
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, value)
1222
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, value)
1198
1223
  }).catch(() => {
1199
1224
  // 兼容旧网关/插件副本: ticket 接口不可用时临时回退旧 token 握手。
1200
- if (state.token === token) openStream(kind, handler, refreshOnOpen, isRestore, '')
1225
+ if (connection.valid()) openStream(kind, handler, refreshOnOpen, isRestore, '')
1201
1226
  })
1202
1227
  return
1203
1228
  }
@@ -1378,7 +1403,7 @@ window.addEventListener('online', () => {
1378
1403
  document.addEventListener('visibilitychange', () => {
1379
1404
  if (document.visibilityState === 'visible' && state.token &&
1380
1405
  (streams.mux?.readyState !== WebSocket.OPEN || streams.host?.readyState !== WebSocket.OPEN)) {
1381
- openStreams()
1406
+ repairMissingStreams()
1382
1407
  }
1383
1408
  })
1384
1409
 
@@ -1585,7 +1610,11 @@ function sessionLabelOf(s) {
1585
1610
  function isTopLevelSession(session) {
1586
1611
  return !!session && !session.parentSessionId && session.origin !== 'subagent'
1587
1612
  }
1588
- 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) }
1589
1618
  const GOAL_TERMINAL_PHASES = new Set(['complete', 'cleared'])
1590
1619
  function isGoalTerminal(goal) {
1591
1620
  return !!goal && GOAL_TERMINAL_PHASES.has(goal.phase)
@@ -1620,6 +1649,7 @@ function onSessionEvent(sessionId, event) {
1620
1649
  renderSessions()
1621
1650
  }
1622
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()}
1623
1653
  if (event?.type === 'agent/status' && session) {
1624
1654
  session.running = !!event.data?.running
1625
1655
  if (state.current === sessionId) { renderQueue(); updateComposerStatus() }
@@ -1636,7 +1666,9 @@ function onSessionEvent(sessionId, event) {
1636
1666
  h.seqs.add(seq)
1637
1667
  h.visible.push({ seq, event })
1638
1668
  h.visible.sort((a, b) => a.seq - b.seq)
1669
+ trimVisible()
1639
1670
  renderHistory()
1671
+ scheduleHistoryCacheSave()
1640
1672
  }
1641
1673
  if (String(event.type || '').startsWith('goal/') || String(event.type || '').startsWith('todo/')) renderSessionCards()
1642
1674
  }
@@ -1644,6 +1676,7 @@ function onSessionEvent(sessionId, event) {
1644
1676
 
1645
1677
  /* ---------------- 会话 ---------------- */
1646
1678
  async function refreshSessions() {
1679
+ if(typeof restoreCachedSessionList==='function')restoreCachedSessionList()
1647
1680
  const connection = captureConnection(), request = ++sessionsRequest
1648
1681
  const v = await safeRpc('session.list', {}, '')
1649
1682
  if (!connection.valid() || request !== sessionsRequest) return
@@ -1651,6 +1684,7 @@ async function refreshSessions() {
1651
1684
  state.sessions = v.items || []
1652
1685
  state.byId = new Map(state.sessions.map(s => [s.sessionId, s]))
1653
1686
  applyPendingProjections()
1687
+ try{LS.set(historyCacheScope()+':sessions',JSON.stringify(state.sessions.slice(0,80)))}catch{}
1654
1688
  renderSessions()
1655
1689
  scheduleWorkbenchRefresh()
1656
1690
  renderOverviewDesktop()
@@ -1771,9 +1805,9 @@ function renderSessions() {
1771
1805
  }
1772
1806
 
1773
1807
  const emptySessionCleanup = new Set()
1774
- async function archiveEmptySessionOnLeave(sessionId) {
1775
- const base = state.server
1776
- 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)
1777
1811
  || !state.byId.has(sessionId) || state.byId.get(sessionId)?.running
1778
1812
  || state.sessionActivity?.has(sessionId) || state.pendingPrompts?.has(sessionId)
1779
1813
  || (state.queues[sessionId] || []).length > 0
@@ -1794,14 +1828,16 @@ async function archiveEmptySessionOnLeave(sessionId) {
1794
1828
  }
1795
1829
 
1796
1830
  async function openSession(id) {
1797
- if (state.current && state.current !== id) await archiveEmptySessionOnLeave(state.current)
1831
+ if (state.current && state.current !== id) void archiveEmptySessionOnLeave(state.current,true)
1798
1832
  state.current = id
1799
1833
  setSessionRecovery('loading')
1800
1834
  state.history = emptyDesktopHistory()
1835
+ const openingHistory=state.history, openingConnection=captureConnection()
1801
1836
  state.models = { loaded: false, loading: false, groups: [], current: null, failures: [] }
1802
1837
  showView('view-chat')
1803
1838
  $('ds-title').textContent = titleOf(state.byId.get(id)) || t('ds.sessions')
1804
1839
  updateSessionActions()
1840
+ getConversationView().reset()
1805
1841
  $('history').innerHTML = `<div class="ds-empty">${t('ds.historyLoading')}</div>`
1806
1842
  renderSessions()
1807
1843
  renderSessionCards()
@@ -1809,6 +1845,8 @@ async function openSession(id) {
1809
1845
  renderSessionPendingDesktop()
1810
1846
  updateComposerStatus()
1811
1847
  void refreshCompactionStatus(id)
1848
+ await restoreCachedHistory()
1849
+ if(state.current!==id||state.history!==openingHistory||!openingConnection.valid())return
1812
1850
  await loadHistory()
1813
1851
  }
1814
1852
  async function closeSession() {
@@ -1826,53 +1864,112 @@ async function closeSession() {
1826
1864
  updateSessionActions()
1827
1865
  showView('view-sessions')
1828
1866
  }
1829
- async function loadHistory() {
1830
- const connection = captureConnection()
1831
- const id = state.current
1832
- if (!id || state.history.loading) return
1833
- const history = state.history
1834
- const reasoningVersion = history.reasoningVersion || 0
1835
- state.history.loading = true
1836
- 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()
1837
1931
  let v
1838
- try { v = await rpc('session.history', { sessionId: id, maxMessages: 60 }) }
1839
- catch (e) {
1840
- if (!connection.valid() || state.current !== id || state.history !== history) return
1841
- state.history.loading = false
1842
- if (e.message === 'AUTH') return
1843
- setSessionRecovery('error', e.message)
1844
- $('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)
1845
1940
  return
1846
1941
  }
1847
- if (!connection.valid() || state.current !== id || state.history !== history) return
1848
- const liveReasoning = (history.reasoningVersion || 0) !== reasoningVersion ? new Map(history.partialReasoning) : null
1849
- 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=[]
1850
1946
  for (const entry of v.events || []) {
1851
- const ev = entry?.event
1852
- const seq = ev?.seq
1853
- if (ev?.type === 'turn/start' || ev?.type === 'turn/end') noteSessionTurnTime(id, ev)
1947
+ const ev=entry?.event
1854
1948
  applyReasoningStreamEvent(ev)
1855
- if (seq == null || state.history.seqs.has(seq)) continue
1856
- if (!shouldShowEvent(ev.type, ev)) continue
1857
- state.history.seqs.add(seq)
1858
- state.history.visible.push({ seq, event: ev })
1859
- }
1860
- state.history.visible.sort((a, b) => a.seq - b.seq)
1861
- if (liveReasoning) history.partialReasoning = liveReasoning
1862
- else applyReasoningBaseline(v.partialReasoning)
1863
- state.history.hasMore = !!v.hasMore
1864
- state.history.loading = false
1865
- setSessionRecovery('ready')
1866
- updateSessionActions()
1867
- 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()
1868
1964
  }
1869
1965
 
1966
+
1870
1967
  const INTERESTING_EVENTS = new Set([
1871
1968
  'user/message', 'assistant/message', 'tool/call', 'tool/result',
1872
1969
  'agent/status', 'checkpoint/created', 'compaction/complete', 'compaction/summary',
1873
1970
  'goal/created', 'goal/updated', 'goal/completed', 'goal/cleared',
1874
1971
  'todo/updated', 'plan/updated', 'question/asked', 'question/resolved',
1875
- 'approval/asked', 'approval/resolved', 'session/title', 'title'
1972
+ 'approval/asked', 'approval/resolved'
1876
1973
  ])
1877
1974
  function messageSource(data) {
1878
1975
  const source = data?.source ?? data?.message?.source
@@ -1997,12 +2094,8 @@ function eventHtml(entry) {
1997
2094
  if (type === 'question/asked') return `<div class="ds-tool">❓ ${esc(data.question || '')}</div>`
1998
2095
  return `<div class="ds-tool">${esc(type)}</div>`
1999
2096
  }
2000
- function renderHistory() {
2001
- const box = $('history')
2002
- const items = state.history.visible
2003
- const html = items.map(eventHtml).join('') + partialReasoningHtml()
2004
- box.innerHTML = html || `<div class="ds-empty">${t('ds.historyEmpty')}</div>`
2005
- 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')})
2006
2099
  }
2007
2100
 
2008
2101
  /* ---------------- 会话信息卡(goal / todo / 子代理) ---------------- */
@@ -2888,7 +2981,7 @@ function renderWorkbench() {
2888
2981
  let html = `<div class="ds-wb-panel-title">${esc(t('wb.projects'))}</div>`
2889
2982
  html += projects.length ? projects.map(w => {
2890
2983
  const id = String(w.workspaceId || '')
2891
- 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)))
2892
2985
  const open = state.wb.open === id
2893
2986
  return `<div class="ds-wb-project ${open ? 'open' : ''}" data-wb-project="${esc(id)}" data-motion-key="${esc(id)}">
2894
2987
  <button type="button" class="ds-wb-project-head" data-wb-head="${esc(id)}">
@@ -3133,35 +3226,88 @@ function renderStats(days) {
3133
3226
  }
3134
3227
 
3135
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
+
3136
3281
  function renderOverviewDesktop() {
3137
3282
  const ring = $('ds-overview-pulse-ring')
3138
3283
  if (!ring) return
3139
- const checks = {
3140
- // 桌面独立页面默认使用当前 origin,state.server 为空不代表网关离线。
3141
- gateway: !!state.token && (!!state.server || /^https?:$/.test(location.protocol)),
3142
- dsh: dshReachable(),
3143
- mux: !!state.streamsOk?.mux,
3144
- host: !!state.streamsOk?.host
3145
- }
3284
+ const checks = overviewChecks()
3285
+ const detection=overviewDetector?.model,checking=detection?.phase==='checking'||(detection?.phase==='degraded'&&detection?.probing===true)
3146
3286
  const online = Object.values(checks).filter(Boolean).length
3287
+ $('ds-overview-refresh').disabled=detection?.probing===true
3147
3288
  const status = online === 4 ? 'Nominal' : online > 0 ? 'Degraded' : 'Offline'
3148
3289
  const pulseCard = document.querySelector('.ds-overview-pulse-card')
3149
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)
3150
3294
  pulseCard.classList.remove('status-nominal', 'status-degraded', 'status-offline')
3295
+ pulseCard.classList.toggle('status-checking',checking)
3296
+ pulseCard.setAttribute('aria-busy',String(checking))
3151
3297
  pulseCard.classList.add('status-' + status.toLowerCase())
3152
3298
  }
3153
- ring.style.setProperty('--pulse-pct', `${online / 4 * 100}%`)
3154
- $('ds-overview-health').textContent = online === 4 ? t('ds.live') : online ? `${online}/4` : t('ds.offlineCore')
3155
- $('ds-overview-health-caption').textContent = online === 4 ? t('ds.allLinked') : online ? t('ds.components', { n: online }) : t('ds.offlineShort')
3156
- $('ds-overview-status').textContent = t(`ds.system${status}`)
3157
- $('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}))
3158
3303
  for (const [name, ok] of Object.entries(checks)) {
3159
3304
  const item = document.querySelector(`[data-ds-overview-link="${name}"]`)
3160
3305
  if (!item) continue
3161
3306
  item.classList.toggle('ok', ok)
3162
- item.classList.toggle('off', !ok)
3307
+ item.classList.toggle('off', !ok&&!checking)
3308
+ item.classList.toggle('pending',!ok&&checking)
3163
3309
  const value = item.querySelector('b')
3164
- 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'))
3165
3311
  }
3166
3312
 
3167
3313
  const pending = [
@@ -3233,19 +3379,28 @@ function setMobileSidebar(open) {
3233
3379
  $('ds-app')?.classList.toggle('mobile-nav-open', visible)
3234
3380
  }
3235
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
+
3236
3388
  function showView(id) {
3237
3389
  if (state.view === 'view-chat' && id !== 'view-chat' && state.current) void archiveEmptySessionOnLeave(state.current)
3238
3390
  state.view = id
3239
- 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)
3240
3392
  document.querySelectorAll('.ds-nav-item').forEach(b => b.classList.toggle('active', b.dataset.view === id))
3241
3393
  if (narrowSidebarQuery.matches) setMobileSidebar(false)
3242
3394
  window.DshMotion?.view($(id))
3243
- 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' }
3244
3396
  if (id === 'view-chat') { const s = state.byId.get(state.current); $('ds-title').textContent = s ? titleOf(s) : t('ds.sessions') }
3245
3397
  else $('ds-title').textContent = t(titles[id])
3246
- if (id === 'view-overview') renderOverviewDesktop()
3398
+ if (id === 'view-overview') {startOverviewDetection(true);renderOverviewDesktop()}
3399
+ else {overviewDetector?.pause();overviewPulse?.pause()}
3247
3400
  if (id === 'view-files' && !state.fs.loaded) loadFs(null, true)
3248
3401
  if (id === 'view-settings') showSettingsHome()
3402
+ if (id === 'view-plugins') openDesktopPlugins()
3403
+ else window.DshPluginCenter?.close()
3249
3404
  updateSessionActions()
3250
3405
  }
3251
3406
 
@@ -3254,9 +3409,14 @@ function updateSessionActions() {
3254
3409
  $('btn-rename-session')?.classList.toggle('hidden', !active)
3255
3410
  $('btn-archive-session')?.classList.toggle('hidden', !active || state.archivedIds.includes(state.current))
3256
3411
  $('ds-session-status')?.classList.toggle('hidden', !active || !recoveryLabel())
3412
+ if (!active) $('ds-workspace')?.classList.add('hidden')
3257
3413
  if (active) {
3258
3414
  const s = state.byId.get(state.current)
3259
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')
3260
3420
  $('ds-session-status').textContent = recoveryLabel()
3261
3421
  }
3262
3422
  const running = !!state.byId.get(state.current)?.running || (state.queues[state.current] || []).some(i => i.placement !== 'context')
@@ -3278,6 +3438,7 @@ function showSettingsPage(name) {
3278
3438
  if (name === 'general') renderPresetSummary()
3279
3439
  }
3280
3440
  function updateConn() {
3441
+ overviewDetector?.observe()
3281
3442
  const el = $('conn-badge')
3282
3443
  // 双实时通道可能按任意顺序打开,连接刷新必须同时刷新系统总览。
3283
3444
  renderOverviewDesktop()
@@ -3323,7 +3484,23 @@ function updateConn() {
3323
3484
  }
3324
3485
 
3325
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
+
3326
3500
  function bindUi() {
3501
+ bindSessionDialogs()
3502
+ getConversationView()
3503
+ $('btn-history-latest').addEventListener('click',()=>{getConversationView().latest();if(state.history.tailEvicted)void loadHistory(true)})
3327
3504
  $('btn-new-session').addEventListener('click', openNewSessionModal)
3328
3505
  $('btn-new-workspace').addEventListener('click', openWorkspaceModal)
3329
3506
  $('session-sort')?.addEventListener('change', (e) => {
@@ -3341,14 +3518,7 @@ function bindUi() {
3341
3518
  }
3342
3519
  })
3343
3520
  document.querySelectorAll('.ds-nav-item').forEach(b => b.addEventListener('click', () => showView(b.dataset.view)))
3344
- $('ds-overview-refresh').addEventListener('click', async () => {
3345
- toast(t('ds.loading'))
3346
- if (state.token) {
3347
- await refreshSessions()
3348
- await refreshHostDescriptionDesktop({ notify: true })
3349
- }
3350
- renderOverviewDesktop()
3351
- })
3521
+ $('ds-overview-refresh').addEventListener('click',()=>{startOverviewDetection(true);if(state.token)void refreshSessions()})
3352
3522
  $('ds-overview-primary-action').addEventListener('click', () => {
3353
3523
  const button = $('ds-overview-primary-action')
3354
3524
  const action = button.dataset.dsOverviewAction
@@ -3576,24 +3746,13 @@ async function start() {
3576
3746
  checkNotesOnStart()
3577
3747
  if (state.token) {
3578
3748
  await selectFastestServer({ silent: true, reconnect: false })
3749
+ overviewStarting=false
3579
3750
  openStreams()
3580
- await refreshSessions()
3581
- await refreshHostDescriptionDesktop()
3751
+ await Promise.all([refreshSessions(),refreshHostDescriptionDesktop()])
3582
3752
  refreshWorkbench({ silent: true })
3583
3753
  }
3754
+ overviewStarting=false
3584
3755
  renderOverviewDesktop()
3585
3756
  }
3586
3757
 
3587
3758
  start()
3588
-
3589
- // Capture the connection so switching servers cannot redirect a plugin mutation.
3590
- document.getElementById('btn-plugin-center')?.addEventListener('click', () => {
3591
- const connection = captureConnection()
3592
- const server = connection.server || ''
3593
- const token = connection.token
3594
- window.DshPluginCenter.open({
3595
- url: path => server + path,
3596
- headers: { authorization: 'Bearer ' + token, 'x-dsh-remote-client': 'web', ...clientIdHeaders() },
3597
- valid: connection.valid,
3598
- })
3599
- })