dsh-session-flow 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,1913 @@
1
+ // lib/client.js — dsh-session-flow 浏览器半。
2
+ //
3
+ // 挂载两个 DOM 面:
4
+ // 1) 侧边栏入口行(「会话流」按钮,DOM 级注入 + MutationObserver 自愈,
5
+ // 模式参照 dsh-client-ui-task-board 的 sidebar-entry);
6
+ // 2) 中栏全页视图(容器追加进 [data-pane="conversation"],
7
+ // <html data-dsh-session-flow-active> 切换可见性,React root 渲染)。
8
+ //
9
+ // 视图:M2 会话总览页 + M3 折叠时间线详情页(回合/步骤/工具链折叠、产物内联、锚点定位)。
10
+ // 跨插件激活协调:与 taskboard / ssh 面板互斥(dsh-panel-activate 事件 + 移除对方 html 属性)。
11
+ // 失败策略:挂载问题只记日志,绝不让 GUI 启动失败。
12
+ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
13
+ var module = { exports: {} }; var exports = module.exports;
14
+
15
+ const React = require('react')
16
+ const { useState, useEffect, useMemo, useRef } = React
17
+ const h = React.createElement
18
+ const { createRoot } = require('react-dom/client')
19
+
20
+ // ── 常量 ────────────────────────────────────────────────────────────
21
+ const ENTRY_SELECTOR = '[data-dsh-session-flow-entry]'
22
+ const VIEW_SELECTOR = '[data-dsh-session-flow-view]'
23
+ const ACTIVE_ATTR = 'data-dsh-session-flow-active'
24
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
25
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
26
+ const PANEL_NAME = 'sessionflow'
27
+ const CONVERSATION_SELECTOR = '[data-pane="conversation"]'
28
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
29
+ const ACTIVE_WINDOW_MS = 15 * 60 * 1000 // 最近 15 分钟有事件 → 视为进行中(归档/总览徽标用)
30
+
31
+ let LOCALE = 'en'
32
+ try {
33
+ const nl = String(navigator.language || navigator.userLanguage || '')
34
+ if (nl.toLowerCase().startsWith('zh')) LOCALE = 'zh'
35
+ } catch (e) {}
36
+
37
+ const STR = LOCALE === 'zh' ? {
38
+ entry: '会话流', title: '会话流', rescan: '重新扫描', scanning: '扫描中…',
39
+ search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
40
+ sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
41
+ noMatch: '没有匹配的会话', noData: '暂无会话数据',
42
+ running: '进行中', ended: '已结束', subagent: '子代理',
43
+ turns: '回合', steps: '步骤', tools: '工具', errors: '错误', msgs: '消息',
44
+ duration: '耗时', created: '创建', size: '大小', records: '记录',
45
+ jumpFailed: '无法跳转(会话不在当前列表)', unknownTitle: '(未命名会话)',
46
+ loadFailed: '加载失败',
47
+ back: '返回', close: '关闭', jumpNative: '跳转原生会话', expandAll: '全部展开', collapseAll: '全部折叠',
48
+ exportMd: '导出报告 (ZIP)', exporting: '导出中…', exportFail: '导出失败',
49
+ live: '实时', liveActive: '实时 · 进行中', liveExit: '退出实时', liveLoading: '实时加载…',
50
+ liveFail: '实时不可用', liveUnavailable: '(实时通道不可用:连接不可用)',
51
+ liveEvents: '事件', liveRefreshing: '刷新…', liveLastActive: '最后活动',
52
+ liveTurn: '运行中', liveGenerating: '正在生成…',
53
+ detailLoading: '正在解析会话…', noTimeline: '无可展示的时间线', artifacts: '产物',
54
+ noArtifacts: '(未提取到产物路径)', user: '用户', assistant: '助手', step: '步骤',
55
+ args: '参数', result: '结果', thinking: '思考', expanded: '展开', collapsed: '收起',
56
+ detailFailed: '会话详情加载失败', showEmpty: '显示空会话', emptySession: '空会话',
57
+ emptyHint: '此会话已创建但尚未开始对话', userNav: '用户发言', noUserNav: '(无用户发言)',
58
+ conclusion: '结论', injected: '注入', lineage: '血缘', noLineage: '(无子代理血缘信息)',
59
+ liveTree: '运行时血缘', offlineTree: '档案血缘', modeOneShot: '一次性', modeContinuable: '可续',
60
+ subagentDetail: '子代理详情', loadDetail: '加载子代理详情…', noDetail: '该子代理无可见事件',
61
+ viewSubagent: '查看子代理', toolTop: '工具 Top', issues: '问题会话', issuesHint: '只显示有错误记录的会话',
62
+ searchTab: '检索', searchInPlaceholder: '会话内检索…', noMatches: '无匹配位置', matches: '命中', navUsers: '用户',
63
+ cache: '缓存管理', cacheTotal: '缓存总量', cacheIndex: '会话索引', cacheTimeline: '时间线缓存',
64
+ cleanTimeline: '清理时间线缓存', cleanAll: '清理全部缓存', cacheDone: '已清理', cacheEmpty: '(无缓存文件)',
65
+ summary: '会话摘要', summaryGoal: '首个任务', summaryConclusion: '最近结论', summaryTools: '工具',
66
+ summaryTaskCount: '任务数', summaryProvisional: '(可能仅为当前进展)',
67
+ summaryStale: '⚠ 摘要生成后有新对话,内容可能已不准确,建议重新生成',
68
+ llmSummary: '生成 LLM 摘要', llmGenerating: '生成中…', llmFail: '生成失败', summaryRuleTag: '规则', summaryLlmTag: 'LLM',
69
+ cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
70
+ } : {
71
+ entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
72
+ search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
73
+ sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
74
+ noMatch: 'No matching sessions', noData: 'No session data yet',
75
+ running: 'Running', ended: 'Ended', subagent: 'subagent',
76
+ turns: 'turns', steps: 'steps', tools: 'tools', errors: 'errors', msgs: 'msgs',
77
+ duration: 'duration', created: 'created', size: 'size', records: 'records',
78
+ jumpFailed: 'Cannot open (session not in live list)', unknownTitle: '(untitled session)',
79
+ loadFailed: 'Load failed',
80
+ back: 'Back', close: 'Close', jumpNative: 'Open in native view', expandAll: 'Expand all', collapseAll: 'Collapse all',
81
+ exportMd: 'Export report (ZIP)', exporting: 'Exporting…', exportFail: 'Export failed',
82
+ live: 'Live', liveActive: 'Live · running', liveExit: 'Exit live', liveLoading: 'Loading live…',
83
+ liveFail: 'Live unavailable', liveUnavailable: '(live channel unavailable: no connection)',
84
+ liveEvents: 'events', liveRefreshing: 'refreshing…', liveLastActive: 'last active',
85
+ liveTurn: 'Running', liveGenerating: 'Generating…',
86
+ detailLoading: 'Parsing session…', noTimeline: 'No timeline to show', artifacts: 'Artifacts',
87
+ noArtifacts: '(no artifact paths extracted)', user: 'User', assistant: 'Assistant', step: 'Step',
88
+ args: 'Args', result: 'Result', thinking: 'Thinking', expanded: 'Expanded', collapsed: 'Collapsed',
89
+ detailFailed: 'Failed to load session detail', showEmpty: 'Show empty sessions', emptySession: 'Empty',
90
+ emptyHint: 'This session was created but has no conversation yet', userNav: 'User turns', noUserNav: '(no user turns)',
91
+ conclusion: 'Conclusion', injected: 'Injected', lineage: 'Lineage', noLineage: '(no subagent lineage)',
92
+ liveTree: 'Live lineage', offlineTree: 'Archive lineage', modeOneShot: 'one-shot', modeContinuable: 'continuable',
93
+ subagentDetail: 'Subagent detail', loadDetail: 'Loading subagent detail…', noDetail: 'No visible events for this subagent',
94
+ viewSubagent: 'View subagent', toolTop: 'Tool Top', issues: 'Problem sessions', issuesHint: 'Only sessions with errors',
95
+ searchTab: 'Search', searchInPlaceholder: 'Search in session…', noMatches: 'No matches', matches: 'matches', navUsers: 'User',
96
+ cache: 'Cache Mgmt', cacheTotal: 'Cache total', cacheIndex: 'Index', cacheTimeline: 'Timeline cache',
97
+ cleanTimeline: 'Clean timeline cache', cleanAll: 'Clean all cache', cacheDone: 'Cleaned', cacheEmpty: '(no cache files)',
98
+ summary: 'Session summary', summaryGoal: 'First task', summaryConclusion: 'Latest result', summaryTools: 'Tools',
99
+ summaryTaskCount: 'Tasks', summaryProvisional: '(may only be current progress)',
100
+ summaryStale: '⚠ New conversation since summary was generated; content may be outdated — regenerate',
101
+ llmSummary: 'Generate LLM summary', llmGenerating: 'Generating…', llmFail: 'Failed', summaryRuleTag: 'rule', summaryLlmTag: 'LLM',
102
+ cacheHint: "Only this plugin's cache (auto-rebuilds); never touches DSH session archives or other data", cacheLimit: 'Timeline cache limit',
103
+ }
104
+
105
+ // ── 右侧导航图标(组件级共享,定义在顶层避免作用域问题)───────────
106
+ // feather 风格线条,currentColor 自适应明暗主题、与文字同色。
107
+ const NAV_ICON_USER = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
108
+ const NAV_ICON_TOOL = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="15" cy="7" r="4.2"/><path d="M12.1 9.9 4.6 17.4a1.9 1.9 0 0 0 2.7 2.7l7.5-7.5"/></svg>`
109
+ const NAV_ICON_ERR = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>`
110
+ const NAV_ICON_SEARCH = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>`
111
+
112
+ // ── 样式 ────────────────────────────────────────────────────────────
113
+ // 注意:挂载容器只靠 [data-dsh-session-flow-view] 属性控制可见性(display:none!important),
114
+ // 容器上不得再加任何会设置 display 的 class —— 同级特异性下后者会覆盖 display:none,
115
+ // 导致视图永久显示、属性切换失效(曾踩过 .sf-view 覆盖的坑)。
116
+ // 滚动策略:容器 overflow:hidden,滚动只发生在本视图内部(.sf-body / .sf-timeline 等),
117
+ // 这样 top 栏(flex:none + sticky)天然固定,不会随内容滚出视口。
118
+ const STYLE = [
119
+ `[data-pane=conversation]{position:relative}`,
120
+ `${VIEW_SELECTOR}{z-index:60;background:var(--dsw-alias-bg-base,#fff);display:none!important;position:absolute;inset:0;overflow:hidden}`,
121
+ `html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) ${VIEW_SELECTOR}{display:block!important}`,
122
+ `html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane=conversation]>:not(${VIEW_SELECTOR}),html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*=centerCol]>:not(${VIEW_SELECTOR}){display:none!important}`,
123
+ `.sf-entry{width:100%;height:32px;color:var(--dsw-alias-label-secondary,#666);cursor:pointer;white-space:nowrap;background:0 0;border:none;border-radius:8px;align-items:center;gap:8px;padding:0 12px;font-size:13px;display:flex}`,
124
+ `.sf-entry:hover{background:var(--dsw-specific-sidebar-nav-item-hover,rgba(0,0,0,.06));color:var(--dsw-alias-label-primary,#222)}`,
125
+ `.sf-entry[data-active]{background:var(--dsw-specific-sidebar-nav-item-active,rgba(0,0,0,.1));color:var(--dsw-alias-label-primary,#222);font-weight:600}`,
126
+ `.sf-entryIcon{flex:none;justify-content:center;align-items:center;display:inline-flex}`,
127
+ `.sf-entryLabel{text-overflow:ellipsis;overflow:hidden}`,
128
+ `[data-dsh-frame][data-sidebar-collapsed] .sf-entry{justify-content:center;width:100%;padding:0}`,
129
+ `[data-dsh-frame][data-sidebar-collapsed] .sf-entryLabel{display:none}`,
130
+ // box-sizing:border-box 必须:height:100% + padding 在 content-box 下会溢出容器,
131
+ // 产生多余的整页滚动条,导致 top 栏随滚动消失(用户实测反馈)。
132
+ `.sf-view{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex;overflow:hidden}`,
133
+ // top 栏悬浮固定:flex:none 在布局上已固定;再加 sticky + 背景双保险。
134
+ `.sf-viewHeader{flex:none;position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-base,#fff);align-items:center;gap:10px;display:flex;flex-wrap:wrap}`,
135
+ `.sf-viewTitle{color:var(--dsw-alias-label-primary,#222);white-space:nowrap;margin:0;font-size:16px;font-weight:700}`,
136
+ `.sf-input{color:var(--dsw-alias-label-primary,#222);background:var(--dsw-specific-input-major,#f5f5f5);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;outline:none;padding:5px 10px;font-size:12px}`,
137
+ `.sf-input:focus{border-color:var(--dsw-specific-accent,#4a7dff)}`,
138
+ `.sf-btn{color:var(--dsw-alias-label-primary,#222);background:var(--dsw-specific-input-major,#f5f5f5);border:1px solid var(--dsw-alias-border-l2,#ddd);border-radius:8px;outline:none;padding:5px 12px;font-size:12px;cursor:pointer}`,
139
+ `.sf-btn:hover{opacity:.85}.sf-btn:disabled{opacity:.5;cursor:default}`,
140
+ `.sf-btnActive{border-color:rgba(28,158,90,.55);color:#1c9e5a;background:rgba(28,158,90,.08)}`,
141
+ `.sf-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:8px}`,
142
+ `.sf-groupTitle{flex:none;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#888);margin:6px 0 2px}`,
143
+ `.sf-card{background:var(--dsw-specific-input-major,#fafafa);border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:10px;padding:9px 12px;font-size:13px;cursor:pointer;transition:border-color .12s,box-shadow .12s}`,
144
+ `.sf-card:hover{border-color:var(--dsw-specific-accent,#4a7dff);box-shadow:0 1px 4px rgba(0,0,0,.06)}`,
145
+ `.sf-cardTitle{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}`,
146
+ `.sf-cardText{color:var(--dsw-alias-label-primary,#222)}`,
147
+ `.sf-muted{color:var(--dsw-alias-label-secondary,#888);font-size:12px}`,
148
+ `.sf-mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}`,
149
+ `.sf-badge{flex:none;border-radius:6px;padding:1px 7px;font-size:11px;line-height:16px}`,
150
+ `.sf-badgeWs{background:rgba(90,140,255,.12);color:#4a7dff}`,
151
+ `.sf-badgeRun{background:rgba(60,190,120,.15);color:#1c9e5a}`,
152
+ // 运行中回合内的「运行中」徽标与边框同步呼吸(亮度脉动,周期 2.8s)。
153
+ `.sf-turnLive .sf-badgeRun{animation:sfBadgePulse 2.8s ease-in-out infinite}`,
154
+ `@keyframes sfBadgePulse{0%,100%{opacity:.7}50%{opacity:1}}`,
155
+ `.sf-badgeEnd{background:rgba(120,120,120,.12);color:#888}`,
156
+ `.sf-badgeSub{background:rgba(240,150,60,.15);color:#d97706}`,
157
+ `.sf-badgeErr{background:rgba(230,80,80,.13);color:#d43b3b}`,
158
+ `.sf-statsRow{display:flex;gap:12px;flex-wrap:wrap;margin-top:3px}`,
159
+ `.sf-hint{color:var(--dsw-alias-label-secondary,#888);font-size:12px;padding:2px 2px 0}`,
160
+ // ── 工作区筛选 tabs(独立一行,醒目)──
161
+ `.sf-wsTabs{flex:none;display:flex;gap:6px;flex-wrap:wrap;align-items:center}`,
162
+ `.sf-wsTab{flex:none;display:inline-flex;align-items:center;gap:5px;max-width:280px;padding:3px 11px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-specific-input-major,#f5f5f5);color:var(--dsw-alias-label-secondary,#666);font-size:12px;cursor:pointer;white-space:nowrap}`,
163
+ `.sf-wsTab:hover{border-color:var(--dsw-specific-accent,#4a7dff);color:var(--dsw-alias-label-primary,#222)}`,
164
+ `.sf-wsTab[data-active]{background:rgba(90,140,255,.14);border-color:#4a7dff;color:#4a7dff;font-weight:600}`,
165
+ `.sf-wsTabName{overflow:hidden;text-overflow:ellipsis}`,
166
+ `.sf-wsTabCount{flex:none;border-radius:999px;background:rgba(90,140,255,.12);color:#4a7dff;min-width:18px;text-align:center;padding:0 5px;font-size:11px;line-height:16px}`,
167
+ // ── 卡片工作区行(醒目 + 点击即筛选)──
168
+ `.sf-cardWs{display:flex;align-items:center;gap:6px;padding:0 0 6px;margin-bottom:6px;border-bottom:1px dashed var(--dsw-alias-border-l2,#ddd);color:#4a7dff;font-size:12px;font-weight:600;cursor:pointer;user-select:none}`,
169
+ `.sf-cardWs:hover{color:#2f5fd0}`,
170
+ `.sf-cardWsIcon{flex:none;display:inline-flex}`,
171
+ `.sf-cardWsName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,Consolas,monospace;font-size:11.5px}`,
172
+ `.sf-cardWsFilter{flex:none;margin-left:auto;font-size:11px;font-weight:400;opacity:.75}`,
173
+ // ── 详情页 ──
174
+ `.sf-detailBody{flex:1;min-height:0;display:flex;gap:12px;overflow:hidden}`,
175
+ `.sf-timeline{flex:1;min-width:0;overflow:auto;display:flex;flex-direction:column;gap:10px;padding-right:4px}`,
176
+ `.sf-artifacts{flex:none;width:250px;min-width:200px;overflow:auto;border-left:1px solid var(--dsw-alias-border-l2,#eee);padding-left:12px;display:flex;flex-direction:column;gap:6px}`,
177
+ `.sf-turn{border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:10px;background:var(--dsw-specific-input-major,#fafafa);scroll-margin-top:8px}`,
178
+ `.sf-turnHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:7px 10px;cursor:pointer;border-radius:10px}`,
179
+ `.sf-turnHead:hover{background:rgba(90,140,255,.06)}`,
180
+ // 运行中回合:整圈边框 + 内辉光呼吸——零掉帧方案。
181
+ // 容器边框透明,呼吸由 ::before 伪元素完成:伪元素画「亮边框 + 静态 inset
182
+ // 辉光」,动画只驱动 opacity(合成器线程,不触发布局/重绘)。
183
+ // 静态辉光不参与动画 → 视觉是整圈「发光边框」一起明暗呼吸(非粗细变换),
184
+ // 且 inset 辉光在容器内部,无边缘裁剪问题。
185
+ `.sf-turn.sf-turnLive{position:relative;border-color:transparent;background:rgba(28,158,90,.05)}`,
186
+ `.sf-turn.sf-turnLive::before{content:'';position:absolute;inset:0;border-radius:10px;border:1px solid rgba(28,158,90,.95);box-shadow:inset 0 0 8px 1px rgba(28,158,90,.28);pointer-events:none;animation:sfLiveFade 2.8s ease-in-out infinite}`,
187
+ `.sf-turnHeadLive{background:rgba(28,158,90,.14)}`,
188
+ `@keyframes sfLiveFade{0%,100%{opacity:.35}50%{opacity:1}}`,
189
+ // 选中回合(正文区):蓝色高亮边框 + 底色,点击定位后一眼可辨。
190
+ `.sf-turn.sf-turnSelected{border-color:rgba(74,125,255,.6);background:rgba(74,125,255,.06);box-shadow:0 0 0 2px rgba(74,125,255,.12)}`,
191
+ // 选中回合(右侧导航项):蓝色高亮底色。
192
+ `.sf-navItem.sf-navItemSel{background:rgba(74,125,255,.12);border-color:rgba(74,125,255,.45)}`,
193
+ // 运行中底部动态标志:三点跳动省略号 + 文案。
194
+ `.sf-liveTyping{display:flex;align-items:center;gap:8px;padding:8px 10px 10px;font-size:12px}`,
195
+ `.sf-liveDots{display:inline-flex;gap:3px;align-items:center}`,
196
+ `.sf-liveDots span{width:6px;height:6px;border-radius:50%;background:var(--dsw-alias-label-primary,#333);opacity:.35;animation:sfDotBounce 1.2s infinite ease-in-out}`,
197
+ `.sf-liveDots span:nth-child(2){animation-delay:.15s}.sf-liveDots span:nth-child(3){animation-delay:.3s}`,
198
+ `@keyframes sfDotBounce{0%,60%,100%{transform:translateY(0);opacity:.35}30%{transform:translateY(-3px);opacity:1}}`,
199
+ // 回合摘要(默认折叠态):用户发言 + 最终结论预览。
200
+ `.sf-turnSummary{display:flex;flex-direction:column;gap:4px;padding:0 10px 9px;cursor:pointer}`,
201
+ `.sf-turnSummary:hover{background:rgba(90,140,255,.04)}`,
202
+ `.sf-turnSummaryRow{display:flex;gap:8px;align-items:flex-start;min-width:0}`,
203
+ `.sf-turnSummaryTag{flex:none;border-radius:6px;padding:0 6px;font-size:10.5px;line-height:16px;font-weight:600;margin-top:1px}`,
204
+ `.sf-turnSummaryTag.user{background:rgba(90,140,255,.12);color:#4a7dff}`,
205
+ `.sf-turnSummaryTag.ok{background:rgba(60,190,120,.15);color:#1c9e5a}`,
206
+ `.sf-turnSummaryText{min-width:0;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);word-break:break-word}`,
207
+ `.sf-turnBody{padding:2px 10px 10px;display:flex;flex-direction:column;gap:8px}`,
208
+ `.sf-msg{border-left:3px solid var(--dsw-alias-border-l2,#ddd);padding:2px 10px}`,
209
+ `.sf-msgUser{border-left-color:#4a7dff}.sf-msgAssistant{border-left-color:#1c9e5a}`,
210
+ `.sf-msgInject{border-left-color:#bbb;opacity:.85}`,
211
+ `.sf-injectBadge{display:inline-block;border-radius:6px;background:rgba(120,120,120,.14);color:var(--dsw-alias-label-secondary,#888);padding:0 6px;font-size:10.5px;line-height:15px;margin-left:8px;vertical-align:middle}`,
212
+ `.sf-msgTextInject{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;cursor:pointer;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary,#777)}`,
213
+ `.sf-msgTextInject.sf-open{display:block;-webkit-line-clamp:unset}`,
214
+ `.sf-msgRole{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#888);margin-bottom:2px}`,
215
+ // 核心内容(用户发言/最终结论):默认可见,超过 5 行折叠(line-clamp),点击展开/收起。
216
+ `.sf-msgText{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:5;overflow:hidden;cursor:pointer;white-space:pre-wrap;word-break:break-word;font-size:12.5px;line-height:1.55}`,
217
+ `.sf-msgText.sf-open{display:block;-webkit-line-clamp:unset}`,
218
+ // 思考内容:默认只显示 2 行。
219
+ `.sf-thinking{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;cursor:pointer;white-space:pre-wrap;word-break:break-word;color:var(--dsw-alias-label-tertiary,#999);font-size:12px;line-height:1.5;padding:2px 0}`,
220
+ `.sf-thinking.sf-open{display:block;-webkit-line-clamp:unset}`,
221
+ `.sf-thinkingLabel{display:inline-block;font-size:11px;font-weight:600;color:#d97706;margin-right:6px}`,
222
+ `.sf-msgToggle{font-size:11px;color:#4a7dff;cursor:pointer;border:none;background:none;padding:2px 0;display:inline-block}`,
223
+ `.sf-msgToggle:hover{text-decoration:underline}`,
224
+ `.sf-step{display:flex;flex-direction:column;gap:4px}`,
225
+ `.sf-stepHead{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#888);display:flex;gap:8px;align-items:center}`,
226
+ `.sf-tool{border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:8px;background:var(--dsw-alias-bg-base,#fff);scroll-margin-top:8px}`,
227
+ `.sf-toolHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:5px 9px;cursor:pointer;border-radius:8px;font-size:12.5px}`,
228
+ `.sf-toolHead:hover{background:rgba(90,140,255,.06)}`,
229
+ `.sf-toolName{font-weight:600;font-family:ui-monospace,Consolas,monospace;background:rgba(90,140,255,.1);border-radius:5px;padding:1px 7px;color:var(--dsw-alias-label-primary,#333)}`,
230
+ `.sf-toolArgPrev{flex:1 1 auto;min-width:0;font-family:ui-monospace,Consolas,monospace;font-size:11.5px;color:var(--dsw-alias-label-secondary,#666);background:rgba(127,127,127,.08);border-radius:4px;padding:1px 6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}`,
231
+ `.sf-toolStatus{width:8px;height:8px;border-radius:50%;flex:none}`,
232
+ `.sf-toolStatus.ok{background:#1c9e5a}.sf-toolStatus.err{background:#d43b3b}.sf-toolStatus.run{background:#d97706}`,
233
+ `.sf-toolBody{padding:2px 9px 9px;display:flex;flex-direction:column;gap:6px}`,
234
+ `.sf-pre{background:var(--dsw-specific-input-major,#f5f5f5);border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:6px;padding:6px 8px;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;max-height:260px;overflow:auto;margin:0;font-family:ui-monospace,Consolas,monospace}`,
235
+ `.sf-pre.err{border-color:rgba(230,80,80,.4);background:rgba(230,80,80,.05)}`,
236
+ `.sf-preLabel{font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#888)}`,
237
+ `.sf-chip{display:inline-block;border-radius:6px;background:rgba(90,140,255,.1);color:#4a7dff;padding:0 6px;font-size:11px;line-height:18px;font-family:ui-monospace,Consolas,monospace;cursor:pointer;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}`,
238
+ `.sf-chip:hover{background:rgba(90,140,255,.2)}`,
239
+ // ── 右侧用户发言大纲 ──
240
+ `.sf-navItem{position:relative;display:flex;gap:7px;align-items:flex-start;border-radius:8px;padding:5px 8px;cursor:pointer;font-size:12px;line-height:1.5;border:1px solid transparent}`,
241
+ `.sf-navItem:hover{background:rgba(90,140,255,.07);border-color:var(--dsw-alias-border-l2,#e5e5e5)}`,
242
+ `.sf-navIndex{flex:none;color:#4a7dff;font-weight:600;font-size:11px;line-height:18px;min-width:20px;text-align:right}`,
243
+ `.sf-navText{min-width:0;color:var(--dsw-alias-label-secondary,#666);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word;padding-right:30px}`,
244
+ // 回合标识统一固定在卡片右上角(不再跟随内容流,一行内也明显可见)。
245
+ `.sf-navTurn{position:absolute;top:5px;right:8px;flex:none;color:var(--dsw-alias-label-tertiary,#999);font-size:10.5px;line-height:14px}`,
246
+ // ── 血缘树 ──
247
+ `.sf-tree{flex:none;width:280px;min-width:240px;overflow:auto;border-right:1px solid var(--dsw-alias-border-l2,#eee);padding-right:10px;display:flex;flex-direction:column;gap:2px}`,
248
+ `.sf-treeRow{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;cursor:pointer;font-size:12px;line-height:18px;min-width:0}`,
249
+ `.sf-treeRow:hover{background:rgba(90,140,255,.07)}`,
250
+ `.sf-treeRow[data-active]{background:rgba(90,140,255,.13)}`,
251
+ `.sf-treeIndent{flex:none;width:14px}`,
252
+ `.sf-treeCaret{flex:none;width:12px;text-align:center;color:var(--dsw-alias-label-tertiary,#999)}`,
253
+ `.sf-treeLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}`,
254
+ `.sf-liveDot{flex:none;width:7px;height:7px;border-radius:50%}`,
255
+ `.sf-liveDot.running{background:#1c9e5a}.sf-liveDot.inactive{background:#bbb}`,
256
+ `.sf-treeMeta{flex:none;font-size:10.5px;color:var(--dsw-alias-label-tertiary,#999)}`,
257
+ `.sf-treeDetail{flex:1;min-width:0;overflow:auto;display:flex;flex-direction:column;gap:10px;padding-left:12px}`,
258
+ // ── 档案统计条(M5b)──
259
+ `.sf-statsBar{flex:none;display:flex;align-items:center;gap:6px;flex-wrap:wrap;font-size:12px}`,
260
+ `.sf-statsLabel{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary,#888);font-size:11.5px}`,
261
+ `.sf-statChip{flex:none;display:inline-flex;align-items:center;gap:4px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-specific-input-major,#f5f5f5);color:var(--dsw-alias-label-secondary,#666);padding:2px 10px;font-size:11.5px;cursor:default}`,
262
+ `.sf-statChip.active{background:rgba(230,80,80,.12);border-color:#d43b3b;color:#d43b3b;font-weight:600;cursor:pointer}`,
263
+ `.sf-statChip.clickable{cursor:pointer}`,
264
+ `.sf-statChip.clickable:hover{border-color:var(--dsw-specific-accent,#4a7dff);color:var(--dsw-alias-label-primary,#222)}`,
265
+ `.sf-statCount{flex:none;border-radius:999px;background:rgba(120,120,120,.14);min-width:16px;text-align:center;padding:0 5px;font-size:10.5px;line-height:15px}`,
266
+ `.sf-statCount.err{background:rgba(230,80,80,.15);color:#d43b3b}`,
267
+ // ── 缓存管理面板 ──
268
+ `.sf-btnActive{background:rgba(90,140,255,.12);border-color:#4a7dff;color:#4a7dff}`,
269
+ `.sf-btnDanger{color:#d43b3b;border-color:rgba(230,80,80,.4)}`,
270
+ `.sf-btnDanger:hover{background:rgba(230,80,80,.08)}`,
271
+ `.sf-cachePanel{flex:none;display:flex;flex-direction:column;gap:6px;border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:10px;background:var(--dsw-specific-input-major,#fafafa);padding:10px 12px;font-size:12px}`,
272
+ `.sf-cacheRow{display:flex;align-items:center;gap:8px;min-width:0}`,
273
+ `.sf-cacheLabel{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary,#888)}`,
274
+ `.sf-cacheValue{font-variant-numeric:tabular-nums;font-family:ui-monospace,Consolas,monospace}`,
275
+ // ── M5 会话摘要卡 ──
276
+ `.sf-summaryCard{flex:none;display:flex;flex-direction:column;gap:5px;border:1px solid var(--dsw-alias-border-l2,#e5e5e5);border-radius:10px;background:var(--dsw-specific-input-major,#fafafa);padding:9px 12px;font-size:12px}`,
277
+ `.sf-summaryHead{display:flex;align-items:center;gap:8px}`,
278
+ `.sf-summaryTitle{font-weight:700;font-size:12.5px}`,
279
+ `.sf-summaryRow{display:flex;gap:8px;align-items:flex-start;min-width:0}`,
280
+ `.sf-summaryFull{min-width:0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,#666);overflow:visible}`,
281
+ `.sf-summaryStale{flex:none;font-size:11px;line-height:1.5;color:#b8860b;background:rgba(255,193,7,.14);border:1px solid rgba(255,193,7,.4);border-radius:6px;padding:4px 8px;cursor:pointer;margin-top:2px}`,
282
+ `.sf-liveBar{display:flex;align-items:center;gap:10px;border:1px solid rgba(28,158,90,.35);background:rgba(28,158,90,.06);border-radius:8px;padding:5px 10px;font-size:12px;flex-wrap:wrap}`,
283
+ // 实时状态行:文本区弹性收缩(nowrap+省略),按钮固定右侧,事件数/时间变化不引起跳动。
284
+ `.sf-liveBar .sf-liveText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:flex;align-items:center;gap:8px}`,
285
+ `.sf-liveBar .sf-liveExitBtn{flex:none;margin-left:auto}`,
286
+ // ── 右侧导航标签(用户/工具/错误/检索)──
287
+ `.sf-navTabs{flex:none;display:flex;gap:3px;flex-wrap:wrap;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);padding-bottom:6px}`,
288
+ `.sf-navTab{flex:1 1 44px;min-width:0;display:inline-flex;align-items:center;justify-content:center;gap:4px;border:1px solid transparent;border-radius:8px;background:none;color:var(--dsw-alias-label-secondary,#666);font-size:11.5px;cursor:pointer;padding:4px 5px;white-space:nowrap;overflow:hidden}`,
289
+ `.sf-navTab:hover{background:rgba(90,140,255,.06)}`,
290
+ `.sf-navTab.active{background:rgba(90,140,255,.12);border-color:rgba(90,140,255,.25);color:#4a7dff;font-weight:600}`,
291
+ `.sf-navTabLabel{min-width:0;overflow:hidden;text-overflow:ellipsis}`,
292
+ `.sf-navIcon{flex:none;display:inline-flex;align-items:center;justify-content:center;color:currentColor}`,
293
+ `.sf-navTabCount{flex:none;border-radius:999px;background:rgba(120,120,120,.14);min-width:15px;text-align:center;padding:0 4px;font-size:10px;line-height:14px}`,
294
+ `.sf-navTabCount.err{background:rgba(230,80,80,.15);color:#d43b3b}`,
295
+ `.sf-navBody{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:2px}`,
296
+ `.sf-navToolGroup{display:flex;flex-direction:column;gap:1px}`,
297
+ `.sf-navToolHead{display:flex;align-items:center;gap:6px;padding:4px 6px;border-radius:6px;cursor:pointer;font-size:12px;min-width:0}`,
298
+ `.sf-navToolHead:hover{background:rgba(90,140,255,.07)}`,
299
+ `.sf-navToolName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-family:ui-monospace,Consolas,monospace;font-size:11.5px}`,
300
+ // ── 会话内检索(M5c 方案C)──
301
+ `.sf-navKind{flex:none;border-radius:6px;padding:0 5px;font-size:10px;line-height:15px;font-weight:600;font-family:ui-monospace,Consolas,monospace}`,
302
+ `.sf-navKind.tool{background:rgba(90,140,255,.12);color:#4a7dff}`,
303
+ `.sf-navKind.error{background:rgba(230,80,80,.13);color:#d43b3b}`,
304
+ `.sf-navKind.user{background:rgba(60,190,120,.15);color:#1c9e5a}`,
305
+ `.sf-navKind.thinking{background:rgba(240,150,60,.15);color:#d97706}`,
306
+ `.sf-navKind.assistant{background:rgba(120,120,120,.14);color:#666}`,
307
+ `@keyframes sfFlashBg{0%{background:rgba(90,140,255,.4)}100%{background:transparent}}`,
308
+ `.sf-flash{animation:sfFlashBg 1.5s ease}`,
309
+ ].join('\n')
310
+
311
+ // ── API ─────────────────────────────────────────────────────────────
312
+ function api(method, params) {
313
+ return fetch('/api/session-flow', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/json' },
316
+ body: JSON.stringify(Object.assign({ method }, params || {})),
317
+ }).then((r) => r.json())
318
+ }
319
+
320
+ // ── 迷你 Markdown 渲染(LLM 摘要用)────────────────────────────────
321
+ // 摘要里的 **加粗** / *斜体* / `行内代码` / 列表 / 标题若按纯文本渲染会
322
+ // 原样显示星号;这里做最小解析成 React 元素(不引入 marked 依赖)。
323
+ // 输出为元素数组;无任何匹配时返回 null 由调用方按纯文本兜底。
324
+ function renderSummaryMd(text) {
325
+ const src = String(text || '')
326
+ if (!src) return null
327
+ const out = []
328
+ const lines = src.split(/\r?\n/)
329
+ let i = 0
330
+ let listBuf = null // { ordered, items: [] }
331
+ const flushList = () => {
332
+ if (listBuf) {
333
+ out.push(h(listBuf.ordered ? 'ol' : 'ul', { style: { margin: '2px 0 2px 18px', padding: 0 } },
334
+ listBuf.items.map((it, k) => h('li', { key: k, style: { margin: '1px 0' } }, it))))
335
+ listBuf = null
336
+ }
337
+ }
338
+ while (i < lines.length) {
339
+ const line = lines[i]
340
+ const trimmed = line.trim()
341
+ // 空行:结束当前段落/列表。
342
+ if (!trimmed) { flushList(); i++; continue }
343
+ // 列表项:- / * / 1. 开头(宽松匹配)。
344
+ const liMatch = trimmed.match(/^([-*]|\d+[.)])\s+(.*)$/)
345
+ if (liMatch) {
346
+ if (!listBuf || listBuf.ordered !== /^\d/.test(liMatch[1])) {
347
+ flushList()
348
+ listBuf = { ordered: /^\d/.test(liMatch[1]), items: [] }
349
+ }
350
+ listBuf.items.push(inlineMd(liMatch[2]))
351
+ i++
352
+ continue
353
+ }
354
+ flushList()
355
+ // 标题:## / ### 开头。
356
+ const hd = trimmed.match(/^(#{1,4})\s+(.*)$/)
357
+ if (hd) {
358
+ out.push(h('div', { style: { fontWeight: 700, margin: '3px 0 1px' } }, inlineMd(hd[2])))
359
+ i++
360
+ continue
361
+ }
362
+ // 普通段落行(连续非空行合并为一段)。
363
+ const para = []
364
+ while (i < lines.length && lines[i].trim()) { para.push(lines[i]); i++ }
365
+ out.push(h('div', { style: { margin: '1px 0' } }, inlineMd(para.join('\n'))))
366
+ }
367
+ flushList()
368
+ return out.length > 0 ? out : null
369
+ }
370
+ // 行内样式:**加粗**、*斜体*、`行内代码`;按 token 切分后逐段转元素。
371
+ function inlineMd(str) {
372
+ const tokens = String(str).split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g).filter((t) => t !== '')
373
+ if (tokens.length <= 1) return str
374
+ return tokens.map((t, k) => {
375
+ if (/^\*\*[^*]+\*\*$/.test(t)) return h('strong', { key: k }, t.slice(2, -2))
376
+ if (/^\*[^*]+\*$/.test(t)) return h('em', { key: k }, t.slice(1, -1))
377
+ if (/^`[^`]+`$/.test(t)) return h('code', { key: k, style: { fontFamily: 'monospace', background: 'rgba(127,127,127,.15)', borderRadius: 3, padding: '0 3px' } }, t.slice(1, -1))
378
+ return t
379
+ })
380
+ }
381
+
382
+ // ── 格式化工具 ──────────────────────────────────────────────────────
383
+ function fmtTime(ms) {
384
+ if (ms === null || ms === undefined) return '—'
385
+ const d = new Date(ms)
386
+ const pad = (n) => String(n).padStart(2, '0')
387
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
388
+ }
389
+
390
+ function fmtDuration(ms) {
391
+ if (ms === null || ms === undefined) return '—'
392
+ const s = Math.max(0, Math.floor(ms / 1000))
393
+ if (s < 60) return s + 's'
394
+ const m = Math.floor(s / 60)
395
+ if (m < 60) return m + 'm ' + (s % 60) + 's'
396
+ const hh = Math.floor(m / 60)
397
+ return hh + 'h ' + (m % 60) + 'm'
398
+ }
399
+
400
+ function fmtSize(bytes) {
401
+ if (bytes === null || bytes === undefined) return '—'
402
+ if (bytes < 1024) return bytes + ' B'
403
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KiB'
404
+ return (bytes / 1024 / 1024).toFixed(1) + ' MiB'
405
+ }
406
+
407
+ // ── 总览页 ──────────────────────────────────────────────────────────
408
+ // props: { onOpen: (session) => void, onClose: () => void }
409
+ function SessionFlowOverview(props) {
410
+ const [state, setState] = useState({ phase: 'loading', error: null, data: null })
411
+ const [query, setQuery] = useState('')
412
+ const [wsFilter, setWsFilter] = useState('')
413
+ const [sort, setSort] = useState('recent') // 默认:最近运行(最后活动时间)
414
+ const [showEmpty, setShowEmpty] = useState(false) // 默认隐藏空会话
415
+ const [showIssues, setShowIssues] = useState(false) // 问题速览:只看有错误记录的会话
416
+ // 缓存管理面板。
417
+ const [cacheOpen, setCacheOpen] = useState(false)
418
+ const [cacheInfo, setCacheInfo] = useState(null)
419
+ const [cacheBusy, setCacheBusy] = useState(false)
420
+ const [cacheMsg, setCacheMsg] = useState('')
421
+ const fmtBytes = (b) => {
422
+ if (b === null || b === undefined) return '—'
423
+ if (b < 1024) return b + ' B'
424
+ if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KiB'
425
+ return (b / 1024 / 1024).toFixed(1) + ' MiB'
426
+ }
427
+ const loadCacheInfo = () => {
428
+ api('cacheInfo').then((json) => {
429
+ if (json && json.ok) setCacheInfo(json)
430
+ }).catch(() => {})
431
+ }
432
+ // 打开缓存面板时默认自动刷新一次,避免首次进入数据为空。
433
+ useEffect(() => {
434
+ if (cacheOpen) loadCacheInfo()
435
+ // eslint-disable-next-line react-hooks/exhaustive-deps
436
+ }, [cacheOpen])
437
+ const cleanCache = (what) => {
438
+ setCacheBusy(true)
439
+ setCacheMsg('')
440
+ api('cacheClean', { what }).then((json) => {
441
+ if (json && json.ok) {
442
+ setCacheMsg(STR.cacheDone + ': ' + json.removed + ' 个文件 / ' + fmtBytes(json.bytes))
443
+ loadCacheInfo()
444
+ } else {
445
+ setCacheMsg((json && json.error) || 'failed')
446
+ }
447
+ }).catch((e) => setCacheMsg(String(e))).finally(() => setCacheBusy(false))
448
+ }
449
+
450
+ // 空会话判断:优先用索引标记,兼容旧缓存按内容指标兜底。
451
+ const isSessionEmpty = (s) =>
452
+ s.empty === true ||
453
+ (!(s.userMessages || 0) && !(s.toolCalls || 0) && !(s.turns || 0))
454
+
455
+ // 工作区显示名:与左侧栏一致只留最后一级目录;完整路径放 title(悬浮显示)。
456
+ // 注意:必须在 useMemo 之前定义(const 的 TDZ——useMemo 回调先于其初始化执行会报错)。
457
+ const wsLabelOf = (g) => g.label || g.name
458
+ const wsCwdOf = (g) => g.cwd || null
459
+
460
+ const load = () => {
461
+ setState({ phase: 'loading', error: null, data: null })
462
+ api('list').then((json) => {
463
+ if (json && json.ok) setState({ phase: 'ready', error: null, data: json })
464
+ else setState({ phase: 'error', error: (json && json.error) || 'list failed', data: null })
465
+ }).catch((e) => setState({ phase: 'error', error: String(e), data: null }))
466
+ }
467
+ useEffect(() => { load() }, [])
468
+
469
+ const rescan = () => {
470
+ setState({ phase: 'loading', error: null, data: null })
471
+ api('rescan', { force: true }).then((json) => {
472
+ if (json && json.ok) setState({ phase: 'ready', error: null, data: json })
473
+ else setState({ phase: 'error', error: (json && json.error) || 'rescan failed', data: null })
474
+ }).catch((e) => setState({ phase: 'error', error: String(e), data: null }))
475
+ }
476
+
477
+ const rows = useMemo(() => {
478
+ const ws = (state.data && state.data.workspaces) || []
479
+ const q = query.trim().toLowerCase()
480
+ // M5c 结构化检索前缀:tool: / file: / path: / err: / error:
481
+ const st = /^(tool|file|path|err|error):(.*)$/.exec(q)
482
+ const stKind = st ? st[1].toLowerCase() : null
483
+ const stVal = st ? st[2].trim().toLowerCase() : ''
484
+ const out = []
485
+ for (const group of ws) {
486
+ for (const s of group.sessions) {
487
+ if (wsFilter !== '' && wsFilter !== group.name) continue
488
+ // 子代理会话:不从总览混入,从详情页「血缘」树进入。
489
+ if (s.parentSession) continue
490
+ if (!showEmpty && isSessionEmpty(s)) continue // 默认隐藏空会话
491
+ if (showIssues && !(s.toolErrors > 0)) continue // 问题速览:只看有错误记录的会话
492
+ if (q !== '') {
493
+ if (st) {
494
+ // 结构化:按工具名 / 文件路径 / 错误信号精确检索。
495
+ if (stKind === 'tool') {
496
+ if (!(s.toolNames || []).some((n) => n.toLowerCase().includes(stVal))) continue
497
+ } else if (stKind === 'file' || stKind === 'path') {
498
+ if (!(s.artifactPaths || []).some((p) => p.toLowerCase().includes(stVal))) continue
499
+ } else {
500
+ if (!(s.toolErrors > 0)) continue
501
+ }
502
+ } else {
503
+ // 自由文本:标题/工作区/ID/cwd + 工具名 + 文件路径宽松匹配。
504
+ const hay = String(s.title || '') + ' ' + group.name + ' ' + String(s.id || '') + ' ' + String(s.cwd || '') +
505
+ ' ' + (s.toolNames || []).join(' ') + ' ' + (s.artifactPaths || []).join(' ')
506
+ if (!hay.toLowerCase().includes(q)) continue
507
+ }
508
+ }
509
+ const now = Date.now()
510
+ const running = s.lastEventTime !== null && s.lastEventTime !== undefined && (now - s.lastEventTime) < ACTIVE_WINDOW_MS
511
+ out.push({ ...s, workspace: group.name, workspaceLabel: wsLabelOf(group), workspaceCwd: wsCwdOf(group), running, isEmpty: isSessionEmpty(s) })
512
+ }
513
+ }
514
+ // 排序:除「最早创建」外均按降序。默认「最近运行」= 最后活动时间(lastEventTime)。
515
+ const key = (r) => {
516
+ if (sort === 'recent') return r.lastEventTime || 0
517
+ if (sort === 'newest') return r.createdAt || 0
518
+ if (sort === 'oldest') return r.createdAt || 0
519
+ if (sort === 'tools') return r.toolCalls || 0
520
+ if (sort === 'longest') return (r.lastEventTime || 0) - (r.createdAt || 0)
521
+ return 0
522
+ }
523
+ const ascending = sort === 'oldest'
524
+ out.sort((a, b) => (ascending ? key(a) - key(b) : key(b) - key(a)))
525
+ return out
526
+ }, [state.data, query, wsFilter, sort, showEmpty, showIssues])
527
+
528
+ // M5b 档案统计:聚合工具出现热度与问题会话数,跟随选中的工作区(wsFilter)变化。
529
+ const stats = useMemo(() => {
530
+ const toolAgg = new Map()
531
+ let issues = 0
532
+ let roots = 0
533
+ for (const g of (state.data && state.data.workspaces) || []) {
534
+ if (wsFilter !== '' && wsFilter !== g.name) continue // 统计范围 = 当前选中的工作区
535
+ for (const s of g.sessions) {
536
+ if (s.parentSession) continue
537
+ roots++
538
+ if (s.toolErrors > 0) issues++
539
+ for (const n of s.toolNames || []) toolAgg.set(n, (toolAgg.get(n) || 0) + 1)
540
+ }
541
+ }
542
+ const topTools = [...toolAgg.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
543
+ return { topTools, issues, roots }
544
+ }, [state.data, wsFilter])
545
+
546
+ const wsNames = ((state.data && state.data.workspaces) || []).map((g) => g.name)
547
+ // 各工作区的会话计数(含空会话,反映磁盘全貌)。
548
+ const wsCounts = new Map(((state.data && state.data.workspaces) || []).map((g) => [g.name, g.sessionCount]))
549
+ const totalCount = ((state.data && state.data.workspaces) || []).reduce((a, g) => a + g.sessionCount, 0)
550
+
551
+ const FOLDER_ICON = `<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1.5 4.5a1 1 0 0 1 1-1h3.6l1.5 1.8h5.9a1 1 0 0 1 1 1v5.2a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1z"/></svg>`
552
+ const FILTER_ICON = `<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true"><path d="M2.5 4.5h11M4.5 8h7M6.5 11.5h3"/></svg>`
553
+
554
+ return h('div', { className: 'sf-view' },
555
+ h('div', { className: 'sf-viewHeader' },
556
+ h('h2', { className: 'sf-viewTitle' }, STR.title),
557
+ h('input', {
558
+ className: 'sf-input', style: { flex: '1 1 200px', minWidth: 160 },
559
+ placeholder: STR.search, value: query,
560
+ onChange: (e) => setQuery(e.target.value),
561
+ }),
562
+ h('select', { className: 'sf-input', value: sort, onChange: (e) => setSort(e.target.value) },
563
+ h('option', { value: 'recent' }, STR.sortRecent),
564
+ h('option', { value: 'newest' }, STR.sortNewest),
565
+ h('option', { value: 'oldest' }, STR.sortOldest),
566
+ h('option', { value: 'tools' }, STR.sortTools),
567
+ h('option', { value: 'longest' }, STR.sortLongest),
568
+ ),
569
+ h('label', { className: 'sf-muted', style: { display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
570
+ h('input', { type: 'checkbox', checked: showEmpty, onChange: (e) => setShowEmpty(e.target.checked) }),
571
+ STR.showEmpty,
572
+ ),
573
+ h('button', { className: 'sf-btn', onClick: rescan, disabled: state.phase === 'loading' },
574
+ state.phase === 'loading' ? STR.scanning : STR.rescan),
575
+ h('button', { className: 'sf-btn' + (cacheOpen ? ' sf-btnActive' : ''), onClick: () => setCacheOpen(!cacheOpen) }, STR.cache),
576
+ h('button', { className: 'sf-btn', onClick: props.onClose }, '✕ ' + STR.close),
577
+ ),
578
+ // 缓存管理面板:仅本插件缓存,查看体积 + 分类清理(清理后自动重建,无需重启)。
579
+ cacheOpen && h('div', { className: 'sf-cachePanel' },
580
+ h('div', { className: 'sf-muted', style: { flex: 'none' } }, STR.cacheHint),
581
+ h('div', { className: 'sf-cacheRow' },
582
+ h('span', { className: 'sf-cacheLabel' }, STR.cacheTotal),
583
+ h('span', { className: 'sf-cacheValue' }, cacheInfo ? fmtBytes(cacheInfo.totalBytes) : '…'),
584
+ h('button', { className: 'sf-btn', style: { marginLeft: 'auto' }, onClick: loadCacheInfo, disabled: cacheBusy }, '↻'),
585
+ ),
586
+ h('div', { className: 'sf-cacheRow' },
587
+ h('span', { className: 'sf-cacheLabel' }, STR.cacheIndex + ' (' + (cacheInfo ? cacheInfo.indexFiles.length : '…') + ')'),
588
+ h('span', { className: 'sf-cacheValue' }, cacheInfo ? fmtBytes(cacheInfo.indexBytes) : ''),
589
+ ),
590
+ h('div', { className: 'sf-cacheRow' },
591
+ h('span', { className: 'sf-cacheLabel' }, STR.cacheTimeline + ' (' + (cacheInfo ? cacheInfo.timelineFiles.length : '…') + ')'),
592
+ h('span', { className: 'sf-cacheValue' }, cacheInfo
593
+ ? fmtBytes(cacheInfo.timelineBytes) + ' / ' + STR.cacheLimit + ' ' + fmtBytes(cacheInfo.timelineLimit)
594
+ : ''),
595
+ h('button', { className: 'sf-btn', style: { marginLeft: 'auto' }, onClick: () => cleanCache('timeline'), disabled: cacheBusy }, STR.cleanTimeline),
596
+ h('button', { className: 'sf-btn sf-btnDanger', onClick: () => cleanCache('all'), disabled: cacheBusy }, STR.cleanAll),
597
+ ),
598
+ cacheMsg && h('div', { className: 'sf-muted' }, cacheMsg),
599
+ (!cacheInfo || (cacheInfo.totalBytes === 0)) && h('div', { className: 'sf-muted' }, STR.cacheEmpty),
600
+ ),
601
+ // 工作区筛选 tabs:独立一行,醒目可见,点击切换;只显示末级目录名,悬浮显示完整路径。
602
+ h('div', { className: 'sf-wsTabs' },
603
+ h('button', { className: 'sf-wsTab', 'data-active': wsFilter === '' ? 'true' : undefined, onClick: () => setWsFilter('') },
604
+ h('span', { className: 'sf-wsTabName' }, STR.allWorkspaces),
605
+ h('span', { className: 'sf-wsTabCount' }, totalCount),
606
+ ),
607
+ ((state.data && state.data.workspaces) || []).map((g) =>
608
+ h('button', {
609
+ key: g.name, className: 'sf-wsTab', title: g.cwd || g.name,
610
+ 'data-active': wsFilter === g.name ? 'true' : undefined,
611
+ onClick: () => setWsFilter(g.name),
612
+ },
613
+ h('span', { className: 'sf-wsTabName' }, wsLabelOf(g)),
614
+ h('span', { className: 'sf-wsTabCount' }, wsCounts.get(g.name) || 0),
615
+ )),
616
+ ),
617
+ // M5b 档案统计条:工具使用热度 + 问题会话速览。
618
+ state.phase === 'ready' && h('div', { className: 'sf-statsBar' },
619
+ h('span', { className: 'sf-statsLabel' }, STR.toolTop),
620
+ stats.topTools.map(([name, cnt]) =>
621
+ h('span', { key: name, className: 'sf-statChip', title: name + ' · ' + cnt + ' 个会话' },
622
+ h('span', {}, name),
623
+ h('span', { className: 'sf-statCount' }, cnt),
624
+ )),
625
+ h('button', {
626
+ className: 'sf-statChip' + (showIssues ? ' active' : ' clickable'),
627
+ title: STR.issuesHint,
628
+ onClick: () => setShowIssues(!showIssues),
629
+ },
630
+ h('span', {}, '⚠ ' + STR.issues),
631
+ h('span', { className: 'sf-statCount err' }, stats.issues),
632
+ ),
633
+ ),
634
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.scanning),
635
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.loadFailed + ': ' + String(state.error)),
636
+ state.phase === 'ready' && h('div', { className: 'sf-body' },
637
+ rows.length === 0 && h('div', { className: 'sf-hint' }, STR.noMatch),
638
+ rows.map((s) =>
639
+ h('div', {
640
+ key: s.id, className: 'sf-card', title: s.id,
641
+ onClick: () => { props.onOpen(s, query) },
642
+ },
643
+ // 工作区行:只显示末级目录名(与左侧栏一致),悬浮显示完整路径;点击即筛选到该工作区。
644
+ h('div', {
645
+ className: 'sf-cardWs', title: s.workspaceCwd || s.workspace,
646
+ onClick: (e) => { e.stopPropagation(); setWsFilter(s.workspace) },
647
+ },
648
+ h('span', { className: 'sf-cardWsIcon', dangerouslySetInnerHTML: { __html: FOLDER_ICON } }),
649
+ h('span', { className: 'sf-cardWsName' }, s.workspaceLabel || s.workspace),
650
+ h('span', { className: 'sf-cardWsFilter', dangerouslySetInnerHTML: { __html: FILTER_ICON } }),
651
+ ),
652
+ h('div', { className: 'sf-cardTitle' },
653
+ h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, s.title || STR.unknownTitle),
654
+ s.isEmpty && h('span', { className: 'sf-badge sf-badgeEnd' }, STR.emptySession),
655
+ s.running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
656
+ s.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent + ' · d' + s.delegationDepth),
657
+ s.toolErrors > 0 && h('span', { className: 'sf-badge sf-badgeErr' }, STR.errors + ': ' + s.toolErrors),
658
+ ),
659
+ h('div', { className: 'sf-muted' },
660
+ h('span', {}, fmtTime(s.createdAt)),
661
+ h('span', { style: { marginLeft: 8 } }, STR.duration + ' ' + fmtDuration(s.lastEventTime - s.createdAt)),
662
+ ),
663
+ h('div', { className: 'sf-statsRow sf-muted' },
664
+ h('span', {}, STR.turns + ' ' + (s.turns || 0)),
665
+ h('span', {}, STR.steps + ' ' + (s.steps || 0)),
666
+ h('span', {}, STR.tools + ' ' + (s.toolCalls || 0)),
667
+ h('span', {}, STR.msgs + ' ' + ((s.userMessages || 0) + (s.assistantMessages || 0))),
668
+ h('span', {}, STR.records + ' ' + (s.recordCount || 0)),
669
+ h('span', {}, STR.size + ' ' + fmtSize(s.sizeBytes)),
670
+ ),
671
+ h('div', { className: 'sf-mono sf-muted', style: { marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
672
+ s.cwd || s.id),
673
+ )),
674
+ ),
675
+ )
676
+ }
677
+
678
+ // ── 折叠时间线渲染(详情页 / 血缘子代理详情 共用)──────────────────
679
+ // props: { turns, collapsed: Set, toggle: (key) => void, onOpenSubagent?: (childId) => void, hideHead?: boolean }
680
+ // 渐进披露:回合/用户发言/最终结论默认可见(行数 clamp),工具默认折叠;真实时间序。
681
+ function TimelineTurns(props) {
682
+ const { turns, collapsed, toggle, onOpenSubagent, hideHead, liveActive, activeTurn } = props
683
+ const turnList = turns || []
684
+ return turnList.map((t, tIdx) => {
685
+ const tKey = 'turn-' + t.turn
686
+ const tCollapsed = collapsed.has(tKey)
687
+ const isActive = activeTurn === t.turn // 当前选中回合(点击定位后高亮)
688
+ const tc = t.steps.reduce((a, s) => a + s.toolCalls.length, 0)
689
+ const te = t.steps.reduce((a, s) => a + s.toolCalls.filter((c) => c.isError).length, 0)
690
+ // 结论标记:该回合时间序中最后一条含正文的助手消息。
691
+ const assistantItems = (t.items || []).filter((i) => i.kind === 'assistant' && i.hasText)
692
+ const finalSeq = assistantItems.length > 0 ? assistantItems[assistantItems.length - 1].seq : null
693
+ // 运行中回合判定(实时模式且会话活跃时):最后一个回合,或含未完成工具调用
694
+ // (resultTime === null,工具仍在执行)的回合 → 高亮 + 底部动态生成标志。
695
+ const hasUnfinishedTool = (t.steps || []).some((s) => (s.toolCalls || []).some((c) => c.resultTime === null))
696
+ const isLiveTurn = liveActive === true && (tIdx === turnList.length - 1 || hasUnfinishedTool)
697
+ const turnCls = 'sf-turn' + (isLiveTurn ? ' sf-turnLive' : '') + (isActive ? ' sf-turnSelected' : '')
698
+ return h('div', { key: tKey, id: 'sf-turn-' + t.turn, className: turnCls },
699
+ !hideHead && h('div', { className: 'sf-turnHead' + (isLiveTurn ? ' sf-turnHeadLive' : ''), onClick: () => { toggle(tKey); setActiveTurn && setActiveTurn(t.turn) } },
700
+ h('span', { style: { fontWeight: 600 } }, STR.turns + ' ' + t.turn),
701
+ isLiveTurn && h('span', { className: 'sf-badge sf-badgeRun' }, STR.liveTurn),
702
+ h('span', { className: 'sf-muted' }, STR.tools + ' ' + tc + (te > 0 ? ' · ' + STR.errors + ' ' + te : '')),
703
+ h('span', { className: 'sf-muted' }, fmtTime(t.startTime) + (t.endTime ? ' → ' + fmtTime(t.endTime) : '')),
704
+ h('span', { className: 'sf-muted', style: { marginLeft: 'auto' } }, tCollapsed ? STR.expanded : STR.collapsed),
705
+ ),
706
+ !tCollapsed && h('div', { className: 'sf-turnBody' },
707
+ // 按真实时间序渲染(items 由 host 按事件 seq 交织生成):
708
+ // 用户发言 → 助手思考/回复 → 工具调用 … 完全遵循发生顺序。
709
+ (t.items || []).map((it) => {
710
+ if (it.kind === 'user') {
711
+ const uKey = 'msg-' + it.seq
712
+ const open = !collapsed.has(uKey)
713
+ return h('div', { key: uKey, id: 'sf-msg-' + it.seq, className: 'sf-msg sf-msgUser' },
714
+ h('div', { className: 'sf-msgRole' }, STR.user),
715
+ // 用户发言:核心内容默认可见,超过 5 行折叠(点击展开/收起)。
716
+ h('div', { className: 'sf-msgText' + (open ? ' sf-open' : ''), onClick: () => toggle(uKey), title: open ? STR.collapsed : STR.expanded }, it.preview),
717
+ h('button', { className: 'sf-msgToggle', onClick: () => toggle(uKey) }, open ? STR.collapsed : STR.expanded),
718
+ )
719
+ }
720
+ if (it.kind === 'inject') {
721
+ // 智能体/插件注入的信息:灰色标记,不进「用户发言」大纲。
722
+ const uKey = 'msg-' + it.seq
723
+ const open = !collapsed.has(uKey)
724
+ return h('div', { key: uKey, id: 'sf-msg-' + it.seq, className: 'sf-msg sf-msgInject' },
725
+ h('div', { className: 'sf-msgRole' },
726
+ STR.user,
727
+ h('span', { className: 'sf-injectBadge' }, STR.injected + ' · ' + (it.sourceKind || '?')),
728
+ ),
729
+ h('div', { className: 'sf-msgTextInject' + (open ? ' sf-open' : ''), onClick: () => toggle(uKey), title: open ? STR.collapsed : STR.expanded }, it.preview),
730
+ h('button', { className: 'sf-msgToggle', onClick: () => toggle(uKey) }, open ? STR.collapsed : STR.expanded),
731
+ )
732
+ }
733
+ if (it.kind === 'assistant') {
734
+ const aKey = 'msg-' + it.seq
735
+ const thinkKey = 'think-' + it.seq
736
+ const open = !collapsed.has(aKey)
737
+ const thinkOpen = !collapsed.has(thinkKey)
738
+ const isFinal = it.seq === finalSeq
739
+ return h('div', { key: aKey, className: 'sf-msg sf-msgAssistant' },
740
+ h('div', { className: 'sf-msgRole' },
741
+ STR.assistant,
742
+ isFinal && h('span', { className: 'sf-badge sf-badgeRun', style: { marginLeft: 8 } }, STR.conclusion),
743
+ ),
744
+ // 思考内容:默认只显示 2 行,点击展开/收起。
745
+ it.hasThinking && h('div', { className: 'sf-thinking' + (thinkOpen ? ' sf-open' : ''), onClick: () => toggle(thinkKey), title: thinkOpen ? STR.collapsed : STR.expanded },
746
+ h('span', { className: 'sf-thinkingLabel' }, STR.thinking),
747
+ it.thinkingPreview,
748
+ ),
749
+ // 正文(最终结论):核心内容默认可见,超过 5 行折叠。
750
+ it.hasText && h('div', { className: 'sf-msgText' + (open ? ' sf-open' : ''), onClick: () => toggle(aKey), title: open ? STR.collapsed : STR.expanded }, it.preview),
751
+ (it.hasThinking || it.hasText) && h('button', { className: 'sf-msgToggle', onClick: () => toggle(aKey) }, open ? STR.collapsed : STR.expanded),
752
+ )
753
+ }
754
+ if (it.kind === 'step') {
755
+ const s = t.steps.find((x) => x.step === it.step)
756
+ return h('div', { key: 'step-' + it.seq, className: 'sf-step' },
757
+ h('div', { className: 'sf-stepHead' },
758
+ h('span', {}, STR.step + ' ' + it.step),
759
+ s && h('span', {}, s.toolCalls.length + ' ' + STR.tools),
760
+ s && s.endTime !== null && h('span', {}, fmtDuration(s.endTime - s.startTime)),
761
+ ),
762
+ )
763
+ }
764
+ if (it.kind === 'tool') {
765
+ const c = it.call
766
+ const cKey = 'tool-' + c.callId
767
+ const open = !collapsed.has(cKey)
768
+ const status = c.isError === true ? 'err' : (c.resultTime === null ? 'run' : 'ok')
769
+ return h('div', {
770
+ key: cKey, id: 'sf-tc-' + c.callId, className: 'sf-tool',
771
+ },
772
+ h('div', { className: 'sf-toolHead', onClick: () => toggle(cKey) },
773
+ h('span', { className: 'sf-toolStatus ' + status }),
774
+ h('span', { className: 'sf-toolName' }, c.name),
775
+ h('span', { className: 'sf-muted' }, c.durationMs !== null ? fmtDuration(c.durationMs) : (c.resultTime === null ? '…' : '—')),
776
+ c.isError === true && h('span', { className: 'sf-badge sf-badgeErr' }, STR.errors),
777
+ // 子代理工具:一键跳转查看该子代理的实际执行内容。
778
+ c.childSessionId && typeof onOpenSubagent === 'function' && h('button', {
779
+ className: 'sf-btn', style: { padding: '1px 8px', fontSize: 11, flex: 'none' },
780
+ onClick: (e) => { e.stopPropagation(); onOpenSubagent(c.childSessionId) },
781
+ }, STR.viewSubagent),
782
+ // 参数预览:代码标识符样式(等宽+底色+单行省略),与导出一致。
783
+ h('span', { className: 'sf-toolArgPrev', title: c.argumentsText || c.argumentsPreview },
784
+ c.argumentsPreview),
785
+ h('span', { className: 'sf-muted' }, open ? STR.collapsed : STR.expanded),
786
+ ),
787
+ open && h('div', { className: 'sf-toolBody' },
788
+ h('div', { className: 'sf-preLabel' }, STR.args + ' · json'),
789
+ h('pre', { className: 'sf-pre' }, c.argumentsText || '—'),
790
+ c.resultTime !== null && h('div', { className: 'sf-preLabel' }, STR.result + (c.isError ? ' · error' : ' · text')),
791
+ c.resultTime !== null && h('pre', { className: 'sf-pre' + (c.isError ? ' err' : '') },
792
+ c.resultText || '(空)'),
793
+ (c.artifacts || []).length > 0 && h('div', { className: 'sf-preLabel' }, STR.artifacts),
794
+ (c.artifacts || []).length > 0 && h('div', {},
795
+ c.artifacts.map((p) => h('span', { key: p, className: 'sf-chip', title: p }, p))),
796
+ ),
797
+ )
798
+ }
799
+ return null
800
+ }),
801
+ // 运行中回合底部动态标志:AI 生成/任务执行中的提示(三点跳动省略号)。
802
+ isLiveTurn && !tCollapsed && h('div', { className: 'sf-liveTyping', 'aria-label': STR.liveGenerating },
803
+ h('span', { className: 'sf-liveDots' },
804
+ h('span', {}), h('span', {}), h('span', {}),
805
+ ),
806
+ h('span', { className: 'sf-muted' }, STR.liveGenerating),
807
+ ),
808
+ ),
809
+ )
810
+ })
811
+ }
812
+
813
+ // ── 回合摘要列表(秒开 + 默认折叠)────────────────────────────────
814
+ // props: { sessionId, lightTurns, collapsed: Set, toggle, ensureTurn, turnItems, onOpenSubagent }
815
+ // 每个回合默认折叠,只展示「用户发言 + 最终结论」摘要;展开时才按需加载完整时间线。
816
+ function TurnList(props) {
817
+ const { sessionId, lightTurns, collapsed, toggle, ensureTurn, turnItems, onOpenSubagent, activeTurn, onSelectTurn } = props
818
+ return (lightTurns || []).map((lt) => {
819
+ const tKey = 'turn-' + lt.turn
820
+ const isCollapsed = collapsed.has(tKey)
821
+ const full = turnItems.get(lt.turn)
822
+ const isActive = activeTurn === lt.turn
823
+ const onToggle = () => {
824
+ const expanding = isCollapsed
825
+ toggle(tKey)
826
+ if (expanding) ensureTurn(lt.turn) // 展开时按需加载完整时间线
827
+ if (typeof onSelectTurn === 'function') onSelectTurn(lt.turn)
828
+ }
829
+ return h('div', { key: tKey, id: 'sf-turn-' + lt.turn, className: 'sf-turn' + (isActive ? ' sf-turnSelected' : ''), 'data-loaded': full ? 'true' : undefined },
830
+ h('div', { className: 'sf-turnHead', onClick: onToggle },
831
+ h('span', { style: { fontWeight: 600 } }, STR.turns + ' ' + lt.turn),
832
+ h('span', { className: 'sf-muted' }, STR.tools + ' ' + lt.toolCount + (lt.errorCount > 0 ? ' · ' + STR.errors + ' ' + lt.errorCount : '')),
833
+ h('span', { className: 'sf-muted' }, fmtTime(lt.startTime) + (lt.endTime ? ' → ' + fmtTime(lt.endTime) : '')),
834
+ h('span', { className: 'sf-muted', style: { marginLeft: 'auto' } }, isCollapsed ? STR.expanded : STR.collapsed),
835
+ ),
836
+ // 折叠时展示摘要:用户发言 + 最终结论。
837
+ h('div', { className: 'sf-turnSummary', onClick: onToggle },
838
+ lt.userMessages.length > 0 && h('div', { className: 'sf-turnSummaryRow' },
839
+ h('span', { className: 'sf-turnSummaryTag user' }, STR.user),
840
+ h('span', { className: 'sf-turnSummaryText' }, lt.userMessages[lt.userMessages.length - 1].preview),
841
+ ),
842
+ lt.conclusionPreview && h('div', { className: 'sf-turnSummaryRow' },
843
+ h('span', { className: 'sf-turnSummaryTag ok' }, STR.conclusion),
844
+ h('span', { className: 'sf-turnSummaryText' }, lt.conclusionPreview),
845
+ ),
846
+ ),
847
+ !isCollapsed && h('div', { className: 'sf-turnBody' },
848
+ full
849
+ ? h(TimelineTurns, { turns: [full], collapsed, toggle, hideHead: true, onOpenSubagent })
850
+ : h('div', { className: 'sf-hint' }, STR.detailLoading),
851
+ ),
852
+ )
853
+ })
854
+ }
855
+
856
+ // ── 血缘树视图(M4)───────────────────────────────────────────────
857
+ // props: { sessionId, connection, onBack }
858
+ // 数据双通道:离线档案树(parentSession 链接,host lineage 方法)+ 运行时实时树
859
+ // (subagents.list 递归,在线子代理)。节点详情:离线 get 优先,回退运行时
860
+ // subagents.history → host derive 桥接 → 复用 TimelineTurns 折叠渲染。
861
+ function LineageView(props) {
862
+ const { sessionId, connection, initialSelectId } = props
863
+ const [offline, setOffline] = useState(null)
864
+ const [live, setLive] = useState(null)
865
+ const [liveState, setLiveState] = useState('none') // none/loading/ready
866
+ const [selected, setSelected] = useState(null) // { id, label, parentId, mode }
867
+ const [detail, setDetail] = useState(null) // { phase, timeline?, counts?, session? }
868
+ const [collapsed, setCollapsed] = useState(() => new Set())
869
+ const collapsedInit = useRef(false)
870
+ const selectHandled = useRef(false)
871
+
872
+ // 工具行「查看子代理」直达:树就绪后自动选中指定子代理并加载详情。
873
+ useEffect(() => {
874
+ if (!initialSelectId || selectHandled.current) return
875
+ if (selected !== null && selected.id === initialSelectId) { selectHandled.current = true; return }
876
+ const ready = offline !== null || liveState === 'ready'
877
+ if (!ready) return
878
+ const findIn = (nodes) => {
879
+ for (const n of nodes || []) {
880
+ if (n.id === initialSelectId) return n
881
+ const hit = findIn(n.children)
882
+ if (hit) return hit
883
+ }
884
+ return null
885
+ }
886
+ let node = null
887
+ if (liveState === 'ready') node = findIn(live) || null
888
+ if (!node && offline && offline.focus) node = findIn(offline.focus.children) || null
889
+ if (node) {
890
+ selectHandled.current = true
891
+ selectNode({ ...node, parentId: node.parentId, mode: node.mode })
892
+ }
893
+ }, [initialSelectId, offline, live, liveState, selected])
894
+
895
+ useEffect(() => {
896
+ let alive = true
897
+ api('lineage', { sessionId }).then((json) => {
898
+ if (alive && json && json.ok) setOffline(json)
899
+ }).catch(() => {})
900
+ if (connection !== undefined) {
901
+ setLiveState('loading')
902
+ const buildLiveTree = async (parentId) => {
903
+ const resp = await connection.api.subagents.list({ parentSessionId: parentId })
904
+ if (!resp || !resp.result || !resp.result.ok) return []
905
+ const entries = (resp.result.value && resp.result.value.entries || []).filter((e) => e.kind === 'child')
906
+ const nodes = []
907
+ for (const e of entries) {
908
+ const children = e.hasChildren ? await buildLiveTree(e.id) : []
909
+ nodes.push({ id: e.id, label: e.label || null, mode: e.mode, activity: e.activity, parentId, children })
910
+ }
911
+ return nodes
912
+ }
913
+ buildLiveTree(sessionId).then((nodes) => {
914
+ if (alive) { setLive(nodes); setLiveState('ready') }
915
+ }).catch(() => { if (alive) setLiveState('none') })
916
+ }
917
+ return () => { alive = false }
918
+ }, [sessionId])
919
+
920
+ // 默认折叠:选中子代理详情就绪后所有回合折叠(只显示摘要)。
921
+ useEffect(() => {
922
+ if (detail && detail.phase === 'ready' && !collapsedInit.current) {
923
+ collapsedInit.current = true
924
+ const all = new Set()
925
+ for (const lt of detail.lightTurns || []) all.add('turn-' + lt.turn)
926
+ setCollapsed(all)
927
+ }
928
+ }, [detail])
929
+
930
+ // 展开回合时按需加载完整时间线(getTurn,与详情页同一缓存)。
931
+ const [turnItems, setTurnItems] = useState(() => new Map())
932
+ const loadingTurns = useRef(new Set())
933
+ const ensureTurn = async (turnNo) => {
934
+ const sid = selected !== null ? selected.id : null
935
+ if (!sid || turnItems.has(turnNo) || loadingTurns.current.has(turnNo)) return
936
+ loadingTurns.current.add(turnNo)
937
+ try {
938
+ const json = await api('getTurn', { sessionId: sid, turn: turnNo })
939
+ if (json && json.ok && json.turn) {
940
+ setTurnItems((prev) => new Map(prev).set(turnNo, json.turn))
941
+ }
942
+ } catch (e) {
943
+ console.warn('[dsh-session-flow] getTurn failed', turnNo, e)
944
+ } finally {
945
+ loadingTurns.current.delete(turnNo)
946
+ }
947
+ }
948
+
949
+ const toggle = (key) => {
950
+ setCollapsed((prev) => {
951
+ const next = new Set(prev)
952
+ if (next.has(key)) next.delete(key)
953
+ else next.add(key)
954
+ return next
955
+ })
956
+ }
957
+
958
+ const selectNode = async (node) => {
959
+ setSelected(node)
960
+ collapsedInit.current = false
961
+ setCollapsed(new Set())
962
+ setTurnItems(new Map())
963
+ setDetail({ phase: 'loading' })
964
+ // 1) 离线档案优先(light:回合摘要 + 工具统计)
965
+ try {
966
+ const arch = await api('get', { sessionId: node.id })
967
+ if (arch && arch.ok) {
968
+ setDetail({ phase: 'ready', lightTurns: arch.lightTurns, toolStats: arch.toolStats, counts: arch.counts, session: arch.session })
969
+ return
970
+ }
971
+ } catch (e) {}
972
+ // 2) 运行时 history 桥接(在线子代理)→ host derive 得 light 结构
973
+ if (connection !== undefined && node.parentId !== undefined) {
974
+ try {
975
+ const resp = await connection.api.subagents.history({
976
+ parentSessionId: node.parentId,
977
+ childSessionId: node.id,
978
+ mode: node.mode,
979
+ maxMessages: 120,
980
+ })
981
+ if (resp && resp.result && resp.result.ok && resp.result.value && Array.isArray(resp.result.value.events)) {
982
+ const events = resp.result.value.events.map((entry) => entry.event)
983
+ const derived = await api('derive', { events })
984
+ if (derived && derived.ok) {
985
+ // derive 返回完整 timeline——转成 light 结构
986
+ const turns = (derived.timeline && derived.timeline.turns) || []
987
+ const lightTurns = turns.map((t) => {
988
+ const toolCount = t.steps.reduce((a, s) => a + s.toolCalls.length, 0)
989
+ const errorCount = t.steps.reduce((a, s) => a + s.toolCalls.filter((c) => c.isError).length, 0)
990
+ const finals = t.assistantMessages.filter((a) => a.hasText)
991
+ return {
992
+ turn: t.turn, startTime: t.startTime, endTime: t.endTime, toolCount, errorCount,
993
+ userMessages: t.userMessages.map((u) => ({ seq: u.seq, preview: u.preview })),
994
+ conclusionPreview: finals.length > 0 ? finals[finals.length - 1].preview : '',
995
+ }
996
+ })
997
+ setDetail({ phase: 'ready', lightTurns, counts: derived.counts, session: derived.session })
998
+ return
999
+ }
1000
+ }
1001
+ } catch (e) {}
1002
+ }
1003
+ setDetail({ phase: 'empty' })
1004
+ }
1005
+
1006
+ // 树渲染(递归行)。
1007
+ const renderNode = (node, depth, isLive) => {
1008
+ const active = selected !== null && selected.id === node.id
1009
+ const hasChildren = (node.children && node.children.length > 0)
1010
+ const label = node.label || node.id
1011
+ const meta = isLive
1012
+ ? ((node.mode === 'continuable' ? STR.modeContinuable : STR.modeOneShot) + ' · ' + (node.activity === 'running' ? STR.running : STR.ended))
1013
+ : ((node.toolCalls || 0) + ' ' + STR.tools + (node.toolErrors ? ' · ' + node.toolErrors + ' ' + STR.errors : ''))
1014
+ return h('div', { key: node.id },
1015
+ h('div', {
1016
+ className: 'sf-treeRow', 'data-active': active ? 'true' : undefined,
1017
+ title: node.id,
1018
+ onClick: () => selectNode({ ...node, parentId: node.parentId, mode: node.mode }),
1019
+ },
1020
+ h('span', { className: 'sf-treeIndent', style: { width: 14 * depth } }),
1021
+ hasChildren && h('span', { className: 'sf-treeCaret' }, '▾'),
1022
+ isLive && h('span', { className: 'sf-liveDot ' + (node.activity === 'running' ? 'running' : 'inactive') }),
1023
+ h('span', { className: 'sf-treeLabel' }, String(label).slice(0, 40)),
1024
+ h('span', { className: 'sf-treeMeta', style: { marginLeft: 'auto' } }, meta),
1025
+ ),
1026
+ hasChildren && node.children.map((c) => renderNode(c, depth + 1, isLive)),
1027
+ )
1028
+ }
1029
+
1030
+ const offlineChildren = offline && offline.focus ? offline.focus.children || [] : []
1031
+ const hasOffline = offlineChildren.length > 0
1032
+ const hasLive = liveState === 'ready' && live !== null && live.length > 0
1033
+ const hasAny = hasOffline || hasLive
1034
+
1035
+ return h('div', { className: 'sf-view' },
1036
+ h('div', { className: 'sf-viewHeader' },
1037
+ h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + STR.back),
1038
+ h('h2', { className: 'sf-viewTitle' }, STR.lineage),
1039
+ ),
1040
+ h('div', { className: 'sf-detailBody' },
1041
+ // 左:血缘树
1042
+ h('div', { className: 'sf-tree' },
1043
+ h('div', { className: 'sf-groupTitle' }, STR.liveTree + (liveState === 'loading' ? '…' : '')),
1044
+ !hasLive && liveState !== 'loading' && h('div', { className: 'sf-hint' }, STR.noLineage),
1045
+ hasLive && live.map((n) => renderNode(n, 0, true)),
1046
+ h('div', { className: 'sf-groupTitle', style: { marginTop: 10 } }, STR.offlineTree),
1047
+ !hasOffline && h('div', { className: 'sf-hint' }, STR.noLineage),
1048
+ hasOffline && offlineChildren.map((n) => renderNode(n, 0, false)),
1049
+ ),
1050
+ // 右:选中节点详情
1051
+ h('div', { className: 'sf-treeDetail' },
1052
+ selected === null && h('div', { className: 'sf-hint' }, STR.noDetail),
1053
+ detail && detail.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.loadDetail),
1054
+ detail && detail.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.noDetail),
1055
+ detail && detail.phase === 'ready' && h('div', { className: 'sf-muted', style: { flex: 'none' } },
1056
+ STR.subagentDetail + ' · ' + String(selected.label || selected.id).slice(0, 60) +
1057
+ (detail.counts ? ' · ' + (detail.counts['tool/call'] || 0) + ' ' + STR.tools : ''),
1058
+ ),
1059
+ detail && detail.phase === 'ready' && h(TurnList, {
1060
+ sessionId: selected.id,
1061
+ lightTurns: detail.lightTurns || [],
1062
+ collapsed,
1063
+ toggle,
1064
+ ensureTurn,
1065
+ turnItems,
1066
+ }),
1067
+ ),
1068
+ ),
1069
+ )
1070
+ }
1071
+
1072
+ // ── 详情页(M3 折叠时间线)─────────────────────────────────────────
1073
+ // props: { session (总览卡片数据), workspace, sessions (runtime, 跳转用), onBack }
1074
+ // 交互:信息流默认全部折叠(回合/消息/工具),右侧为用户发言大纲(点击展开回合并定位)。
1075
+ function SessionFlowDetail(props) {
1076
+ const { session } = props
1077
+ const [state, setState] = useState({ phase: 'loading', error: null, data: null })
1078
+ // 折叠状态:键集合,has(key) = 折叠。turn-N / tool-<callId> / msg-<seq>
1079
+ const [collapsed, setCollapsed] = useState(() => new Set())
1080
+ const collapsedInit = useRef(false)
1081
+
1082
+ useEffect(() => {
1083
+ // 空会话(新建后未对话):不请求详情,直接显示占位提示。
1084
+ const isEmpty = session.empty === true ||
1085
+ (!(session.userMessages || 0) && !(session.toolCalls || 0) && !(session.turns || 0))
1086
+ if (isEmpty) {
1087
+ setState({ phase: 'empty', error: null, data: null })
1088
+ return
1089
+ }
1090
+ let alive = true
1091
+ api('get', { sessionId: session.id }).then((json) => {
1092
+ if (!alive) return
1093
+ if (json && json.ok) setState({ phase: 'ready', error: null, data: json })
1094
+ else setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
1095
+ }).catch((e) => {
1096
+ if (alive) setState({ phase: 'error', error: String(e), data: null })
1097
+ })
1098
+ return () => { alive = false }
1099
+ }, [session.id])
1100
+
1101
+ // 默认折叠:所有回合折叠,只展示「用户发言 + 结论」摘要;展开才按需加载。
1102
+ useEffect(() => {
1103
+ if (state.phase !== 'ready' || collapsedInit.current) return
1104
+ collapsedInit.current = true
1105
+ const all = new Set()
1106
+ for (const lt of state.data.lightTurns || []) all.add('turn-' + lt.turn)
1107
+ setCollapsed(all)
1108
+ }, [state.phase])
1109
+
1110
+ const toggle = (key) => {
1111
+ setCollapsed((prev) => {
1112
+ const next = new Set(prev)
1113
+ if (next.has(key)) next.delete(key)
1114
+ else next.add(key)
1115
+ return next
1116
+ })
1117
+ }
1118
+ // 当前选中回合(正文区/右侧区点击定位后高亮,便于知道正在看哪个回合)。
1119
+ const [activeTurn, setActiveTurn] = useState(null)
1120
+ const selectTurn = (turnNo) => setActiveTurn(turnNo)
1121
+ const expandAll = () => {
1122
+ // 全部展开:展开所有回合并逐个按需加载。
1123
+ setCollapsed(new Set())
1124
+ for (const lt of lightTurns) ensureTurn(lt.turn)
1125
+ }
1126
+ const collapseAll = () => {
1127
+ const all = new Set()
1128
+ for (const lt of lightTurns) all.add('turn-' + lt.turn)
1129
+ // 已加载回合内的消息/思考/工具也折叠。
1130
+ for (const full of turnItems.values()) {
1131
+ for (const u of full.userMessages || []) all.add('msg-' + u.seq)
1132
+ for (const a of full.assistantMessages || []) {
1133
+ all.add('msg-' + a.seq)
1134
+ if (a.hasThinking) all.add('think-' + a.seq)
1135
+ }
1136
+ for (const s of full.steps || []) {
1137
+ for (const c of s.toolCalls) all.add('tool-' + c.callId)
1138
+ }
1139
+ }
1140
+ setCollapsed(all)
1141
+ }
1142
+
1143
+ const jumpNative = () => {
1144
+ try {
1145
+ props.sessions.open(session.id)
1146
+ // 打开原生会话后收起会话流视图,交还中栏给对话界面。
1147
+ props.onClose()
1148
+ } catch (error) {
1149
+ console.warn('[dsh-session-flow] cannot open session', session.id, error)
1150
+ }
1151
+ }
1152
+
1153
+ // 右侧大纲:点击用户发言 → 展开所在回合(按需加载)→ 滚动定位到「回合开头」。
1154
+ const scrollToMsg = (turnNo, seq) => {
1155
+ setActiveTurn(turnNo)
1156
+ setCollapsed((prev) => {
1157
+ const next = new Set(prev)
1158
+ next.delete('turn-' + turnNo)
1159
+ return next
1160
+ })
1161
+ ensureTurn(turnNo)
1162
+ setTimeout(() => {
1163
+ const turnEl = document.getElementById('sf-turn-' + turnNo)
1164
+ if (turnEl) {
1165
+ turnEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
1166
+ return
1167
+ }
1168
+ const el = document.getElementById('sf-msg-' + seq)
1169
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
1170
+ }, 80)
1171
+ }
1172
+
1173
+ const data = state.data
1174
+ // 轻量时间线:回合摘要(默认折叠,秒开)。
1175
+ const lightTurns = (data && data.lightTurns) || []
1176
+ // 会话内工具统计(来自 host 服务端聚合,避免前端全量扫描)。
1177
+ const toolStats = (data && data.toolStats) || []
1178
+ // 展开回合时的完整时间线按需加载。
1179
+ const [turnItems, setTurnItems] = useState(() => new Map())
1180
+ const loadingTurns = useRef(new Set())
1181
+ const ensureTurn = async (turnNo) => {
1182
+ if (turnItems.has(turnNo) || loadingTurns.current.has(turnNo)) return
1183
+ loadingTurns.current.add(turnNo)
1184
+ try {
1185
+ const json = await api('getTurn', { sessionId: session.id, turn: turnNo })
1186
+ if (json && json.ok && json.turn) {
1187
+ setTurnItems((prev) => new Map(prev).set(turnNo, json.turn))
1188
+ }
1189
+ } catch (e) {
1190
+ console.warn('[dsh-session-flow] getTurn failed', turnNo, e)
1191
+ } finally {
1192
+ loadingTurns.current.delete(turnNo)
1193
+ }
1194
+ }
1195
+ // 统计条展开态与定位游标。
1196
+ const [statsExpanded, setStatsExpanded] = useState(false)
1197
+ const toolCursor = useRef(new Map())
1198
+
1199
+ // ── M6 实时通道:connection.api.sessions.history → host derive → 完整折叠视图 ──
1200
+ // 与 M4 子代理桥同一数据源形态(events 数组);轮询刷新仅实时模式激活时进行。
1201
+ const [liveOn, setLiveOn] = useState(false)
1202
+ const [liveState, setLiveState] = useState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null })
1203
+ const liveTimer = useRef(null)
1204
+ // 已见过的实时回合号:刷新时只折叠「新出现的回合」,已展开的回合保持用户状态
1205
+ // (此前误把所有不在折叠集合中的回合加入集合 = 每次刷新全部收拢)。
1206
+ const liveSeenTurns = useRef(new Set())
1207
+ const fetchLive = useRef(async () => {})
1208
+ fetchLive.current = async () => {
1209
+ const conn = props.connection
1210
+ if (conn === undefined || conn.api === undefined || conn.api.sessions === undefined) {
1211
+ setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
1212
+ return
1213
+ }
1214
+ setLiveState((s) => ({ ...s, phase: 'loading' }))
1215
+ try {
1216
+ // 会话地址未知时走普通 sessions 通道(当前会话);若连接层要求显式地址则回退。
1217
+ const resp = await conn.api.sessions.history({
1218
+ sessionId: session.id,
1219
+ mode: 'full',
1220
+ maxMessages: 400,
1221
+ })
1222
+ const val = resp && resp.result && resp.result.value
1223
+ if (val === undefined || !Array.isArray(val.events)) {
1224
+ setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
1225
+ return
1226
+ }
1227
+ const events = val.events.map((entry) => (entry && entry.event) || entry)
1228
+ const derived = await api('derive', { events })
1229
+ if (!derived || !derived.ok) {
1230
+ setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
1231
+ return
1232
+ }
1233
+ const tl = (derived.timeline && derived.timeline.turns) || []
1234
+ // 实时视图只保留「最近 3 个历史回合 + 当前运行回合」(若有),数量恒定,
1235
+ // 不随 history 窗口大小变化——新回合出现时最旧的被挤出。
1236
+ const LIVE_MAX_HISTORY_TURNS = 3
1237
+ const liveRunning = derived.running === true
1238
+ const keepCount = tl.length > LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
1239
+ ? LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
1240
+ : tl.length
1241
+ const displayTl = keepCount > 0 ? tl.slice(-keepCount) : tl
1242
+ setLiveState({
1243
+ phase: 'ready',
1244
+ error: null,
1245
+ timeline: displayTl,
1246
+ eventCount: events.length,
1247
+ lastActive: derived.session && derived.session.lastEventTime != null ? derived.session.lastEventTime : null,
1248
+ // 运行中 = 事件结构信号(未闭合回合/步骤/工具调用或流式中间态),
1249
+ // 不依赖时间戳:输出间隔长(模型思考/长工具执行)时不会误判停止。
1250
+ running: liveRunning,
1251
+ })
1252
+ // 只把「新出现的回合」并入折叠集合(默认折叠);已展开的回合不被收拢。
1253
+ const newTurns = displayTl.filter((t) => !liveSeenTurns.current.has(t.turn))
1254
+ for (const t of displayTl) liveSeenTurns.current.add(t.turn)
1255
+ if (newTurns.length > 0) {
1256
+ setCollapsed((prev) => {
1257
+ const next = new Set(prev)
1258
+ for (const t of newTurns) next.add('turn-' + t.turn)
1259
+ return next
1260
+ })
1261
+ }
1262
+ } catch (e) {
1263
+ setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }))
1264
+ }
1265
+ }
1266
+ const enterLive = () => {
1267
+ setLiveOn(true)
1268
+ liveSeenTurns.current.clear() // 重进实时:全新视图,首轮全部折叠
1269
+ fetchLive.current()
1270
+ if (liveTimer.current === null) {
1271
+ liveTimer.current = setInterval(() => { fetchLive.current() }, 3000)
1272
+ }
1273
+ }
1274
+ const exitLive = () => {
1275
+ setLiveOn(false)
1276
+ liveSeenTurns.current.clear()
1277
+ setLiveState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null, running: false })
1278
+ if (liveTimer.current !== null) { clearInterval(liveTimer.current); liveTimer.current = null }
1279
+ }
1280
+ useEffect(() => () => { if (liveTimer.current !== null) clearInterval(liveTimer.current) }, [])
1281
+ // 右侧导航:标签(用户发言/工具/错误/检索)+ 工具展开态。
1282
+ const [navTab, setNavTab] = useState(props.initialSearchQuery ? 'search' : 'users')
1283
+ const [expandedTools, setExpandedTools] = useState(() => new Set())
1284
+ const errorCalls = toolStats.flatMap((e) => e.errorCalls)
1285
+ // 会话内检索(M5c 方案C):检索词(总览自动带入)+ 匹配位置列表。
1286
+ const [searchQuery, setSearchQuery] = useState(props.initialSearchQuery || '')
1287
+ const [searchMatches, setSearchMatches] = useState(null) // null = 尚未检索
1288
+ useEffect(() => {
1289
+ const q = searchQuery.trim()
1290
+ if (!q) { setSearchMatches(null); return }
1291
+ let alive = true
1292
+ api('searchIn', { sessionId: session.id, query: q }).then((json) => {
1293
+ if (alive && json && json.ok) setSearchMatches(json.matches || [])
1294
+ }).catch(() => {})
1295
+ return () => { alive = false }
1296
+ }, [searchQuery, session.id])
1297
+
1298
+ // 跳转到检索命中位置:展开回合(按需加载)→ 滚动定位 → 闪烁高亮。
1299
+ const jumpToMatch = (m) => {
1300
+ const turn = m.turn
1301
+ setActiveTurn(turn)
1302
+ setCollapsed((prev) => {
1303
+ const next = new Set(prev)
1304
+ next.delete('turn-' + turn)
1305
+ return next
1306
+ })
1307
+ ensureTurn(turn)
1308
+ setTimeout(() => {
1309
+ let el = null
1310
+ if (m.callId) el = document.getElementById('sf-tc-' + m.callId)
1311
+ else if (m.seq !== undefined) el = document.getElementById('sf-msg-' + m.seq)
1312
+ if (el) {
1313
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1314
+ el.classList.add('sf-flash')
1315
+ setTimeout(() => el.classList.remove('sf-flash'), 1600)
1316
+ }
1317
+ }, 120)
1318
+ }
1319
+
1320
+ // 定位工具/错误调用:展开所在回合(按需加载)→ 滚动到调用行;同一种再次点击循环。
1321
+ const locateCall = (list, cursorKey) => {
1322
+ if (!list || list.length === 0) return
1323
+ const idx = (toolCursor.current.get(cursorKey) || 0) % list.length
1324
+ toolCursor.current.set(cursorKey, idx + 1)
1325
+ const { callId, turn } = list[idx]
1326
+ setActiveTurn(turn)
1327
+ setCollapsed((prev) => {
1328
+ const next = new Set(prev)
1329
+ next.delete('turn-' + turn)
1330
+ return next
1331
+ })
1332
+ ensureTurn(turn)
1333
+ setTimeout(() => {
1334
+ const el = document.getElementById('sf-tc-' + callId)
1335
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1336
+ }, 80)
1337
+ }
1338
+ const jumpToTool = (name) => {
1339
+ const e = toolStats.find((x) => x.name === name)
1340
+ if (e) locateCall(e.calls, 'tool:' + name)
1341
+ }
1342
+ const jumpToError = () => {
1343
+ const errs = toolStats.flatMap((e) => e.errorCalls)
1344
+ locateCall(errs, 'error')
1345
+ }
1346
+
1347
+ // 用户发言大纲(以用户发言为导航节点,来自 light 摘要)。
1348
+ const userTurns = []
1349
+ for (const lt of lightTurns) {
1350
+ for (const u of lt.userMessages || []) userTurns.push({ turn: lt.turn, seq: u.seq, preview: u.preview })
1351
+ }
1352
+ const sessionInfo = data ? data.session : session
1353
+ const running = sessionInfo.lastEventTime !== null && sessionInfo.lastEventTime !== undefined &&
1354
+ (Date.now() - sessionInfo.lastEventTime) < ACTIVE_WINDOW_MS
1355
+ // 视图切换:时间线详情 ⇄ 血缘树。pendingSelect:工具行「查看子代理」直达选中。
1356
+ const [view, setView] = useState('timeline')
1357
+ const [pendingSelect, setPendingSelect] = useState(null)
1358
+ // M5 摘要:规则摘要前端实时组装;LLM 摘要走 host summarize(索引缓存)。
1359
+ const [llmSummary, setLlmSummary] = useState(null)
1360
+ const [llmStale, setLlmStale] = useState(false)
1361
+ const [llmBusy, setLlmBusy] = useState(false)
1362
+ const [llmErr, setLlmErr] = useState('')
1363
+ useEffect(() => {
1364
+ // get 响应里带索引中已缓存的 LLM 摘要 + 过期标记(生成后有新对话)。
1365
+ if (state.phase === 'ready' && data) {
1366
+ if (data.summary) setLlmSummary(data.summary)
1367
+ setLlmStale(data.summaryStale === true)
1368
+ }
1369
+ }, [state.phase])
1370
+ const genLlmSummary = () => {
1371
+ setLlmBusy(true)
1372
+ setLlmErr('')
1373
+ api('summarize', { sessionId: session.id, mode: 'llm' }).then((json) => {
1374
+ if (json && json.ok) { setLlmSummary(json.summary); setLlmStale(false) }
1375
+ else setLlmErr(STR.llmFail + ': ' + ((json && json.error) || 'unknown'))
1376
+ }).catch((e) => setLlmErr(STR.llmFail + ': ' + String(e))).finally(() => setLlmBusy(false))
1377
+ }
1378
+ // M5d 导出:host 生成分卷 Markdown 并打包 ZIP(base64)→ Blob 下载。
1379
+ // 超大会话拆为「概览 + 时间线分卷」,避免单文件过大导致打不开。
1380
+ const [exporting, setExporting] = useState(false)
1381
+ const exportSessionMd = () => {
1382
+ if (exporting) return
1383
+ setExporting(true)
1384
+ api('exportMd', { sessionId: session.id }).then((json) => {
1385
+ if (!json || !json.ok) { alert(STR.exportFail + ': ' + ((json && json.error) || 'unknown')); return }
1386
+ // base64 → Uint8Array → Blob(application/zip)。
1387
+ const bin = atob(json.base64)
1388
+ const bytes = new Uint8Array(bin.length)
1389
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
1390
+ const blob = new Blob([bytes], { type: 'application/zip' })
1391
+ const url = URL.createObjectURL(blob)
1392
+ const a = document.createElement('a')
1393
+ a.href = url
1394
+ a.download = json.filename || ('session-' + session.id + '.zip')
1395
+ document.body.appendChild(a)
1396
+ a.click()
1397
+ document.body.removeChild(a)
1398
+ setTimeout(() => URL.revokeObjectURL(url), 10000)
1399
+ }).catch((e) => alert(STR.exportFail + ': ' + String(e))).finally(() => setExporting(false))
1400
+ }
1401
+ // 规则摘要(零请求,从 get 响应组装):任务数 = 含用户消息的回合数;
1402
+ // 「首个任务」= 第一个回合的用户消息;「最近结论」= 最后一个有结论的回合。
1403
+ const ruleTaskCount = lightTurns.filter((lt) => lt.userMessages && lt.userMessages.length > 0).length
1404
+ const ruleGoal = (() => {
1405
+ for (const lt of lightTurns) {
1406
+ if (lt.userMessages && lt.userMessages.length > 0) return String(lt.userMessages[0].preview).slice(0, 150)
1407
+ }
1408
+ return ''
1409
+ })()
1410
+ const ruleConclusion = (() => {
1411
+ for (let i = lightTurns.length - 1; i >= 0; i--) {
1412
+ if (lightTurns[i].conclusionPreview) return String(lightTurns[i].conclusionPreview).slice(0, 220)
1413
+ }
1414
+ return ''
1415
+ })()
1416
+ const ruleTools = toolStats.slice(0, 6).map((t) => t.name + '·' + t.count).join(' ')
1417
+
1418
+ if (view === 'lineage') {
1419
+ return h(LineageView, {
1420
+ sessionId: session.id,
1421
+ connection: props.connection,
1422
+ initialSelectId: pendingSelect,
1423
+ onBack: () => setView('timeline'),
1424
+ })
1425
+ }
1426
+
1427
+ const openSubagent = (childId) => {
1428
+ setPendingSelect(childId)
1429
+ setView('lineage')
1430
+ }
1431
+
1432
+ return h('div', { className: 'sf-view' },
1433
+ h('div', { className: 'sf-viewHeader' },
1434
+ h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + STR.back),
1435
+ h('h2', { className: 'sf-viewTitle', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis' } },
1436
+ session.title || STR.unknownTitle),
1437
+ running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1438
+ session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
1439
+ h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
1440
+ // M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
1441
+ props.connection !== undefined && h('button', {
1442
+ className: 'sf-btn' + (liveOn ? ' sf-btnActive' : ''),
1443
+ onClick: liveOn ? exitLive : enterLive,
1444
+ disabled: liveOn && liveState.phase === 'loading',
1445
+ }, liveOn ? STR.liveActive + ' ✕' : STR.live),
1446
+ h('button', { className: 'sf-btn', onClick: exportSessionMd, disabled: exporting }, exporting ? STR.exporting : STR.exportMd),
1447
+ h('button', { className: 'sf-btn', onClick: jumpNative }, STR.jumpNative),
1448
+ h('button', { className: 'sf-btn', onClick: expandAll }, STR.expandAll),
1449
+ h('button', { className: 'sf-btn', onClick: collapseAll }, STR.collapseAll),
1450
+ ),
1451
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
1452
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
1453
+ state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.emptyHint),
1454
+ state.phase === 'ready' && h('div', { className: 'sf-detailBody' },
1455
+ // 左侧时间线
1456
+ h('div', { className: 'sf-timeline' },
1457
+ h('div', { className: 'sf-muted', style: { flex: 'none' }, title: data.workspaceCwd || data.workspace },
1458
+ (data.workspaceLabel || data.workspace || '') + ' · ' + fmtTime(sessionInfo.createdAt) +
1459
+ ' · ' + STR.duration + ' ' + fmtDuration((sessionInfo.lastEventTime || 0) - (sessionInfo.createdAt || 0)) +
1460
+ ' · ' + (sessionInfo.recordCount || 0) + ' ' + STR.records,
1461
+ ),
1462
+ // 详情页统计条:本会话工具分布概览,可展开;点击联动右侧导航对应标签。
1463
+ toolStats.length > 0 && h('div', { className: 'sf-statsBar', style: { flex: 'none' } },
1464
+ h('span', { className: 'sf-statsLabel' }, STR.toolTop),
1465
+ (statsExpanded ? toolStats : toolStats.slice(0, 5)).map((t) =>
1466
+ h('button', {
1467
+ key: t.name, className: 'sf-statChip clickable',
1468
+ title: t.name + ' · ' + t.count + ' 次' + (t.errors > 0 ? ' · ' + t.errors + ' 错误' : ''),
1469
+ onClick: () => { setNavTab('tools'); setExpandedTools((prev) => new Set(prev).add(t.name)) },
1470
+ },
1471
+ h('span', {}, t.name),
1472
+ h('span', { className: 'sf-statCount' + (t.errors > 0 ? ' err' : '') }, t.count),
1473
+ )),
1474
+ toolStats.length > 5 && h('button', {
1475
+ className: 'sf-statChip clickable',
1476
+ onClick: () => setStatsExpanded(!statsExpanded),
1477
+ }, statsExpanded ? STR.collapsed : STR.expanded + ' (' + toolStats.length + ')'),
1478
+ sessionInfo.toolErrors > 0 && h('button', {
1479
+ className: 'sf-statChip clickable', title: STR.issuesHint,
1480
+ onClick: () => setNavTab('errors'),
1481
+ },
1482
+ h('span', {}, '⚠ ' + STR.errors),
1483
+ h('span', { className: 'sf-statCount err' }, sessionInfo.toolErrors),
1484
+ ),
1485
+ ),
1486
+ // M5 会话摘要卡:规则摘要实时组装 + LLM 摘要(DSH 模型通道,索引缓存)。
1487
+ (ruleGoal || ruleConclusion || ruleTools) && h('div', { className: 'sf-summaryCard' },
1488
+ h('div', { className: 'sf-summaryHead' },
1489
+ h('span', { className: 'sf-summaryTitle' }, STR.summary),
1490
+ h('span', { className: 'sf-badge sf-badgeEnd' }, STR.summaryRuleTag),
1491
+ ),
1492
+ ruleTaskCount > 0 && h('div', { className: 'sf-summaryRow' },
1493
+ h('span', { className: 'sf-turnSummaryTag' }, STR.summaryTaskCount),
1494
+ h('span', { className: 'sf-turnSummaryText' }, ruleTaskCount),
1495
+ ),
1496
+ ruleGoal && h('div', { className: 'sf-summaryRow' },
1497
+ h('span', { className: 'sf-turnSummaryTag user' }, STR.summaryGoal),
1498
+ h('span', { className: 'sf-turnSummaryText' }, ruleGoal),
1499
+ ),
1500
+ ruleConclusion && h('div', { className: 'sf-summaryRow' },
1501
+ h('span', { className: 'sf-turnSummaryTag ok' }, STR.summaryConclusion + (running ? STR.summaryProvisional : '')),
1502
+ h('span', { className: 'sf-turnSummaryText' }, ruleConclusion),
1503
+ ),
1504
+ ruleTools && h('div', { className: 'sf-summaryRow' },
1505
+ h('span', { className: 'sf-turnSummaryTag' }, STR.summaryTools),
1506
+ h('span', { className: 'sf-turnSummaryText' }, ruleTools),
1507
+ ),
1508
+ llmSummary && h('div', { className: 'sf-summaryRow', style: { marginTop: 4, alignItems: 'flex-start' } },
1509
+ h('span', { className: 'sf-turnSummaryTag', style: { background: 'rgba(140,110,255,.14)', color: '#8b5cf6' } }, STR.summaryLlmTag),
1510
+ // LLM 摘要完整显示(不 clamp),支持迷你 Markdown(粗体/斜体/代码/列表/标题)。
1511
+ h('div', { className: 'sf-summaryFull' }, renderSummaryMd(llmSummary) || llmSummary),
1512
+ ),
1513
+ llmSummary && llmStale && h('div', {
1514
+ className: 'sf-summaryStale',
1515
+ onClick: genLlmSummary,
1516
+ title: STR.llmSummary,
1517
+ }, STR.summaryStale),
1518
+ llmErr && h('div', { className: 'sf-muted', style: { color: '#d43b3b' } }, llmErr),
1519
+ h('button', {
1520
+ className: 'sf-btn', style: { alignSelf: 'flex-start', marginTop: 6 },
1521
+ onClick: genLlmSummary, disabled: llmBusy,
1522
+ }, llmBusy ? STR.llmGenerating : (llmSummary ? STR.llmSummary + ' ↻' : STR.llmSummary)),
1523
+ ),
1524
+ // M6 实时模式:sessions.history → derive 的完整折叠时间线(轮询刷新)。
1525
+ liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
1526
+ h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
1527
+ h('span', { className: 'sf-liveText' },
1528
+ h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
1529
+ liveState.lastActive && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveLastActive + ' ' + fmtTime(liveState.lastActive)),
1530
+ liveState.phase === 'loading' && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveRefreshing),
1531
+ ),
1532
+ h('button', { className: 'sf-btn sf-liveExitBtn', onClick: exitLive }, STR.liveExit),
1533
+ ),
1534
+ liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
1535
+ liveOn && liveState.phase === 'loading' && liveState.timeline === null && h('div', { className: 'sf-hint' }, STR.liveLoading),
1536
+ liveOn && liveState.timeline !== null && h(TimelineTurns, {
1537
+ // 保留回合头(默认折叠可点击展开):hideHead=true 时折叠回合既无内容
1538
+ // 也无展开入口,实时时间线会整片空白。
1539
+ // liveActive:事件结构信号判定运行中(未闭合回合/步骤/工具或流式中间态),
1540
+ // 与输出节奏无关——思考/长工具执行期间保持高亮,会话结束立即熄灭。
1541
+ turns: liveState.timeline, collapsed, toggle, onOpenSubagent: openSubagent,
1542
+ liveActive: liveState.running === true,
1543
+ activeTurn, setActiveTurn,
1544
+ }),
1545
+ !liveOn && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
1546
+ !liveOn && h(TurnList, { sessionId: session.id, lightTurns, collapsed, toggle, ensureTurn, turnItems, onOpenSubagent: openSubagent, activeTurn, onSelectTurn: selectTurn }),
1547
+ ),
1548
+ // 右侧:四标签导航(用户发言 / 工具 / 错误 / 检索),点击条目定位到时间线对应位置。
1549
+ h('div', { className: 'sf-artifacts' },
1550
+ h('div', { className: 'sf-navTabs', style: { flex: 'none' } },
1551
+ h('button', { className: 'sf-navTab' + (navTab === 'users' ? ' active' : ''), title: STR.userNav, onClick: () => setNavTab('users') },
1552
+ h('span', { className: 'sf-navIcon', dangerouslySetInnerHTML: { __html: NAV_ICON_USER } }),
1553
+ h('span', { className: 'sf-navTabCount' }, userTurns.length)),
1554
+ h('button', { className: 'sf-navTab' + (navTab === 'tools' ? ' active' : ''), title: STR.tools, onClick: () => setNavTab('tools') },
1555
+ h('span', { className: 'sf-navIcon', dangerouslySetInnerHTML: { __html: NAV_ICON_TOOL } }),
1556
+ h('span', { className: 'sf-navTabCount' }, toolStats.length)),
1557
+ h('button', { className: 'sf-navTab' + (navTab === 'errors' ? ' active' : ''), title: STR.errors, onClick: () => setNavTab('errors') },
1558
+ h('span', { className: 'sf-navIcon', dangerouslySetInnerHTML: { __html: NAV_ICON_ERR } }),
1559
+ h('span', { className: 'sf-navTabCount err' }, errorCalls.length)),
1560
+ h('button', { className: 'sf-navTab' + (navTab === 'search' ? ' active' : ''), title: STR.searchTab, onClick: () => setNavTab('search') },
1561
+ h('span', { className: 'sf-navIcon', dangerouslySetInnerHTML: { __html: NAV_ICON_SEARCH } }),
1562
+ h('span', { className: 'sf-navTabCount' + (searchMatches && searchMatches.length > 0 ? ' err' : '') }, searchMatches ? searchMatches.length : 0)),
1563
+ ),
1564
+ navTab === 'search' && h('div', { className: 'sf-navBody' },
1565
+ h('input', {
1566
+ className: 'sf-input', style: { flex: 'none', width: 'calc(100% - 4px)', boxSizing: 'border-box', marginBottom: 6 },
1567
+ placeholder: STR.searchInPlaceholder, value: searchQuery,
1568
+ onChange: (e) => setSearchQuery(e.target.value),
1569
+ }),
1570
+ searchMatches !== null && h('div', { className: 'sf-hint', style: { flex: 'none' } },
1571
+ STR.matches + ' ' + searchMatches.length),
1572
+ searchMatches !== null && searchMatches.length === 0 && h('div', { className: 'sf-hint' }, STR.noMatches),
1573
+ (searchMatches || []).map((m, i) => {
1574
+ const kindLabel = m.kind === 'tool' || m.kind === 'error' ? m.name
1575
+ : m.kind === 'user' ? STR.user
1576
+ : m.kind === 'thinking' ? STR.thinking : STR.assistant
1577
+ return h('div', {
1578
+ key: (m.callId || 's' + m.seq), className: 'sf-navItem' + (activeTurn === m.turn ? ' sf-navItemSel' : ''),
1579
+ title: 'T' + m.turn + ' · ' + kindLabel,
1580
+ onClick: () => jumpToMatch(m),
1581
+ },
1582
+ h('span', { className: 'sf-navIndex', style: m.kind === 'error' ? { color: '#d43b3b' } : undefined }, '#' + (i + 1)),
1583
+ h('span', { className: 'sf-navKind ' + m.kind }, kindLabel),
1584
+ h('span', { className: 'sf-navText' }, m.preview || ''),
1585
+ h('span', { className: 'sf-navTurn' }, 'T' + m.turn),
1586
+ )
1587
+ }),
1588
+ ),
1589
+ navTab === 'users' && h('div', { className: 'sf-navBody' },
1590
+ userTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noUserNav),
1591
+ userTurns.map((u, i) =>
1592
+ h('div', {
1593
+ key: u.seq, className: 'sf-navItem' + (activeTurn === u.turn ? ' sf-navItemSel' : ''), title: STR.turns + ' ' + u.turn,
1594
+ onClick: () => scrollToMsg(u.turn, u.seq),
1595
+ },
1596
+ h('span', { className: 'sf-navIndex' }, '#' + (i + 1)),
1597
+ h('span', { className: 'sf-navText' }, u.preview),
1598
+ h('span', { className: 'sf-navTurn' }, 'T' + u.turn),
1599
+ ),
1600
+ ),
1601
+ ),
1602
+ navTab === 'tools' && h('div', { className: 'sf-navBody' },
1603
+ toolStats.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
1604
+ toolStats.map((t) => {
1605
+ const open = expandedTools.has(t.name)
1606
+ return h('div', { key: t.name, className: 'sf-navToolGroup' },
1607
+ h('div', {
1608
+ className: 'sf-navToolHead', title: t.name + ' · ' + t.count + ' 次',
1609
+ onClick: () => setExpandedTools((prev) => {
1610
+ const next = new Set(prev)
1611
+ if (next.has(t.name)) next.delete(t.name)
1612
+ else next.add(t.name)
1613
+ return next
1614
+ }),
1615
+ },
1616
+ h('span', { className: 'sf-treeCaret' }, open ? '▾' : '▸'),
1617
+ h('span', { className: 'sf-navToolName' }, t.name),
1618
+ h('span', { className: 'sf-navTurn' }, t.count + ' ' + STR.tools),
1619
+ t.errors > 0 && h('span', { className: 'sf-badge sf-badgeErr' }, t.errors),
1620
+ ),
1621
+ open && t.calls.map((c, i) =>
1622
+ h('div', {
1623
+ key: c.callId, className: 'sf-navItem' + (activeTurn === c.turn ? ' sf-navItemSel' : ''),
1624
+ title: 'T' + c.turn + ' · ' + t.name,
1625
+ onClick: () => locateCall([c], 'toolcall:' + c.callId),
1626
+ },
1627
+ h('span', { className: 'sf-navIndex' }, '#' + (i + 1)),
1628
+ h('span', { className: 'sf-navText' }, c.preview || '(无参数)'),
1629
+ h('span', { className: 'sf-navTurn' }, 'T' + c.turn),
1630
+ ),
1631
+ ),
1632
+ )
1633
+ }),
1634
+ ),
1635
+ navTab === 'errors' && h('div', { className: 'sf-navBody' },
1636
+ errorCalls.length === 0 && h('div', { className: 'sf-hint' }, STR.noDetail),
1637
+ errorCalls.map((c, i) =>
1638
+ h('div', {
1639
+ key: c.callId, className: 'sf-navItem' + (activeTurn === c.turn ? ' sf-navItemSel' : ''), style: { borderColor: 'rgba(230,80,80,.35)' },
1640
+ title: 'T' + c.turn + ' · 错误调用',
1641
+ onClick: () => locateCall([c], 'err:' + c.callId),
1642
+ },
1643
+ h('span', { className: 'sf-navIndex', style: { color: '#d43b3b' } }, '#' + (i + 1)),
1644
+ h('span', { className: 'sf-navText' }, c.preview || '(无参数)'),
1645
+ h('span', { className: 'sf-navTurn' }, 'T' + c.turn),
1646
+ ),
1647
+ ),
1648
+ ),
1649
+ ),
1650
+ ),
1651
+ )
1652
+ }
1653
+
1654
+ // ── 主视图(总览 ⇄ 详情)───────────────────────────────────────────
1655
+ function SessionFlowView(props) {
1656
+ const [detail, setDetail] = useState(null) // { session, searchQuery }
1657
+
1658
+ return detail === null
1659
+ ? h(SessionFlowOverview, {
1660
+ // M5c 方案C:总览检索词随会话带入详情页,自动执行会话内检索。
1661
+ onOpen: (session, searchQuery) => setDetail({ session, searchQuery }),
1662
+ onClose: props.onClose,
1663
+ })
1664
+ : h(SessionFlowDetail, {
1665
+ session: detail.session,
1666
+ initialSearchQuery: detail.searchQuery || '',
1667
+ sessions: props.sessions,
1668
+ connection: props.connection,
1669
+ onBack: () => setDetail(null),
1670
+ onClose: props.onClose,
1671
+ })
1672
+ }
1673
+
1674
+ // ── 侧边栏入口(DOM 级注入 + 自愈)────────────────────────────────
1675
+ const ICON = `<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.5 3.5h11M2.5 8h11M2.5 12.5h7"/></svg>`
1676
+
1677
+ function sidebarRoot() {
1678
+ const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
1679
+ if (column === null) return undefined
1680
+ const logoOwner = column.querySelector('[class*="logoRow"]')?.parentElement
1681
+ return logoOwner ?? (column.firstElementChild)
1682
+ }
1683
+
1684
+ function newSessionButton(root) {
1685
+ const nested = root.querySelector('button[class*="newSession"]')
1686
+ if (nested !== null) return nested
1687
+ for (const child of root.children) {
1688
+ if (child.tagName === 'BUTTON') return child
1689
+ }
1690
+ return undefined
1691
+ }
1692
+
1693
+ function createEntry(controller) {
1694
+ const entry = document.createElement('button')
1695
+ entry.type = 'button'
1696
+ entry.dataset.dshSessionFlowEntry = ''
1697
+ entry.className = 'sf-entry'
1698
+ entry.setAttribute('aria-label', STR.entry)
1699
+ entry.innerHTML = `<span class="sf-entryIcon">${ICON}</span><span class="sf-entryLabel">${STR.entry}</span>`
1700
+ entry.addEventListener('click', () => { controller.toggle() })
1701
+ return entry
1702
+ }
1703
+
1704
+ function placeEntry(root, entry) {
1705
+ const button = newSessionButton(root)
1706
+ if (button === undefined) return false
1707
+ if (entry.parentElement !== root) {
1708
+ const row = button.closest('[class*="logoRow"]')
1709
+ const base = (row !== null && row.parentElement === root) ? row : button
1710
+ const family = Array.from(root.children).filter(
1711
+ (el) => el instanceof HTMLElement && el.matches('[data-dsh-taskboard-entry], [data-dsh-ssh-entry], [data-dsh-session-flow-entry]'),
1712
+ )
1713
+ // 固定插到家族块末尾:taskboard/ssh 各自把「自己」插到家族最前,
1714
+ // 互相抢占会让相对顺序随加载/自愈时序漂移;我们把位置钉在末尾,
1715
+ // 任何时序下都稳定排在最后(顺序:任务看板 → SSH → 会话流)。
1716
+ const last = family.length > 0 ? family[family.length - 1] : base
1717
+ root.insertBefore(entry, last.nextElementSibling)
1718
+ }
1719
+ return true
1720
+ }
1721
+
1722
+ function mountSidebarEntry(controller) {
1723
+ const entry = createEntry(controller)
1724
+ let root = undefined
1725
+ let placed = false
1726
+ let rootObserver = null
1727
+ let waitObserver = null
1728
+
1729
+ const tryPlace = () => {
1730
+ if (root !== undefined && !root.isConnected) {
1731
+ if (rootObserver) rootObserver.disconnect()
1732
+ root = undefined
1733
+ placed = false
1734
+ }
1735
+ if (placed) {
1736
+ if (document.body.contains(entry)) return
1737
+ if (rootObserver) rootObserver.disconnect()
1738
+ root = undefined
1739
+ placed = false
1740
+ }
1741
+ root = root || sidebarRoot()
1742
+ if (root === undefined) return
1743
+ placed = placeEntry(root, entry)
1744
+ if (placed && rootObserver === null) {
1745
+ rootObserver = new MutationObserver(() => {
1746
+ if (root === undefined || !root.isConnected) {
1747
+ placed = false
1748
+ tryPlace()
1749
+ return
1750
+ }
1751
+ if (!root.contains(entry)) placed = placeEntry(root, entry)
1752
+ })
1753
+ rootObserver.observe(root, { childList: true, subtree: true })
1754
+ }
1755
+ }
1756
+
1757
+ waitObserver = new MutationObserver(() => { tryPlace() })
1758
+ waitObserver.observe(document.body, { childList: true, subtree: true })
1759
+
1760
+ const syncActive = () => {
1761
+ if (controller.getSnapshot().open) entry.dataset.active = 'true'
1762
+ else delete entry.dataset.active
1763
+ }
1764
+ const unsubscribe = controller.subscribe(syncActive)
1765
+ syncActive()
1766
+ tryPlace()
1767
+
1768
+ return () => {
1769
+ if (waitObserver) waitObserver.disconnect()
1770
+ if (rootObserver) rootObserver.disconnect()
1771
+ unsubscribe()
1772
+ entry.remove()
1773
+ }
1774
+ }
1775
+
1776
+ // ── 中栏全页视图(DOM 级接管,参照 task-board board-mount)────────
1777
+ function mountBoard(controller, sessions, connection) {
1778
+ let root = undefined
1779
+ let container = undefined
1780
+ let waitObserver = null
1781
+
1782
+ const ensure = () => {
1783
+ if (container !== undefined) return
1784
+ const column = document.querySelector(CONVERSATION_SELECTOR)
1785
+ if (column === null) return
1786
+ container = document.createElement('div')
1787
+ container.dataset.dshSessionFlowView = ''
1788
+ // 注意:容器不加任何会设置 display 的 class,可见性完全由
1789
+ // [data-dsh-session-flow-view] 属性规则控制(见 STYLE 注释)。
1790
+ column.appendChild(container)
1791
+ root = createRoot(container)
1792
+ root.render(h(SessionFlowView, { sessions, connection, onClose: () => controller.close() }))
1793
+ }
1794
+
1795
+ waitObserver = new MutationObserver(() => { ensure() })
1796
+ waitObserver.observe(document.body, { childList: true, subtree: true })
1797
+
1798
+ const applyActive = () => {
1799
+ if (controller.getSnapshot().open) {
1800
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
1801
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
1802
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
1803
+ } else {
1804
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
1805
+ }
1806
+ }
1807
+ const unsubscribe = controller.subscribe(applyActive)
1808
+ applyActive()
1809
+ ensure()
1810
+
1811
+ return () => {
1812
+ waitObserver.disconnect()
1813
+ unsubscribe()
1814
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
1815
+ if (root) { root.unmount(); root = undefined }
1816
+ if (container) { container.remove(); container = undefined }
1817
+ }
1818
+ }
1819
+
1820
+ // ── 插件体 ─────────────────────────────────────────────────────────
1821
+ const inject = ['sessions', 'connection']
1822
+
1823
+ function apply(ctx) {
1824
+ try {
1825
+ // 注入样式(幂等)。
1826
+ if (!document.getElementById('dsh-session-flow-style')) {
1827
+ const style = document.createElement('style')
1828
+ style.id = 'dsh-session-flow-style'
1829
+ style.textContent = STYLE
1830
+ document.head.appendChild(style)
1831
+ }
1832
+
1833
+ const sessions = ctx.get('sessions')
1834
+ const connection = ctx.get('connection')
1835
+
1836
+ const state = { open: false, listeners: new Set() }
1837
+ const controller = {
1838
+ getSnapshot: () => ({ open: state.open }),
1839
+ subscribe: (fn) => {
1840
+ state.listeners.add(fn)
1841
+ return () => state.listeners.delete(fn)
1842
+ },
1843
+ toggle: () => {
1844
+ state.open = !state.open
1845
+ for (const fn of state.listeners) fn()
1846
+ },
1847
+ close: () => {
1848
+ if (state.open) {
1849
+ state.open = false
1850
+ for (const fn of state.listeners) fn()
1851
+ }
1852
+ },
1853
+ }
1854
+
1855
+ const onActivate = (event) => {
1856
+ if (event.detail !== PANEL_NAME && state.open) controller.close()
1857
+ }
1858
+ document.addEventListener(ACTIVATE_EVENT, onActivate)
1859
+
1860
+ // 点击侧边栏会话/工作区行 → 交还中栏给会话。
1861
+ const onClickSidebarRow = (event) => {
1862
+ if (!state.open) return
1863
+ const target = event.target
1864
+ if (target === null || !(target instanceof Element)) return
1865
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
1866
+ }
1867
+ document.addEventListener('click', onClickSidebarRow, true)
1868
+
1869
+ // 视图激活期间的滚动护栏:底层会话的自动跟随滚动(流式输出时持续触发)
1870
+ // 会把挂载在窗格内的绝对定位视图顶出视口(“top 栏逐渐上抬被半遮住”)。
1871
+ // 捕获阶段监听所有 scroll 事件,把会话流视图子树之外的任何滚动容器复位到 0
1872
+ // (会话流打开时底层内容本来就不可见,复位无副作用)。
1873
+ const onAnyScroll = (event) => {
1874
+ if (!state.open) return
1875
+ const target = event.target
1876
+ if (target === document) {
1877
+ const se = document.scrollingElement
1878
+ if (se !== null && se.scrollTop !== 0) se.scrollTop = 0
1879
+ return
1880
+ }
1881
+ if (!(target instanceof Element)) return
1882
+ const viewEl = document.querySelector(VIEW_SELECTOR)
1883
+ if (viewEl !== null && viewEl.contains(target)) return
1884
+ if (target.scrollTop !== 0) target.scrollTop = 0
1885
+ }
1886
+ document.addEventListener('scroll', onAnyScroll, true)
1887
+
1888
+ const disposers = []
1889
+ try {
1890
+ disposers.push(mountSidebarEntry(controller))
1891
+ disposers.push(mountBoard(controller, sessions, connection))
1892
+ } catch (error) {
1893
+ console.error('[dsh-session-flow] mount failed:', error)
1894
+ }
1895
+
1896
+ const teardown = () => {
1897
+ document.removeEventListener(ACTIVATE_EVENT, onActivate)
1898
+ document.removeEventListener('click', onClickSidebarRow, true)
1899
+ document.removeEventListener('scroll', onAnyScroll, true)
1900
+ for (const dispose of disposers.splice(0)) dispose()
1901
+ }
1902
+ if (typeof ctx.effect === 'function') ctx.effect(() => teardown)
1903
+ else window.addEventListener('beforeunload', teardown)
1904
+ } catch (error) {
1905
+ console.error('[dsh-session-flow] apply failed:', error)
1906
+ }
1907
+ }
1908
+
1909
+ module.exports = { inject, apply }
1910
+ // 必须返回 module.exports:client-modules 的 materialize 直接用 factory 的
1911
+ // 返回值作为插件导出(不 return 会导致加载器拿到 undefined → boot 失败)。
1912
+ return module.exports;
1913
+ }})