dsh-session-flow 0.1.0 → 1.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 CHANGED
@@ -36,6 +36,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
36
36
 
37
37
  const STR = LOCALE === 'zh' ? {
38
38
  entry: '会话流', title: '会话流', rescan: '重新扫描', scanning: '扫描中…',
39
+ pianoTitle: '轮次', pianoNew: '新建会话', pianoFav: '收藏', pianoUnfav: '取消收藏', pianoFavs: '收藏', pianoOpenInFlow: '在会话流中打开',
40
+ pianoJumpFail: '该轮次尚未加载:请在会话区向上滚动加载历史后再点击',
41
+ fullText: '全文检索', fullTextSearching: '搜索中…', fullTextPartial: '已扫描 {N}/{M} 个会话(结果未完整)', fullTextNone: '全文无命中', fullTextFail: '全文检索失败',
42
+ dock: '并入右栏', dockExit: '退出并入', dockHint: '会话流已并入右侧栏 · 随当前会话切换', dockEmpty: '打开会话后可在此查看会话流',
43
+ rename: '重命名', renamePlaceholder: '输入新标题…', renameFail: '重命名失败', origTitle: '原名',
39
44
  search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
40
45
  sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
41
46
  noMatch: '没有匹配的会话', noData: '暂无会话数据',
@@ -44,10 +49,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
44
49
  duration: '耗时', created: '创建', size: '大小', records: '记录',
45
50
  jumpFailed: '无法跳转(会话不在当前列表)', unknownTitle: '(未命名会话)',
46
51
  loadFailed: '加载失败',
47
- back: '返回', close: '关闭', jumpNative: '跳转原生会话', expandAll: '全部展开', collapseAll: '全部折叠',
52
+ back: '返回', openWorkbench: '打开完整工作台', close: '关闭', jumpNative: '跳转原生会话', expandAll: '全部展开', collapseAll: '全部折叠',
48
53
  exportMd: '导出报告 (ZIP)', exporting: '导出中…', exportFail: '导出失败',
49
54
  live: '实时', liveActive: '实时 · 进行中', liveExit: '退出实时', liveLoading: '实时加载…',
50
55
  liveFail: '实时不可用', liveUnavailable: '(实时通道不可用:连接不可用)',
56
+ liveFallbackHint: '会话尚未落盘 · 实时视图(3s 轮询)', retryArchive: '重试档案视图',
51
57
  liveEvents: '事件', liveRefreshing: '刷新…', liveLastActive: '最后活动',
52
58
  liveTurn: '运行中', liveGenerating: '正在生成…',
53
59
  detailLoading: '正在解析会话…', noTimeline: '无可展示的时间线', artifacts: '产物',
@@ -55,8 +61,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
55
61
  args: '参数', result: '结果', thinking: '思考', expanded: '展开', collapsed: '收起',
56
62
  detailFailed: '会话详情加载失败', showEmpty: '显示空会话', emptySession: '空会话',
57
63
  emptyHint: '此会话已创建但尚未开始对话', userNav: '用户发言', noUserNav: '(无用户发言)',
58
- conclusion: '结论', injected: '注入', lineage: '血缘', noLineage: '(无子代理血缘信息)',
59
- liveTree: '运行时血缘', offlineTree: '档案血缘', modeOneShot: '一次性', modeContinuable: '可续',
64
+ conclusion: '结论', injected: '注入', lineage: '血缘', noLineage: '(无子代理血缘信息)', task: '任务', liveTree: '运行时血缘', offlineTree: '档案血缘', modeOneShot: '一次性', modeContinuable: '可续',
60
65
  subagentDetail: '子代理详情', loadDetail: '加载子代理详情…', noDetail: '该子代理无可见事件',
61
66
  viewSubagent: '查看子代理', toolTop: '工具 Top', issues: '问题会话', issuesHint: '只显示有错误记录的会话',
62
67
  searchTab: '检索', searchInPlaceholder: '会话内检索…', noMatches: '无匹配位置', matches: '命中', navUsers: '用户',
@@ -67,8 +72,17 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
67
72
  summaryStale: '⚠ 摘要生成后有新对话,内容可能已不准确,建议重新生成',
68
73
  llmSummary: '生成 LLM 摘要', llmGenerating: '生成中…', llmFail: '生成失败', summaryRuleTag: '规则', summaryLlmTag: 'LLM',
69
74
  cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
75
+ healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死',
76
+ healthSilentMin: '静默 {N} 分钟', healthSilentSec: '静默 {N} 秒',
77
+ healthChipTitle: '会话健康状态(会话流)· 点击打开详情',
78
+ renameClearBlocked: '该会话标题由官方会话管理:请输入新标题(不支持清空)',
70
79
  } : {
71
80
  entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
81
+ pianoTitle: 'Turns', pianoNew: 'New session', pianoFav: 'Favorite', pianoUnfav: 'Unfavorite', pianoFavs: 'Favorites', pianoOpenInFlow: 'Open in Session Flow',
82
+ pianoJumpFail: 'Turn not loaded — scroll up in the conversation to load history, then retry',
83
+ fullText: 'Full-text', fullTextSearching: 'Searching…', fullTextPartial: 'Scanned {N}/{M} sessions (partial)', fullTextNone: 'No full-text matches', fullTextFail: 'Full-text search failed',
84
+ dock: 'Dock right', dockExit: 'Undock', dockHint: 'Session Flow docked · follows current session', dockEmpty: 'Open a session to view its flow here',
85
+ rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
72
86
  search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
73
87
  sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
74
88
  noMatch: 'No matching sessions', noData: 'No session data yet',
@@ -77,10 +91,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
77
91
  duration: 'duration', created: 'created', size: 'size', records: 'records',
78
92
  jumpFailed: 'Cannot open (session not in live list)', unknownTitle: '(untitled session)',
79
93
  loadFailed: 'Load failed',
80
- back: 'Back', close: 'Close', jumpNative: 'Open in native view', expandAll: 'Expand all', collapseAll: 'Collapse all',
94
+ back: 'Back', openWorkbench: 'Open workbench', close: 'Close', jumpNative: 'Open in native view', expandAll: 'Expand all', collapseAll: 'Collapse all',
81
95
  exportMd: 'Export report (ZIP)', exporting: 'Exporting…', exportFail: 'Export failed',
82
96
  live: 'Live', liveActive: 'Live · running', liveExit: 'Exit live', liveLoading: 'Loading live…',
83
97
  liveFail: 'Live unavailable', liveUnavailable: '(live channel unavailable: no connection)',
98
+ liveFallbackHint: 'Session not persisted yet · live view (3s poll)', retryArchive: 'Retry archive view',
84
99
  liveEvents: 'events', liveRefreshing: 'refreshing…', liveLastActive: 'last active',
85
100
  liveTurn: 'Running', liveGenerating: 'Generating…',
86
101
  detailLoading: 'Parsing session…', noTimeline: 'No timeline to show', artifacts: 'Artifacts',
@@ -88,7 +103,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
88
103
  args: 'Args', result: 'Result', thinking: 'Thinking', expanded: 'Expanded', collapsed: 'Collapsed',
89
104
  detailFailed: 'Failed to load session detail', showEmpty: 'Show empty sessions', emptySession: 'Empty',
90
105
  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)',
106
+ conclusion: 'Conclusion', injected: 'Injected', lineage: 'Lineage', noLineage: '(no subagent lineage)', task: 'Task',
92
107
  liveTree: 'Live lineage', offlineTree: 'Archive lineage', modeOneShot: 'one-shot', modeContinuable: 'continuable',
93
108
  subagentDetail: 'Subagent detail', loadDetail: 'Loading subagent detail…', noDetail: 'No visible events for this subagent',
94
109
  viewSubagent: 'View subagent', toolTop: 'Tool Top', issues: 'Problem sessions', issuesHint: 'Only sessions with errors',
@@ -100,6 +115,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
100
115
  summaryStale: '⚠ New conversation since summary was generated; content may be outdated — regenerate',
101
116
  llmSummary: 'Generate LLM summary', llmGenerating: 'Generating…', llmFail: 'Failed', summaryRuleTag: 'rule', summaryLlmTag: 'LLM',
102
117
  cacheHint: "Only this plugin's cache (auto-rebuilds); never touches DSH session archives or other data", cacheLimit: 'Timeline cache limit',
118
+ healthActive: 'Active', healthToolWait: 'Tool running', healthQuiet: 'Quiet', healthStalled: 'Likely stalled',
119
+ healthSilentMin: 'silent {N} min', healthSilentSec: 'silent {N} s',
120
+ healthChipTitle: 'Session health (Session Flow) · click to open detail',
121
+ renameClearBlocked: 'Title is managed by the official session: enter a new title (clearing unsupported)',
103
122
  }
104
123
 
105
124
  // ── 右侧导航图标(组件级共享,定义在顶层避免作用域问题)───────────
@@ -144,6 +163,19 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
144
163
  `.sf-card:hover{border-color:var(--dsw-specific-accent,#4a7dff);box-shadow:0 1px 4px rgba(0,0,0,.06)}`,
145
164
  `.sf-cardTitle{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}`,
146
165
  `.sf-cardText{color:var(--dsw-alias-label-primary,#222)}`,
166
+ // M8a 会话重命名:hover 显示 ✏️;编辑态行内输入;自定义标题时显示「原名」小字。
167
+ `.sf-renameBtn{flex:none;border:none;background:none;padding:1px 4px;margin-left:2px;border-radius:6px;cursor:pointer;color:var(--dsw-alias-label-secondary,#888);font-size:11px;line-height:16px;opacity:0;transition:opacity .12s,color .12s}`,
168
+ `.sf-card:hover .sf-renameBtn,.sf-renameBtn:focus-visible,.sf-renameBtn[data-active='true']{opacity:1}`,
169
+ `.sf-renameBtn:hover{color:var(--dsw-specific-accent,#4a7dff);background:rgba(90,140,255,.1)}`,
170
+ `.sf-renameWrap{flex:1 1 100%;min-width:0;display:inline-flex;align-items:center;gap:6px}`,
171
+ `.sf-renameInput{flex:1 1 auto;min-width:0;border:1px solid var(--dsw-specific-accent,#4a7dff);border-radius:6px;padding:3px 8px;font-size:12.5px;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#222);outline:none}`,
172
+ `.sf-renameErr{color:#d43b3b;font-size:11px;white-space:nowrap}`,
173
+ `.sf-origTitle{flex:none;font-size:11px;color:var(--dsw-alias-label-secondary,#888);font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px;margin-left:8px}`,
174
+ // 候选 B:卡片结论摘要行(最近结论主行 + 首个任务小字,单行 ellipsis,hover 悬浮全文)。
175
+ `.sf-cardConclusion,.sf-cardTask{display:flex;align-items:center;gap:6px;min-width:0;margin-top:3px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666)}`,
176
+ `.sf-cardConclusionText,.sf-cardTaskText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
177
+ `.sf-cardTag{flex:none;border-radius:5px;padding:0 5px;font-size:10px;line-height:16px;font-weight:700;background:rgba(90,140,255,.12);color:#4a7dff}`,
178
+ `.sf-viewTitle .sf-renameBtn{opacity:.55}.sf-viewTitle:hover .sf-renameBtn{opacity:1}`,
147
179
  `.sf-muted{color:var(--dsw-alias-label-secondary,#888);font-size:12px}`,
148
180
  `.sf-mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}`,
149
181
  `.sf-badge{flex:none;border-radius:6px;padding:1px 7px;font-size:11px;line-height:16px}`,
@@ -155,6 +187,29 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
155
187
  `.sf-badgeEnd{background:rgba(120,120,120,.12);color:#888}`,
156
188
  `.sf-badgeSub{background:rgba(240,150,60,.15);color:#d97706}`,
157
189
  `.sf-badgeErr{background:rgba(230,80,80,.13);color:#d43b3b}`,
190
+ // 卡死监控徽标:疑似卡死(红,呼吸动画引起注意)/ 工具执行中·静默中(黄)。
191
+ `.sf-badgeStall{background:rgba(230,80,80,.16);color:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
192
+ `.sf-badgeWait{background:rgba(220,170,40,.15);color:#a87b00}`,
193
+ // 会话页头部健康芯片(官方 header.actions 槽位,模式标识右侧)。
194
+ // 钢琴键同款语言:收拢态 = 彩色短胶囊条(语义色保留:绿/黄/红,卡死带呼吸脉冲);
195
+ // 悬浮/聚焦展开为毛玻璃文字标签(backdrop-filter + color-mix,与 pk-key 展开态同构)。
196
+ `.sf-healthChip{display:inline-flex;align-items:center;gap:6px;height:20px;padding:0 3px;margin-left:2px;border:none;border-radius:99px;background:transparent;cursor:pointer;font-family:inherit;overflow:hidden;outline:none;transition:padding .22s ease,background .18s ease,border-color .18s ease,box-shadow .18s ease}`,
197
+ `.sf-healthChip .sf-hcBar{flex:none;width:4px;height:16px;border-radius:99px;transition:width .22s ease,height .22s ease}`,
198
+ `.sf-healthChip .sf-hcLabel{font-size:11px;line-height:16px;white-space:nowrap;max-width:0;opacity:0;overflow:hidden;user-select:none;transition:max-width .24s ease,opacity .16s ease .05s}`,
199
+ // 展开态:毛玻璃底(blur 10px)+ 同色系淡 tint 背景(状态色 14% 透明混入 72% 基底色),
200
+ // 边框带 40% 状态色呼应;tint 淡雅、毛玻璃质感保留。
201
+ `.sf-healthChip:hover,.sf-healthChip:focus-visible{padding:3px 9px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
202
+ `.sf-healthChip.hc-active:hover,.sf-healthChip.hc-active:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(28,158,90,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#1c9e5a)}`,
203
+ `.sf-healthChip.hc-wait:hover,.sf-healthChip.hc-wait:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(217,165,20,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#d9a514)}`,
204
+ `.sf-healthChip.hc-stall:hover,.sf-healthChip.hc-stall:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(212,59,59,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#d43b3b)}`,
205
+ `.sf-healthChip:hover .sf-hcBar,.sf-healthChip:focus-visible .sf-hcBar{width:6px;height:6px;border-radius:99px}`,
206
+ `.sf-healthChip:hover .sf-hcLabel,.sf-healthChip:focus-visible .sf-hcLabel{max-width:200px;opacity:1}`,
207
+ `.sf-healthChip.hc-active .sf-hcBar{background:#1c9e5a}`,
208
+ `.sf-healthChip.hc-active .sf-hcLabel{color:#1c9e5a}`,
209
+ `.sf-healthChip.hc-wait .sf-hcBar{background:#d9a514}`,
210
+ `.sf-healthChip.hc-wait .sf-hcLabel{color:#a87b00}`,
211
+ `.sf-healthChip.hc-stall .sf-hcBar{background:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
212
+ `.sf-healthChip.hc-stall .sf-hcLabel{color:#d43b3b}`,
158
213
  `.sf-statsRow{display:flex;gap:12px;flex-wrap:wrap;margin-top:3px}`,
159
214
  `.sf-hint{color:var(--dsw-alias-label-secondary,#888);font-size:12px;padding:2px 2px 0}`,
160
215
  // ── 工作区筛选 tabs(独立一行,醒目)──
@@ -272,6 +327,31 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
272
327
  `.sf-cacheRow{display:flex;align-items:center;gap:8px;min-width:0}`,
273
328
  `.sf-cacheLabel{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary,#888)}`,
274
329
  `.sf-cacheValue{font-variant-numeric:tabular-nums;font-family:ui-monospace,Consolas,monospace}`,
330
+ // 方向 A:跨会话全文检索结果区(与元数据筛选结果分区展示)。
331
+ `.sf-fulltext{flex:none;border:1px solid rgba(90,140,255,.3);background:rgba(90,140,255,.05);border-radius:10px;padding:8px 10px;display:flex;flex-direction:column;gap:4px;max-height:220px;overflow:auto}`,
332
+ `.sf-fulltextHead{display:flex;align-items:center;gap:8px;flex:none}`,
333
+ `.sf-fulltextTitle{font-weight:700;font-size:12px;color:var(--dsw-alias-label-primary,#222)}`,
334
+ `.sf-fulltextHit{border-radius:8px;padding:5px 8px;cursor:pointer;display:flex;flex-direction:column;gap:2px;min-width:0}`,
335
+ `.sf-fulltextHit:hover{background:rgba(90,140,255,.1)}`,
336
+ `.sf-fulltextHitTitle{display:flex;align-items:center;gap:8px;min-width:0}`,
337
+ `.sf-fulltextName{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary,#222)}`,
338
+ `.sf-fulltextCount{flex:none;border-radius:999px;background:rgba(90,140,255,.15);color:#4a7dff;font-size:10px;line-height:15px;min-width:18px;text-align:center;padding:0 5px;font-weight:700}`,
339
+ `.sf-fulltext .sf-muted{font-size:11px;color:var(--dsw-alias-label-secondary,#666)}`,
340
+ // M12:详情并入右侧栏(details 槽位轻量视图)。
341
+ `.dk-root{box-sizing:border-box;height:100%;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px;overflow:hidden;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit)}`,
342
+ `.dk-head{flex:none;display:flex;align-items:center;gap:8px;min-width:0}`,
343
+ `.dk-title{flex:1;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
344
+ `.dk-btn{flex:none;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:7px;padding:3px 9px;font-size:11px;cursor:pointer}`,
345
+ `.dk-btn:hover{border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
346
+ `.dk-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px}`,
347
+ `.dk-summary{border:1px solid var(--dsw-alias-border-l2,#e4e6eb);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#f8f9fa);flex:none;display:flex;flex-direction:column;gap:4px}`,
348
+ `.dk-summaryRow{display:flex;gap:6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);min-width:0}`,
349
+ `.dk-summaryTag{flex:none;border-radius:5px;padding:0 5px;font-size:10px;line-height:16px;font-weight:700;background:rgba(90,140,255,.12);color:#4a7dff}`,
350
+ `.dk-summaryText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
351
+ `.dk-summaryFull{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,#222);white-space:pre-wrap;word-break:break-word;max-height:140px;overflow:auto}`,
352
+ // M12:并入期间禁用 layout grid 列宽过渡——官方会话切换会 closeDetails(宽 360→0),
353
+ // 守护重开(0→360)若带过渡动画会产生「关→开」闪烁;禁用后切换直接刷新内容(实测踩坑)。
354
+ `html[data-dsh-dock-active] [class*=frame],html[data-dsh-dock-active] [data-side=details]{transition:none!important}`,
275
355
  // ── M5 会话摘要卡 ──
276
356
  `.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
357
  `.sf-summaryHead{display:flex;align-items:center;gap:8px}`,
@@ -306,14 +386,79 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
306
386
  `.sf-navKind.assistant{background:rgba(120,120,120,.14);color:#666}`,
307
387
  `@keyframes sfFlashBg{0%{background:rgba(90,140,255,.4)}100%{background:transparent}}`,
308
388
  `.sf-flash{animation:sfFlashBg 1.5s ease}`,
389
+ // ── M11 本会话轮次导航(会话页左侧常驻悬浮条目条,DeepSeek 悬浮导航风格)──
390
+ // 只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区;
391
+ // DOM 锚点驱动(data-time-hover-root,dsh-navbar 同款,零 RPC)。
392
+ // 形态:默认收拢为一列短横条(胶囊线,极简不占空间),悬浮/聚焦展开为文字条目卡;
393
+ // 垂直居中悬浮于会话内容区中间(top 由组件算区中心 + translateY(-50%))。
394
+ // 动画体系:一切组件的产生/消失都有「来源与目标」——display 硬切全部改为
395
+ // transform/opacity/max-height 过渡(实测踩坑:display 切换无动画、视觉突兀)。
396
+ `[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1) .22s}`,
397
+ // 收拢时序:离开时宽度过渡延迟 .22s——信息面板先滑出(.24s),悬浮条主体再收拢
398
+ // (用户要求:收拢第一步先收起详情信息框);进入(hover/focus)无延迟即时展开。
399
+ `[data-dsh-piano-keys]:hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
400
+ `html[${ACTIVE_ATTR}] [data-dsh-piano-keys]{display:none!important}`,
401
+ `.pk-strip{flex:1;min-height:0;max-height:calc(60vh - 60px);overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:5px;padding:8px 4px;scrollbar-width:none;outline:none}`,
402
+ `.pk-strip::-webkit-scrollbar{display:none}`,
403
+ // 收拢态:短横条(胶囊线);overflow:hidden 使 label 文字被高度裁剪(来源=胶囊);
404
+ // ::before 延伸命中区(±4px,点击/悬浮无死区)。
405
+ `.pk-key{position:relative;flex:none;height:4px;min-height:4px;border-radius:99px;border:none;cursor:pointer;padding:0;margin:0 4px;background:var(--dsw-alias-border-l2,#d7dae0);color:var(--dsw-alias-label-primary,#222);overflow:hidden;transition:height .22s ease,padding .22s ease,margin .22s ease,border-radius .22s ease,background .14s ease,border-color .14s ease,box-shadow .18s ease;outline:none;min-width:0}`,
406
+ `.pk-key::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px}`,
407
+ `.pk-key:hover{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
408
+ `.pk-key:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);outline-offset:-1px}`,
409
+ `.pk-keyActive{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
410
+ `.pk-keyFocus{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
411
+ // 展开态(悬浮/聚焦):条目变文字卡片;label 常驻(缩略态被条目高度裁剪),
412
+ // 展开时随高度过渡露出文字(来源=胶囊内部,目标=卡片全文)。
413
+ `[data-dsh-piano-keys]:hover .pk-key,[data-dsh-piano-keys]:focus-within .pk-key{display:flex;align-items:center;gap:6px;height:auto;min-height:28px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);padding:5px 8px;margin:0;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 80%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
414
+ `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key:hover{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 96%,transparent);border-color:var(--dsw-alias-brand-primary,#4a7dff)}`,
415
+ `[data-dsh-piano-keys]:hover .pk-keyActive,[data-dsh-piano-keys]:focus-within .pk-keyActive{border-color:var(--dsw-alias-brand-primary,#4a7dff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4a7dff) 10%,var(--dsw-alias-bg-base,#fff))}`,
416
+ `.pk-label{flex:1;min-width:0;font-size:12px;line-height:18px;max-height:18px;color:var(--dsw-alias-label-primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:none;opacity:0;transition:opacity .18s ease .06s}`,
417
+ `[data-dsh-piano-keys]:hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
418
+ // 轮次序号:缩略态折叠(宽度 0 + 透明),展开态从左侧展开露出(来源=左缘)。
419
+ `.pk-turnNo{flex:none;width:0;min-width:0;overflow:hidden;opacity:0;text-align:left;font-size:9.5px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-tertiary,#999);font-family:ui-monospace,Consolas,monospace;user-select:none;white-space:nowrap;transition:width .22s ease,opacity .16s ease .04s}`,
420
+ `[data-dsh-piano-keys]:hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
421
+ // hover 条目:在【该条目右侧】展开信息面板(.pk-detail)——absolute 相对悬浮条容器,
422
+ // 坐标按条目位置换算为容器内偏移(注意:容器有 transform:translateY(-50%),fixed 子元素
423
+ // 会被 transform 祖先劫持成相对容器定位——用视口坐标会双重偏移,实测踩坑)。
424
+ `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
425
+ // 展开时序:详情框延迟 .28s 再滑入——等悬浮条展开动画完成、条目就位后,
426
+ // 位置才准确(第一次展开时条目仍在变形,立即计算会错位,实测踩坑);
427
+ // 隐藏则立即(无延迟)。
428
+ `.pk-detail{position:absolute;left:0;top:0;width:264px;max-height:calc(60vh - 40px);display:flex;flex-direction:column;gap:6px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 76%,transparent);backdrop-filter:blur(14px);-webkit-backdrop-filter:blur(14px);border:1px solid var(--dsw-alias-border-l2,#e4e6eb);border-radius:12px;box-shadow:0 8px 28px rgba(0,0,0,.18);padding:10px 12px;z-index:6;overflow:hidden;opacity:1;transform:translateX(0);transition:opacity .18s ease .28s,transform .24s cubic-bezier(.25,1.1,.4,1) .28s,visibility .24s .28s}`,
429
+ `.pk-detail.hidden{opacity:0;transform:translateX(14px);visibility:hidden;pointer-events:none;transition:opacity .18s ease,transform .24s cubic-bezier(.25,1.1,.4,1),visibility .24s}`,
430
+ `.pk-detailHead{flex:none;font-size:10.5px;font-weight:700;color:var(--dsw-alias-label-secondary,#666);font-family:ui-monospace,Consolas,monospace;letter-spacing:.2px}`,
431
+ // 面板内容随条目切换:新内容从右轻滑入(key 变化重新挂载触发 animation)。
432
+ `.pk-detailBody{min-height:0;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-primary,#222);white-space:pre-wrap;word-break:break-word;overflow-y:auto;max-height:320px;animation:pkSlideIn .22s ease}`,
433
+ `@keyframes pkSlideIn{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}`,
434
+ // 跳转状态提示条(悬浮条下方,与悬浮条等宽):文字在条宽内换行增高显示完整
435
+ // 文案(不加宽),从下方淡入/淡出(来源=悬浮条底缘);毛玻璃。
436
+ `.pk-status{position:absolute;left:0;right:0;top:calc(100% + 4px);padding:7px 10px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 82%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--dsw-alias-border-l2,#e4e6eb);font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);box-shadow:0 4px 14px rgba(0,0,0,.12);z-index:6;text-align:center;white-space:normal;word-break:break-word;opacity:1;transform:translateY(0);transition:opacity .18s ease,transform .22s ease,visibility .22s}`,
437
+ `.pk-status.hidden{opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none}`,
438
+ `.pk-status.warn{color:#d97706;border-color:rgba(217,119,6,.4)}`,
439
+ // 展开态显示悬浮条细滚动条(缩略态保持无滚动条)。
440
+ `[data-dsh-piano-keys]:hover .pk-strip,[data-dsh-piano-keys]:focus-within .pk-strip{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,#d7dae0) transparent}`,
441
+ `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar,[data-dsh-piano-keys]:focus-within .pk-strip::-webkit-scrollbar{width:6px;display:block}`,
442
+ `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,#d7dae0);border-radius:3px}`,
443
+ // 头部:从上方滑入(来源=悬浮条顶缘)——max-height/padding/opacity/transform 过渡,
444
+ // 不占缩略态布局。内容「标题 + ▲▼ 按钮组(并排右上)」。
445
+ `.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
446
+ `[data-dsh-piano-keys]:hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
447
+ `.pk-headLabel{flex:1;min-width:0;font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
448
+ `.pk-navGroup{flex:none;display:flex;gap:3px}`,
449
+ `.pk-navBtn{flex:none;width:20px;height:20px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:6px;cursor:pointer;font-size:9px;line-height:1;padding:0;transition:border-color .14s ease,color .14s ease}`,
450
+ `.pk-navBtn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
451
+ `.pk-navBtn:disabled{opacity:.35;cursor:default}`,
452
+ `@keyframes sfPianoPulse{0%,100%{opacity:.35}50%{opacity:1}}`,
309
453
  ].join('\n')
310
454
 
311
455
  // ── API ─────────────────────────────────────────────────────────────
312
- function api(method, params) {
456
+ function api(method, params, signal) {
313
457
  return fetch('/api/session-flow', {
314
458
  method: 'POST',
315
459
  headers: { 'Content-Type': 'application/json' },
316
460
  body: JSON.stringify(Object.assign({ method }, params || {})),
461
+ signal,
317
462
  }).then((r) => r.json())
318
463
  }
319
464
 
@@ -404,6 +549,148 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
404
549
  return (bytes / 1024 / 1024).toFixed(1) + ' MiB'
405
550
  }
406
551
 
552
+ // ── M8a 会话重命名:行内编辑(总览卡片 / 详情页标题复用)─────────────
553
+ // props: { initial, onSave: (title) => Promise, onCancel, placeholder }
554
+ // 保存成功(ok truthy / 无返回)由父组件退出编辑态;失败(reject / ok===false)留在编辑态显示错误。
555
+ function RenameInline(props) {
556
+ const [value, setValue] = useState(props.initial || '')
557
+ const [saving, setSaving] = useState(false)
558
+ const [err, setErr] = useState('')
559
+ const inputRef = useRef(null)
560
+ useEffect(() => {
561
+ const el = inputRef.current
562
+ if (el) { el.focus(); el.select() }
563
+ }, [])
564
+ const commit = () => {
565
+ if (saving) return
566
+ const v = value.trim()
567
+ if (v === String(props.initial || '').trim()) { props.onCancel(); return }
568
+ setSaving(true)
569
+ setErr('')
570
+ Promise.resolve(props.onSave(v)).then((ok) => {
571
+ if (ok === false) setSaving(false)
572
+ }).catch((e) => {
573
+ setSaving(false)
574
+ setErr(String(e && e.message || e))
575
+ })
576
+ }
577
+ return h('span', { className: 'sf-renameWrap' },
578
+ h('input', {
579
+ ref: inputRef,
580
+ className: 'sf-renameInput',
581
+ value,
582
+ placeholder: props.placeholder || STR.renamePlaceholder,
583
+ disabled: saving,
584
+ onInput: (e) => setValue(e.target.value),
585
+ onKeyDown: (e) => {
586
+ if (e.key === 'Enter') commit()
587
+ else if (e.key === 'Escape') { e.stopPropagation(); props.onCancel() }
588
+ },
589
+ onBlur: commit,
590
+ onClick: (e) => e.stopPropagation(),
591
+ }),
592
+ err && h('span', { className: 'sf-renameErr' }, err),
593
+ )
594
+ }
595
+
596
+ // ── 重命名对齐官方 + 卡死监控 共享辅助 ─────────────────────────────
597
+ /** 官方 rename RPC(log-backed session/title user 事件):本插件与官方侧栏同一数据源。 */
598
+ const renameViaOfficial = (conn, sessionId, title) => {
599
+ return conn.api.sessions.rename({ sessionId, title }).then((resp) => {
600
+ const res = resp && resp.result
601
+ if (res && res.ok === true && res.value && typeof res.value.title === 'string') return res.value.title
602
+ throw new Error((res && res.error && res.error.message) || STR.renameFail)
603
+ })
604
+ }
605
+ const canOfficialRename = (conn) =>
606
+ conn !== undefined && conn.api !== undefined && conn.api.sessions !== undefined && typeof conn.api.sessions.rename === 'function'
607
+ /** 静默时长文本({N} 占位)。 */
608
+ const fmtSilent = (idleMs) => {
609
+ if (idleMs === null || idleMs === undefined) return ''
610
+ const min = Math.floor(idleMs / 60000)
611
+ return min >= 1
612
+ ? STR.healthSilentMin.replace('{N}', String(min))
613
+ : STR.healthSilentSec.replace('{N}', String(Math.max(1, Math.round(idleMs / 1000))))
614
+ }
615
+ /** 健康徽标(详情实时条 / 总览卡片共用);active/ended/unknown 不渲染(已有进行中/已结束徽标)。 */
616
+ const healthBadge = (health) => {
617
+ if (!health) return null
618
+ const silent = fmtSilent(health.idleMs)
619
+ if (health.kind === 'stalled') return h('span', { className: 'sf-badge sf-badgeStall' }, '🔴 ' + STR.healthStalled + (silent ? ' · ' + silent : ''))
620
+ if (health.kind === 'tool-wait') return h('span', { className: 'sf-badge sf-badgeWait' }, '🟡 ' + STR.healthToolWait + (silent ? ' · ' + silent : ''))
621
+ if (health.kind === 'quiet') return h('span', { className: 'sf-badge sf-badgeWait' }, '🟡 ' + STR.healthQuiet + (silent ? ' · ' + silent : ''))
622
+ return null
623
+ }
624
+
625
+ // ── 会话页头部健康芯片(官方 conversation.session.header.actions 槽位)──
626
+ // 位置:官方模式标识(agent-preset label, order -10)右侧(order -9)。
627
+ // 数据:sessions.list 快照跟踪 running(官方真实状态,随 host 流即时翻转)→
628
+ // running 时 10s 轮询 tail history → host derive 健康分类(与总览探测同管线)。
629
+ // 点击:唤起会话流工作台并直达该会话详情(workbenchBridge 意图桥)。
630
+ function SessionHealthChip(props) {
631
+ const { sessionId, connection, sessions } = props
632
+ const [running, setRunning] = useState(false)
633
+ const [health, setHealth] = useState(null)
634
+ useEffect(() => {
635
+ if (!sessionId || !sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
636
+ const compute = () => {
637
+ try {
638
+ // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running)——
639
+ // 不是 { items }(那是 workspace 浏览器用的另一个视图快照,曾误用导致芯片恒 null)。
640
+ const snap = sessions.list.getSnapshot()
641
+ const it = snap && snap.byId ? snap.byId[sessionId] : undefined
642
+ setRunning(it ? it.running === true : false)
643
+ } catch (e) { setRunning(false) }
644
+ }
645
+ compute()
646
+ if (typeof sessions.list.subscribe === 'function') return sessions.list.subscribe(compute)
647
+ return undefined
648
+ }, [sessions, sessionId])
649
+ useEffect(() => {
650
+ if (!running || !sessionId || !canOfficialRename(connection)) { setHealth(null); return undefined }
651
+ let alive = true
652
+ const probe = async () => {
653
+ try {
654
+ const hr = await connection.api.sessions.history({ sessionId, maxMessages: 3 })
655
+ const val = hr && hr.result && hr.result.value
656
+ const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
657
+ if (events.length === 0) { if (alive) setHealth(null); return }
658
+ const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
659
+ if (alive && d && d.ok) setHealth(d.health || null)
660
+ } catch (e) {}
661
+ }
662
+ probe()
663
+ const iv = setInterval(probe, 10000)
664
+ return () => { alive = false; clearInterval(iv) }
665
+ }, [running, sessionId, connection])
666
+ if (!running || !health) return null
667
+ const kind = health.kind
668
+ const silent = fmtSilent(health.idleMs)
669
+ // 收拢态胶囊条颜色语义:绿=活跃 / 黄=工具执行中·静默中 / 红=疑似卡死(脉冲)。
670
+ let text = STR.healthActive, cls = 'hc-active'
671
+ if (kind === 'tool-wait') { text = STR.healthToolWait; cls = 'hc-wait' }
672
+ else if (kind === 'quiet') { text = STR.healthQuiet; cls = 'hc-wait' }
673
+ else if (kind === 'stalled') { text = STR.healthStalled; cls = 'hc-stall' }
674
+ else if (kind !== 'active') return null // ended/unknown 不显示
675
+ return h('button', {
676
+ className: 'sf-healthChip ' + cls,
677
+ title: STR.healthChipTitle,
678
+ onClick: (e) => { e.stopPropagation(); if (props.onOpen) props.onOpen() },
679
+ },
680
+ h('span', { className: 'sf-hcBar' }),
681
+ h('span', { className: 'sf-hcLabel' }, text + (silent && kind !== 'active' ? ' · ' + silent : '')),
682
+ )
683
+ }
684
+
685
+ // 工作台打开意图桥:头部芯片点击 → 唤起工作台并直达该会话详情。
686
+ // (工厂作用域:芯片渲染于官方槽位,与本插件视图分属不同 React 子树。)
687
+ const workbenchBridge = {
688
+ intent: null,
689
+ listeners: new Set(),
690
+ open(sid) { this.intent = sid; for (const fn of [...this.listeners]) fn(sid) },
691
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) },
692
+ }
693
+
407
694
  // ── 总览页 ──────────────────────────────────────────────────────────
408
695
  // props: { onOpen: (session) => void, onClose: () => void }
409
696
  function SessionFlowOverview(props) {
@@ -418,6 +705,144 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
418
705
  const [cacheInfo, setCacheInfo] = useState(null)
419
706
  const [cacheBusy, setCacheBusy] = useState(false)
420
707
  const [cacheMsg, setCacheMsg] = useState('')
708
+ // 方向 A:跨会话全文检索(防抖 500ms;AbortController 取消;结果区渲染)。
709
+ const [fullText, setFullText] = useState(null) // {phase:'searching'|'done'|'error', query, results, scanned, total, hasMore, error}
710
+ const fullTextAbort = useRef(null)
711
+ const fullTextTimer = useRef(null)
712
+ // M8a:当前编辑重命名的会话 id(null = 无编辑态)。
713
+ const [renamingId, setRenamingId] = useState(null)
714
+ // M8a:保存自定义标题 → 更新本地列表数据(userTitle 覆盖显示,不整页重刷)。
715
+ // 对齐官方(2026-08 起):非空标题且连接可用时优先走官方 session.rename RPC
716
+ // (log-backed,官方侧栏同步);空标题(清除)/官方拒绝(子代理 agent-busy 等)回退 legacy renames.json。
717
+ const saveRename = (sid, title) => {
718
+ const conn = props.connection
719
+ const applyLocal = (userTitle) => {
720
+ setState((prev) => {
721
+ if (!prev || !prev.data) return prev
722
+ return {
723
+ ...prev,
724
+ data: {
725
+ ...prev.data,
726
+ workspaces: (prev.data.workspaces || []).map((g) => ({
727
+ ...g,
728
+ sessions: (g.sessions || []).map((s) => (s.id === sid ? { ...s, userTitle } : s)),
729
+ })),
730
+ },
731
+ }
732
+ })
733
+ setRenamingId(null)
734
+ return true
735
+ }
736
+ const legacy = () => api('rename', { sessionId: sid, title }).then((json) => {
737
+ if (json && json.ok) return applyLocal(json.userTitle)
738
+ throw new Error((json && json.error) || STR.renameFail)
739
+ })
740
+ const trimmed = String(title || '').trim()
741
+ // 官方 user 标题不可经本插件清空(官方服务无删除语义):阻止并提示。
742
+ if (trimmed === '') {
743
+ const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
744
+ const target = all.find((x) => x.id === sid)
745
+ if (target && target.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
746
+ }
747
+ if (trimmed !== '' && canOfficialRename(conn)) {
748
+ return renameViaOfficial(conn, sid, trimmed)
749
+ .then((accepted) => applyLocal(accepted))
750
+ .catch(() => legacy())
751
+ }
752
+ return legacy()
753
+ }
754
+ // ── 卡死监控:总览探测 ─────────────────────────────────────────────
755
+ // 官方 sessions.list 的 running(attached agent 真实状态,比 15 分钟启发式准)+
756
+ // tail history → host derive 健康分类。30s 轮询,上限 8 个、并发 3。
757
+ const [healthMap, setHealthMap] = useState({})
758
+ const [liveRunning, setLiveRunning] = useState(null) // null=未探测;Set<sessionId>
759
+ useEffect(() => {
760
+ const conn = props.connection
761
+ if (!canOfficialRename(conn) || state.phase !== 'ready') return undefined
762
+ let alive = true
763
+ let probing = false
764
+ const probe = async () => {
765
+ if (probing) return
766
+ probing = true
767
+ try {
768
+ const resp = await conn.api.sessions.list({})
769
+ const items = (resp && resp.result && resp.result.value && resp.result.value.items) || []
770
+ const runningIds = items.filter((i) => i.running === true).map((i) => i.sessionId)
771
+ if (!alive) return
772
+ setLiveRunning(new Set(runningIds))
773
+ const next = {}
774
+ for (let i = 0; i < Math.min(runningIds.length, 8); i += 3) {
775
+ await Promise.all(runningIds.slice(i, i + 3).map(async (sid) => {
776
+ try {
777
+ const hr = await conn.api.sessions.history({ sessionId: sid, maxMessages: 3 })
778
+ const val = hr && hr.result && hr.result.value
779
+ const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
780
+ if (events.length === 0) return
781
+ const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
782
+ if (d && d.ok && d.health) next[sid] = d.health
783
+ } catch {}
784
+ }))
785
+ if (!alive) return
786
+ }
787
+ if (alive) setHealthMap(next)
788
+ } catch {} finally { probing = false }
789
+ }
790
+ probe()
791
+ const iv = setInterval(probe, 30000)
792
+ return () => { alive = false; clearInterval(iv) }
793
+ }, [props.connection, state.phase])
794
+ // 头部芯片直达:工作台打开意图 → 列表就绪后自动进入该会话详情(每个意图只消费一次)。
795
+ const intentConsumed = useRef(null)
796
+ useEffect(() => {
797
+ if (!props.intentId || intentConsumed.current === props.intentId || state.phase !== 'ready') return
798
+ const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
799
+ const target = all.find((x) => x.id === props.intentId)
800
+ if (target) { intentConsumed.current = props.intentId; props.onOpen(target) }
801
+ }, [props.intentId, state.phase])
802
+ useEffect(() => () => {
803
+ if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
804
+ if (fullTextAbort.current) fullTextAbort.current.abort()
805
+ }, [])
806
+ useEffect(() => {
807
+ const q = query.trim()
808
+ const isStructured = /^(tool|file|path|err|error):/i.test(q)
809
+ if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
810
+ if (fullTextAbort.current) { fullTextAbort.current.abort(); fullTextAbort.current = null }
811
+ // 结构化前缀走元数据筛选(已有);自由文本 ≥2 字符才触发全文检索。
812
+ if (q.length < 2 || isStructured) { setFullText(null); return }
813
+ fullTextTimer.current = setTimeout(() => {
814
+ const ctrl = new AbortController()
815
+ fullTextAbort.current = ctrl
816
+ setFullText({ phase: 'searching', query: q, results: null, scanned: 0, total: 0, hasMore: false, error: '' })
817
+ api('searchAll', { query: q, workspace: wsFilter || undefined }, ctrl.signal).then((json) => {
818
+ if (json && json.ok) {
819
+ setFullText({ phase: 'done', query: q, results: json.results || [], scanned: json.scanned || 0, total: json.total || 0, hasMore: json.hasMore === true, error: '' })
820
+ } else {
821
+ setFullText((p) => p && p.query === q ? { phase: 'error', query: q, results: null, scanned: 0, total: 0, hasMore: false, error: (json && json.error) || STR.fullTextFail } : p)
822
+ }
823
+ }).catch((e) => {
824
+ if (e && e.name === 'AbortError') return
825
+ setFullText((p) => p && p.query === q ? { phase: 'error', query: q, results: null, scanned: 0, total: 0, hasMore: false, error: String(e && e.message || e) } : p)
826
+ })
827
+ }, 500)
828
+ }, [query, wsFilter])
829
+ // 命中跳转:localStorage 桥(会话流标签页读取后执行会话内检索定位)+ 原生打开会话。
830
+ const openFullTextHit = (hit) => {
831
+ try {
832
+ // 预写目标会话的 chat store view='session-flow':会话打开时(store 按会话重建并
833
+ // rehydrate localStorage)自动激活「会话流」标签页,而非停在默认 Chat 标签。
834
+ const storeKey = 'dsh.conversation.chat.' + hit.sessionId
835
+ let st = {}
836
+ try { const raw = localStorage.getItem(storeKey); if (raw) st = JSON.parse(raw) } catch (e) {}
837
+ st.view = 'session-flow'
838
+ localStorage.setItem(storeKey, JSON.stringify(st))
839
+ localStorage.setItem(PENDING_SEARCH_KEY, JSON.stringify({ sessionId: hit.sessionId, query: query.trim() }))
840
+ } catch (e) {}
841
+ try { props.sessions.open(hit.sessionId) } catch (e) { console.warn('[dsh-session-flow] open failed', hit.sessionId, e) }
842
+ // 关闭工作台(中栏交还给会话),用户直接看到目标会话的会话流标签页——
843
+ // 否则工作台仍覆盖中栏,需手动点侧边栏才可见(实测踩坑)。
844
+ try { if (props.onClose) props.onClose() } catch (e) {}
845
+ }
421
846
  const fmtBytes = (b) => {
422
847
  if (b === null || b === undefined) return '—'
423
848
  if (b < 1024) return b + ' B'
@@ -507,7 +932,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
507
932
  }
508
933
  }
509
934
  const now = Date.now()
510
- const running = s.lastEventTime !== null && s.lastEventTime !== undefined && (now - s.lastEventTime) < ACTIVE_WINDOW_MS
935
+ // 卡死监控:官方探测过(liveRunning null)则以 attached agent 真实状态为准,
936
+ // 否则回退 15 分钟活动窗口启发式。
937
+ const running = liveRunning === null
938
+ ? s.lastEventTime !== null && s.lastEventTime !== undefined && (now - s.lastEventTime) < ACTIVE_WINDOW_MS
939
+ : liveRunning.has(s.id)
511
940
  out.push({ ...s, workspace: group.name, workspaceLabel: wsLabelOf(group), workspaceCwd: wsCwdOf(group), running, isEmpty: isSessionEmpty(s) })
512
941
  }
513
942
  }
@@ -523,7 +952,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
523
952
  const ascending = sort === 'oldest'
524
953
  out.sort((a, b) => (ascending ? key(a) - key(b) : key(b) - key(a)))
525
954
  return out
526
- }, [state.data, query, wsFilter, sort, showEmpty, showIssues])
955
+ }, [state.data, query, wsFilter, sort, showEmpty, showIssues, liveRunning])
527
956
 
528
957
  // M5b 档案统计:聚合工具出现热度与问题会话数,跟随选中的工作区(wsFilter)变化。
529
958
  const stats = useMemo(() => {
@@ -575,6 +1004,27 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
575
1004
  h('button', { className: 'sf-btn' + (cacheOpen ? ' sf-btnActive' : ''), onClick: () => setCacheOpen(!cacheOpen) }, STR.cache),
576
1005
  h('button', { className: 'sf-btn', onClick: props.onClose }, '✕ ' + STR.close),
577
1006
  ),
1007
+ // 方向 A:跨会话全文检索结果区(防抖触发;与元数据筛选结果分区展示)。
1008
+ fullText && h('div', { className: 'sf-fulltext' },
1009
+ h('div', { className: 'sf-fulltextHead' },
1010
+ h('span', { className: 'sf-fulltextTitle' }, STR.fullText + '「' + fullText.query + '」'),
1011
+ fullText.phase === 'searching' && h('span', { className: 'sf-muted' }, STR.fullTextSearching),
1012
+ fullText.phase === 'done' && fullText.hasMore && h('span', { className: 'sf-muted' },
1013
+ STR.fullTextPartial.replace('{N}', String(fullText.scanned)).replace('{M}', String(fullText.total))),
1014
+ ),
1015
+ fullText.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.fullTextFail + ': ' + fullText.error),
1016
+ fullText.phase === 'done' && fullText.results.length === 0 && h('div', { className: 'sf-hint' }, STR.fullTextNone),
1017
+ fullText.phase === 'done' && fullText.results.map((hit) =>
1018
+ h('div', { key: hit.sessionId, className: 'sf-fulltextHit', onClick: () => openFullTextHit(hit) },
1019
+ h('div', { className: 'sf-fulltextHitTitle' },
1020
+ h('span', { className: 'sf-fulltextName' }, hit.userTitle || hit.title),
1021
+ h('span', { className: 'sf-fulltextCount' }, hit.matchCount),
1022
+ ),
1023
+ h('div', { className: 'sf-muted', style: { marginTop: 2 } },
1024
+ (hit.matches[0] ? hit.matches[0].preview : '') + ' · ' + hit.workspace),
1025
+ ),
1026
+ ),
1027
+ ),
578
1028
  // 缓存管理面板:仅本插件缓存,查看体积 + 分类清理(清理后自动重建,无需重启)。
579
1029
  cacheOpen && h('div', { className: 'sf-cachePanel' },
580
1030
  h('div', { className: 'sf-muted', style: { flex: 'none' } }, STR.cacheHint),
@@ -650,12 +1100,37 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
650
1100
  h('span', { className: 'sf-cardWsFilter', dangerouslySetInnerHTML: { __html: FILTER_ICON } }),
651
1101
  ),
652
1102
  h('div', { className: 'sf-cardTitle' },
653
- h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, s.title || STR.unknownTitle),
1103
+ renamingId === s.id
1104
+ ? h(RenameInline, {
1105
+ initial: (props.liveTitles && props.liveTitles[s.id]) || s.userTitle || s.title || '',
1106
+ onSave: (t) => saveRename(s.id, t),
1107
+ onCancel: () => setRenamingId(null),
1108
+ })
1109
+ : h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
1110
+ // 显示优先级:mux 近实时官方标题 > userTitle(档案 user/遗留 overlay)> 自动标题。
1111
+ (props.liveTitles && props.liveTitles[s.id]) || s.userTitle || s.title || STR.unknownTitle,
1112
+ (s.userTitle || (props.liveTitles && props.liveTitles[s.id])) && h('span', { className: 'sf-origTitle', title: s.title || STR.unknownTitle },
1113
+ STR.origTitle + ': ' + (s.title || STR.unknownTitle)),
1114
+ h('button', {
1115
+ className: 'sf-renameBtn', title: STR.rename,
1116
+ onClick: (e) => { e.stopPropagation(); setRenamingId(s.id) },
1117
+ }, '✎'),
1118
+ ),
654
1119
  s.isEmpty && h('span', { className: 'sf-badge sf-badgeEnd' }, STR.emptySession),
655
1120
  s.running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1121
+ healthBadge(healthMap[s.id]),
656
1122
  s.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent + ' · d' + s.delegationDepth),
657
1123
  s.toolErrors > 0 && h('span', { className: 'sf-badge sf-badgeErr' }, STR.errors + ': ' + s.toolErrors),
658
1124
  ),
1125
+ // 候选 B:最近结论(主行)+ 首个任务(小字);无值不渲染;hover 悬浮全文。
1126
+ s.lastConclusion && h('div', { className: 'sf-cardConclusion', title: s.lastConclusion },
1127
+ h('span', { className: 'sf-cardTag' }, STR.conclusion),
1128
+ h('span', { className: 'sf-cardConclusionText' }, s.lastConclusion),
1129
+ ),
1130
+ s.firstTask && h('div', { className: 'sf-cardTask', title: s.firstTask },
1131
+ h('span', { className: 'sf-cardTag' }, STR.task),
1132
+ h('span', { className: 'sf-cardTaskText' }, s.firstTask),
1133
+ ),
659
1134
  h('div', { className: 'sf-muted' },
660
1135
  h('span', {}, fmtTime(s.createdAt)),
661
1136
  h('span', { style: { marginLeft: 8 } }, STR.duration + ' ' + fmtDuration(s.lastEventTime - s.createdAt)),
@@ -1007,7 +1482,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1007
1482
  const renderNode = (node, depth, isLive) => {
1008
1483
  const active = selected !== null && selected.id === node.id
1009
1484
  const hasChildren = (node.children && node.children.length > 0)
1010
- const label = node.label || node.id
1485
+ // M8a:自定义标题优先(离线节点 host 已透传 userTitle;live 节点无此字段)。
1486
+ const label = node.userTitle || node.label || node.title || node.id
1011
1487
  const meta = isLive
1012
1488
  ? ((node.mode === 'continuable' ? STR.modeContinuable : STR.modeOneShot) + ' · ' + (node.activity === 'running' ? STR.running : STR.ended))
1013
1489
  : ((node.toolCalls || 0) + ' ' + STR.tools + (node.toolErrors ? ' · ' + node.toolErrors + ' ' + STR.errors : ''))
@@ -1078,6 +1554,31 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1078
1554
  // 折叠状态:键集合,has(key) = 折叠。turn-N / tool-<callId> / msg-<seq>
1079
1555
  const [collapsed, setCollapsed] = useState(() => new Set())
1080
1556
  const collapsedInit = useRef(false)
1557
+ // M8a:标题重命名(编辑态 + 保存后的显示覆盖,优先于 session.userTitle)。
1558
+ const [renaming, setRenaming] = useState(false)
1559
+ const [titleOverride, setTitleOverride] = useState(null)
1560
+ // mux 近实时官方标题(SessionFlowView 订阅 session/projection(title) 帧传入)。
1561
+ const liveTitleOf = (id) => (props.liveTitles && props.liveTitles[id]) || null
1562
+ // 对齐官方:优先官方 session.rename RPC(log-backed,双向一致);空标题/官方拒绝回退 legacy。
1563
+ const saveRename = (title) => {
1564
+ const conn = props.connection
1565
+ const legacy = () => api('rename', { sessionId: session.id, title }).then((json) => {
1566
+ if (json && json.ok) {
1567
+ setTitleOverride(json.userTitle) // null = 清除恢复原名
1568
+ setRenaming(false)
1569
+ return true
1570
+ }
1571
+ throw new Error((json && json.error) || STR.renameFail)
1572
+ })
1573
+ const trimmed = String(title || '').trim()
1574
+ if (trimmed === '' && session.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1575
+ if (trimmed !== '' && canOfficialRename(conn)) {
1576
+ return renameViaOfficial(conn, session.id, trimmed)
1577
+ .then((accepted) => { setTitleOverride(accepted); setRenaming(false); return true })
1578
+ .catch(() => legacy())
1579
+ }
1580
+ return legacy()
1581
+ }
1081
1582
 
1082
1583
  useEffect(() => {
1083
1584
  // 空会话(新建后未对话):不请求详情,直接显示占位提示。
@@ -1225,7 +1726,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1225
1726
  return
1226
1727
  }
1227
1728
  const events = val.events.map((entry) => (entry && entry.event) || entry)
1228
- const derived = await api('derive', { events })
1729
+ const derived = await api('derive', { events, now: Date.now() })
1229
1730
  if (!derived || !derived.ok) {
1230
1731
  setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
1231
1732
  return
@@ -1248,6 +1749,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1248
1749
  // 运行中 = 事件结构信号(未闭合回合/步骤/工具调用或流式中间态),
1249
1750
  // 不依赖时间戳:输出间隔长(模型思考/长工具执行)时不会误判停止。
1250
1751
  running: liveRunning,
1752
+ // 卡死监控:host 分类(active/tool-wait/quiet/stalled/ended)。
1753
+ health: derived.health || null,
1251
1754
  })
1252
1755
  // 只把「新出现的回合」并入折叠集合(默认折叠);已展开的回合不被收拢。
1253
1756
  const newTurns = displayTl.filter((t) => !liveSeenTurns.current.has(t.turn))
@@ -1274,7 +1777,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1274
1777
  const exitLive = () => {
1275
1778
  setLiveOn(false)
1276
1779
  liveSeenTurns.current.clear()
1277
- setLiveState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null, running: false })
1780
+ setLiveState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null, running: false, health: null })
1278
1781
  if (liveTimer.current !== null) { clearInterval(liveTimer.current); liveTimer.current = null }
1279
1782
  }
1280
1783
  useEffect(() => () => { if (liveTimer.current !== null) clearInterval(liveTimer.current) }, [])
@@ -1317,6 +1820,61 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1317
1820
  }, 120)
1318
1821
  }
1319
1822
 
1823
+ // ── M7 遗留 L1:跨视图 inspect 握手 ──────────────────────────────
1824
+ // chat 视图工具调用行「Inspect」按钮写入共享 store({callId}),会话流标签
1825
+ // 作为 conversation.view 占用者收到 owner props:inspect/onInspectDone。
1826
+ // 消费:toolStats 里定位该 callId 所属回合 → 展开回合(按需加载)→ 滚动定位
1827
+ // → 闪烁高亮 → onInspectDone() 清除(参照 ui-trajectory 的 inspect 消费方式)。
1828
+ // 时序处理:inspect 可能在数据未就绪时到达(依赖 phase 重跑);liveOn/血缘视图
1829
+ // 时先退出再定位;同一 callId 只处理一次(inspectHandled ref,store 清除后重置)。
1830
+ const inspectHandled = useRef(null)
1831
+ const jumpToCall = (turn, callId) => {
1832
+ setActiveTurn(turn)
1833
+ setCollapsed((prev) => {
1834
+ const next = new Set(prev)
1835
+ next.delete('turn-' + turn)
1836
+ return next
1837
+ })
1838
+ ensureTurn(turn)
1839
+ setTimeout(() => {
1840
+ const el = document.getElementById('sf-tc-' + callId)
1841
+ if (el) {
1842
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1843
+ el.classList.add('sf-flash')
1844
+ setTimeout(() => el.classList.remove('sf-flash'), 1600)
1845
+ }
1846
+ }, 120)
1847
+ }
1848
+ const applyInspect = (callId) => {
1849
+ if (!callId) return
1850
+ let turn = null
1851
+ for (const t of toolStats) {
1852
+ const c = (t.calls || []).find((x) => x.callId === callId)
1853
+ if (c) { turn = c.turn; break }
1854
+ }
1855
+ if (turn === null) {
1856
+ // toolStats 未命中(理论上不应发生):searchIn 按 callId 全文兜底。
1857
+ api('searchIn', { sessionId: session.id, query: callId }).then((json) => {
1858
+ const m = json && json.ok ? (json.matches || []).find((x) => x.callId === callId) : null
1859
+ if (m && typeof m.turn === 'number') jumpToCall(m.turn, callId)
1860
+ if (props.onInspectDone) props.onInspectDone()
1861
+ }).catch(() => { if (props.onInspectDone) props.onInspectDone() })
1862
+ return
1863
+ }
1864
+ jumpToCall(turn, callId)
1865
+ if (props.onInspectDone) props.onInspectDone()
1866
+ }
1867
+ useEffect(() => {
1868
+ // store 清除(inspect → null)后重置防重标记,允许同一 callId 再次检视。
1869
+ if (!props.inspect || !props.inspect.callId) { inspectHandled.current = null; return }
1870
+ if (state.phase !== 'ready') return
1871
+ if (inspectHandled.current === props.inspect.callId) return
1872
+ inspectHandled.current = props.inspect.callId
1873
+ if (liveOn) exitLive()
1874
+ if (view === 'lineage') setView('timeline')
1875
+ applyInspect(props.inspect.callId)
1876
+ }, [props.inspect, state.phase])
1877
+
1320
1878
  // 定位工具/错误调用:展开所在回合(按需加载)→ 滚动到调用行;同一种再次点击循环。
1321
1879
  const locateCall = (list, cursorKey) => {
1322
1880
  if (!list || list.length === 0) return
@@ -1431,12 +1989,32 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1431
1989
 
1432
1990
  return h('div', { className: 'sf-view' },
1433
1991
  h('div', { className: 'sf-viewHeader' },
1434
- h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + STR.back),
1992
+ h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + (props.backLabel || STR.back)),
1435
1993
  h('h2', { className: 'sf-viewTitle', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis' } },
1436
- session.title || STR.unknownTitle),
1994
+ renaming
1995
+ ? h(RenameInline, {
1996
+ initial: titleOverride || liveTitleOf(session.id) || session.userTitle || session.title || '',
1997
+ onSave: saveRename,
1998
+ onCancel: () => setRenaming(false),
1999
+ })
2000
+ : h('span', { style: { display: 'inline-flex', alignItems: 'center', minWidth: 0 } },
2001
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: (titleOverride || liveTitleOf(session.id) || session.userTitle) ? session.title || STR.unknownTitle : undefined },
2002
+ titleOverride || liveTitleOf(session.id) || session.userTitle || session.title || STR.unknownTitle),
2003
+ h('button', {
2004
+ className: 'sf-renameBtn', title: STR.rename,
2005
+ onClick: () => setRenaming(true),
2006
+ }, '✎'),
2007
+ ),
2008
+ ),
1437
2009
  running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1438
2010
  session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
1439
2011
  h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
2012
+ // M12:并入右栏(仅工作台详情页;嵌入标签页不显示——避免右栏与标签页重复)。
2013
+ !props.embedded && h('button', {
2014
+ className: 'sf-btn',
2015
+ onClick: () => { if (dockBridge.controller) dockBridge.controller.open() },
2016
+ title: STR.dockHint,
2017
+ }, STR.dock),
1440
2018
  // M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
1441
2019
  props.connection !== undefined && h('button', {
1442
2020
  className: 'sf-btn' + (liveOn ? ' sf-btnActive' : ''),
@@ -1444,7 +2022,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1444
2022
  disabled: liveOn && liveState.phase === 'loading',
1445
2023
  }, liveOn ? STR.liveActive + ' ✕' : STR.live),
1446
2024
  h('button', { className: 'sf-btn', onClick: exportSessionMd, disabled: exporting }, exporting ? STR.exporting : STR.exportMd),
1447
- h('button', { className: 'sf-btn', onClick: jumpNative }, STR.jumpNative),
2025
+ // 嵌入原生会话页模式:无「跳转原生会话」(本页即原生会话页)。
2026
+ !props.embedded && h('button', { className: 'sf-btn', onClick: jumpNative }, STR.jumpNative),
1448
2027
  h('button', { className: 'sf-btn', onClick: expandAll }, STR.expandAll),
1449
2028
  h('button', { className: 'sf-btn', onClick: collapseAll }, STR.collapseAll),
1450
2029
  ),
@@ -1524,6 +2103,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1524
2103
  // M6 实时模式:sessions.history → derive 的完整折叠时间线(轮询刷新)。
1525
2104
  liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
1526
2105
  h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
2106
+ healthBadge(liveState.health),
1527
2107
  h('span', { className: 'sf-liveText' },
1528
2108
  h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
1529
2109
  liveState.lastActive && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveLastActive + ' ' + fmtTime(liveState.lastActive)),
@@ -1651,29 +2231,758 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1651
2231
  )
1652
2232
  }
1653
2233
 
2234
+ // ── 二期:嵌入原生会话页(conversation.view 槽位标签页)────────────
2235
+ // 槽位环为会话作用域:每个原生会话页内一个「会话流」标签页,框架按
2236
+ // key={sessionId} 重挂载,天然跟随当前会话切换。组件收到的框架标准
2237
+ // props:sessionId + useSession(会话快照);数据源与一期详情页一致
2238
+ // (host get/getTurn/searchIn/summarize/exportMd/lineage + 实时通道)。
2239
+ // 先取 host get 的轻量元信息(标题/计数,用于详情页空会话判定),
2240
+ // 再复用 SessionFlowDetail 完整详情视图(摘要/统计/折叠时间线/四标签导航)。
2241
+ // 嵌入模式差异:返回按钮 →「打开完整工作台」(唤起侧边栏工作台);
2242
+ // 隐藏「跳转原生会话」(本页即原生会话页);onClose 无操作。
2243
+ function SessionFlowTab(props) {
2244
+ const { sessionId } = props
2245
+ const [meta, setMeta] = useState(null) // {id, title, userMessages, toolCalls, turns}
2246
+ const [loadErr, setLoadErr] = useState('')
2247
+ // M7 遗留 L2:档案未命中(会话尚未落盘)时进入实时兜底视图,而非纯报错。
2248
+ const [liveFallback, setLiveFallback] = useState(false)
2249
+ const [retryKey, setRetryKey] = useState(0) // 「重试档案视图」触发重新 get
2250
+ // 方向 A:全文检索命中跳转——读取 pendingSearch 桥(总览命中点击写入),
2251
+ // 匹配当前会话则把检索词带入详情页(initialSearchQuery → 自动执行会话内 searchIn)。
2252
+ const [pendingQuery, setPendingQuery] = useState('')
2253
+ useEffect(() => {
2254
+ if (!sessionId) return
2255
+ try {
2256
+ const raw = localStorage.getItem(PENDING_SEARCH_KEY)
2257
+ if (!raw) return
2258
+ const p = JSON.parse(raw)
2259
+ if (p && p.sessionId === sessionId && p.query) {
2260
+ localStorage.removeItem(PENDING_SEARCH_KEY)
2261
+ setPendingQuery(p.query)
2262
+ }
2263
+ } catch (e) {}
2264
+ }, [sessionId])
2265
+
2266
+ useEffect(() => {
2267
+ let alive = true
2268
+ setMeta(null)
2269
+ setLoadErr('')
2270
+ setLiveFallback(false)
2271
+ if (!sessionId) return undefined
2272
+ const conn = props.connection
2273
+ const canLive = conn !== undefined && conn.api !== undefined && conn.api.sessions !== undefined
2274
+ const toFallback = () => { if (alive && canLive) setLiveFallback(true) }
2275
+ api('get', { sessionId }).then((json) => {
2276
+ if (!alive) return
2277
+ if (json && json.ok) {
2278
+ const counts = json.counts || {}
2279
+ const t = json.title
2280
+ setMeta({
2281
+ id: sessionId,
2282
+ // entry.parsed.title 是 {title, source} 对象(已知陷阱),取 .title。
2283
+ title: t && typeof t === 'object' ? String(t.title || '') : String(t || ''),
2284
+ // M8a:自定义标题覆盖显示(userTitle || title)。
2285
+ userTitle: json.userTitle || null,
2286
+ userMessages: counts['user/message'] || 0,
2287
+ toolCalls: counts['tool/call'] || 0,
2288
+ turns: counts['turn/start'] || 0,
2289
+ })
2290
+ } else {
2291
+ if (canLive) setLiveFallback(true)
2292
+ else setLoadErr((json && json.error) || STR.detailFailed)
2293
+ }
2294
+ }).catch((e) => {
2295
+ if (!alive) return
2296
+ if (canLive) setLiveFallback(true)
2297
+ else setLoadErr(String(e && e.message || e))
2298
+ })
2299
+ return () => { alive = false }
2300
+ }, [sessionId, retryKey])
2301
+
2302
+ if (liveFallback) {
2303
+ return h(SessionFlowLiveFallback, {
2304
+ sessionId,
2305
+ connection: props.connection,
2306
+ onRetry: () => setRetryKey((k) => k + 1),
2307
+ })
2308
+ }
2309
+ if (loadErr) {
2310
+ return h('div', { className: 'sf-view' },
2311
+ h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + loadErr))
2312
+ }
2313
+ if (meta === null) {
2314
+ return h('div', { className: 'sf-view' }, h('div', { className: 'sf-hint' }, STR.detailLoading))
2315
+ }
2316
+ return h(SessionFlowDetail, {
2317
+ session: meta,
2318
+ sessions: props.sessions,
2319
+ connection: props.connection,
2320
+ onBack: props.onOpenWorkbench,
2321
+ backLabel: STR.openWorkbench,
2322
+ embedded: true,
2323
+ onClose: () => {},
2324
+ initialSearchQuery: pendingQuery,
2325
+ // M7 遗留 L1:透传跨视图 inspect 握手(chat Inspect 按钮 → 会话流定位)。
2326
+ inspect: props.inspect,
2327
+ onInspectDone: props.onInspectDone,
2328
+ })
2329
+ }
2330
+
2331
+ // ── M7 遗留 L2:会话未落盘时的实时兜底视图 ────────────────────────
2332
+ // 档案通道未命中(host get 404)时,用实时通道(sessions.history → derive)
2333
+ // 渲染折叠时间线 + 3s 轮询,并保留「重试档案视图」入口。逻辑与 SessionFlowDetail
2334
+ // 内实时通道同构(结构信号运行判定 / 只折叠新回合 / 不传 hideHead),自包含实现。
2335
+ function SessionFlowLiveFallback(props) {
2336
+ const { sessionId, connection } = props
2337
+ const [state, setState] = useState({ phase: 'loading', error: null, timeline: null, eventCount: 0, running: false, health: null })
2338
+ const [collapsed, setCollapsed] = useState(() => new Set())
2339
+ const [activeTurn, setActiveTurn] = useState(null)
2340
+ const liveSeenTurns = useRef(new Set())
2341
+ const timer = useRef(null)
2342
+
2343
+ const fetchLive = useRef(async () => {})
2344
+ fetchLive.current = async () => {
2345
+ if (connection === undefined || connection.api === undefined || connection.api.sessions === undefined) {
2346
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
2347
+ return
2348
+ }
2349
+ setState((s) => ({ ...s, phase: s.timeline === null ? 'loading' : 'ready' }))
2350
+ try {
2351
+ const resp = await connection.api.sessions.history({ sessionId, mode: 'full', maxMessages: 400 })
2352
+ const val = resp && resp.result && resp.result.value
2353
+ if (val === undefined || !Array.isArray(val.events)) {
2354
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2355
+ return
2356
+ }
2357
+ const events = val.events.map((entry) => (entry && entry.event) || entry)
2358
+ const derived = await api('derive', { events, now: Date.now() })
2359
+ if (!derived || !derived.ok) {
2360
+ setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
2361
+ return
2362
+ }
2363
+ const tl = (derived.timeline && derived.timeline.turns) || []
2364
+ setState({
2365
+ phase: 'ready', error: null, timeline: tl, eventCount: events.length,
2366
+ running: derived.running === true,
2367
+ health: derived.health || null,
2368
+ })
2369
+ // 只折叠新出现的回合(默认折叠),已展开的保持用户状态(M6 经验)。
2370
+ const newTurns = tl.filter((t) => !liveSeenTurns.current.has(t.turn))
2371
+ for (const t of tl) liveSeenTurns.current.add(t.turn)
2372
+ if (newTurns.length > 0) {
2373
+ setCollapsed((prev) => {
2374
+ const next = new Set(prev)
2375
+ for (const t of newTurns) next.add('turn-' + t.turn)
2376
+ return next
2377
+ })
2378
+ }
2379
+ } catch (e) {
2380
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }))
2381
+ }
2382
+ }
2383
+
2384
+ useEffect(() => {
2385
+ liveSeenTurns.current.clear()
2386
+ fetchLive.current()
2387
+ if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, 3000)
2388
+ return () => { if (timer.current !== null) { clearInterval(timer.current); timer.current = null } }
2389
+ }, [sessionId])
2390
+
2391
+ const toggle = (key) => {
2392
+ setCollapsed((prev) => {
2393
+ const next = new Set(prev)
2394
+ if (next.has(key)) next.delete(key)
2395
+ else next.add(key)
2396
+ return next
2397
+ })
2398
+ }
2399
+
2400
+ return h('div', { className: 'sf-view' },
2401
+ h('div', { className: 'sf-viewHeader' },
2402
+ h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
2403
+ healthBadge(state.health),
2404
+ h('span', { className: 'sf-liveText' }, STR.liveFallbackHint),
2405
+ h('button', { className: 'sf-btn sf-liveExitBtn', onClick: props.onRetry }, STR.retryArchive),
2406
+ ),
2407
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.liveLoading),
2408
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, state.error),
2409
+ state.phase === 'ready' && state.timeline !== null && state.timeline.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2410
+ state.phase === 'ready' && state.timeline !== null && state.timeline.length > 0 && h(TimelineTurns, {
2411
+ // 不传 hideHead:折叠回合需保留展开入口(M6 经验)。
2412
+ turns: state.timeline, collapsed, toggle,
2413
+ liveActive: state.running === true,
2414
+ activeTurn, setActiveTurn,
2415
+ }),
2416
+ )
2417
+ }
2418
+
1654
2419
  // ── 主视图(总览 ⇄ 详情)───────────────────────────────────────────
1655
2420
  function SessionFlowView(props) {
1656
2421
  const [detail, setDetail] = useState(null) // { session, searchQuery }
2422
+ // 头部芯片直达意图(workbenchBridge):收到会话 id → 总览数据就绪后自动进详情。
2423
+ const [intentId, setIntentId] = useState(workbenchBridge.intent)
2424
+ useEffect(() => workbenchBridge.subscribe((sid) => { setDetail(null); setIntentId(sid) }), [])
2425
+ // ── 官方标题近实时同步(rename 对齐增强)──────────────────────────
2426
+ // 自建 mux 下行流监听 session/projection(title) 帧:官方 rename / 自动标题生成后
2427
+ // host 向每个 mux 消费者即时广播(value 为标题字符串),秒级同步、免等重扫。
2428
+ const [liveTitles, setLiveTitles] = useState({}) // sessionId -> 最新官方标题
2429
+ useEffect(() => {
2430
+ const conn = props.connection
2431
+ if (conn === undefined || conn.api === undefined || conn.api.events === undefined || typeof conn.api.events.mux !== 'function') return undefined
2432
+ const ac = new AbortController()
2433
+ ;(async () => {
2434
+ try {
2435
+ for await (const envelope of conn.api.events.mux({}, ac.signal)) {
2436
+ const f = (envelope && envelope.payload) || envelope
2437
+ if (!f || f.type !== 'session/projection' || f.key !== 'title') continue
2438
+ const title = typeof f.value === 'string' ? f.value : (f.value && typeof f.value.title === 'string' ? f.value.title : '')
2439
+ if (f.sessionId && title !== '') {
2440
+ const sid = f.sessionId
2441
+ setLiveTitles((prev) => (prev[sid] === title ? prev : { ...prev, [sid]: title }))
2442
+ }
2443
+ }
2444
+ } catch (e) { /* 流断开/中止:静默(组件卸载或下次挂载时重建) */ }
2445
+ })()
2446
+ return () => ac.abort()
2447
+ }, [props.connection])
1657
2448
 
1658
2449
  return detail === null
1659
2450
  ? h(SessionFlowOverview, {
1660
2451
  // M5c 方案C:总览检索词随会话带入详情页,自动执行会话内检索。
1661
2452
  onOpen: (session, searchQuery) => setDetail({ session, searchQuery }),
1662
2453
  onClose: props.onClose,
2454
+ sessions: props.sessions,
2455
+ connection: props.connection,
2456
+ liveTitles,
2457
+ intentId,
1663
2458
  })
1664
2459
  : h(SessionFlowDetail, {
1665
2460
  session: detail.session,
1666
2461
  initialSearchQuery: detail.searchQuery || '',
1667
2462
  sessions: props.sessions,
1668
2463
  connection: props.connection,
2464
+ liveTitles,
1669
2465
  onBack: () => setDetail(null),
1670
2466
  onClose: props.onClose,
1671
2467
  })
1672
2468
  }
1673
2469
 
2470
+ // ── M11:钢琴键会话快切(会话页左侧常驻竖条)─────────────────────
2471
+ // 数据:ctx.sessions.list(ObservableSnapshot 订阅)→ 过滤(排除 subagent、隐藏 blank)
2472
+ // → 排序(updatedAt 降序,收藏置顶)→ 分组(连续 cwd 同段,段间黑白交替)。
2473
+ // 点击 sessions.open(id) 切换;会话切换由框架重挂载会话组件(M7 已验证)。
2474
+ // 错误角标:挂载时拉一次 host list 索引(toolErrors 映射)。
2475
+ // 预览卡:hover 200ms 防抖 → host get 单个会话(缓存秒回)→ fixed 浮层(不裁剪)。
2476
+ // 交互:滚轮/箭头滚动窗口(默认 15 键,兼容大量会话)、键盘 ↑↓/Enter、
2477
+ // 收藏星标(localStorage)、右键「在会话流中打开」(工作台唤起)。
2478
+ const PIANO_FAVS_KEY = 'dsh.sessionFlow.pianoFavs' // 预留(收藏键 v2 迁移用;当前轮次导航不使用)
2479
+ const PENDING_SEARCH_KEY = 'dsh.sessionFlow.pendingSearch' // 方向 A:全文命中跳转桥
2480
+ // M12:并入右栏状态桥——挂 window 全局共享(跨 factory 实例/热重载稳定;
2481
+ // 曾用模块闭包 const dockState,热重载后新 apply 闭包访问不到 → ReferenceError 踩坑)。
2482
+ const dockBridge = (typeof window !== 'undefined' && window.__dshSessionFlowDock__) ||
2483
+ (typeof window !== 'undefined'
2484
+ ? (window.__dshSessionFlowDock__ = { open: false, listeners: new Set(), controller: null })
2485
+ : { open: false, listeners: new Set(), controller: null })
2486
+ const PIANO_WINDOW = 15
2487
+
2488
+ // 定位目标:会话内容区。注意必须选**滚动容器** scrollBody(data-conversation-scroll):
2489
+ // viewArea 在滚动场景下 rect 是「内容全尺寸」(如 y=-25687, h=26823),用它定位会把
2490
+ // 竖条甩到视口外(实测踩坑)。scrollBody 才有正确的视口内矩形。
2491
+ // 优先级:scrollBody(滚动容器)→ viewArea(仅无 scrollBody 时兜底)。
2492
+ function pianoViewArea() {
2493
+ const col = document.querySelector(CONVERSATION_SELECTOR)
2494
+ if (col === null) return undefined
2495
+ const body = col.querySelector('[data-conversation-scroll]')
2496
+ if (body instanceof Element) return body
2497
+ const area = col.querySelector('[class*=viewArea]')
2498
+ if (area instanceof Element) return area
2499
+ return undefined
2500
+ }
2501
+
2502
+ // ── M11:本会话轮次导航(会话页左侧常驻悬浮条目条,v3)──────────
2503
+ // 范围:只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区。
2504
+ // 数据:官方消息行锚点 DOM 驱动(data-time-hover-root,dsh-navbar 同款机制,
2505
+ // 零 RPC 实时):用户行 = 无 data-turn-tail + 含 [class*=bubble]。
2506
+ // 轮次 → 结论预览:按顺序索引映射 host get 的 lightTurns(用户消息摊平)。
2507
+ // 交互:点击滚动定位该轮、active 跟随视口第一条、窗口裁剪 + 滚轮/▲▼、
2508
+ // 键盘 ↑↓/Enter、hover 预览卡(该轮用户消息 + 结论)、右键唤起会话流工作台。
2509
+ // 样式:DeepSeek 网页版右侧悬浮消息导航风格(圆角卡片 + 单行截断文字),移到左侧。
2510
+ function TurnKeys(props) {
2511
+ const { sessions, onOpenWorkbench } = props
2512
+ const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
2513
+ const [activeIdx, setActiveIdx] = useState(-1)
2514
+ const [winOffset, setWinOffset] = useState(0)
2515
+ const [kbGlobal, setKbGlobal] = useState(null)
2516
+ const [detailIdx, setDetailIdx] = useState(null) // 右侧信息面板显示的条目索引
2517
+ const detailTimer = useRef(null)
2518
+ const rootRef = useRef(null) // 悬浮条根容器(面板定位换算基准)
2519
+ const [jumpFail, setJumpFail] = useState(false) // 未加载轮次提示
2520
+ const [, force] = useState(0)
2521
+
2522
+ // 定位自管理:垂直居中悬浮于会话内容区(scrollBody 矩形中心 + translateY(-50%))。
2523
+ const [pos, setPos] = useState(null) // {left, top(区中心)}
2524
+ useEffect(() => {
2525
+ const compute = () => {
2526
+ const area = pianoViewArea()
2527
+ if (area === undefined) { setPos(null); return }
2528
+ const r = area.getBoundingClientRect()
2529
+ if (r.width === 0 && r.height === 0) return
2530
+ const seat = area.querySelector('[data-composer-seat]')
2531
+ const seatH = seat ? seat.getBoundingClientRect().height : 0
2532
+ setPos({ left: r.left, top: r.top + Math.max(0, r.height - seatH) / 2 })
2533
+ }
2534
+ compute()
2535
+ window.addEventListener('resize', compute)
2536
+ const iv = setInterval(compute, 2000)
2537
+ return () => { window.removeEventListener('resize', compute); clearInterval(iv) }
2538
+ }, [])
2539
+
2540
+ // 订阅会话列表变化:切换会话(current 变化)→ 重渲染刷新条目。
2541
+ useEffect(() => {
2542
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
2543
+ return sessions.list.subscribe(() => force((n) => n + 1))
2544
+ }
2545
+ return undefined
2546
+ }, [sessions])
2547
+
2548
+ // 默认定位到【最新一轮】:首次拿到条目后窗口初始 offset 指向末尾
2549
+ // (此前默认从最旧开始,看最新轮次需翻页——已修正)。之后用户手动
2550
+ // 翻页/新消息到达都不再重置(不打断用户当前窗口位置)。
2551
+ const initTailDone = useRef(false)
2552
+ useEffect(() => {
2553
+ if (initTailDone.current || entries.length <= WINDOW) return
2554
+ initTailDone.current = true
2555
+ setWinOffset(entries.length - WINDOW)
2556
+ }, [entries])
2557
+
2558
+ // 当前会话 id(仅取 current,不展示跨会话列表)。
2559
+ const currentId = (() => {
2560
+ try {
2561
+ const s = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list.getSnapshot() : null
2562
+ return s && s.current ? s.current : null
2563
+ } catch (e) { return null }
2564
+ })()
2565
+
2566
+ // 条目数据:host get lightTurns 全量用户轮次(含往期,不受 DOM 虚拟列表窗口限制)。
2567
+ // 轮询降载(PERF-ANALYSIS §4.1):大会话 get 每轮 ~383ms(缓存 JSON.parse)——
2568
+ // 观察消息流 DOM 变化(新消息/流式渲染)→ 立即刷新并切高频(3s);
2569
+ // 空闲时低频兜底(15s),避免常驻高频轮询空转。
2570
+ useEffect(() => {
2571
+ if (!currentId) { setEntries([]); return undefined }
2572
+ let alive = true
2573
+ // 切换会话后重置「已定位」标记,下次加载重新定位到新会话末尾。
2574
+ initTailDone.current = false
2575
+ const refresh = () => {
2576
+ api('get', { sessionId: currentId }).then((json) => {
2577
+ if (!alive || !json || !json.ok) return
2578
+ const flat = []
2579
+ for (const lt of json.lightTurns || []) {
2580
+ for (const u of lt.userMessages || []) {
2581
+ flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview })
2582
+ }
2583
+ }
2584
+ // 防御:同一 seq 只保留一条(避免同一条消息重复渲染 = 「最后一条显示两次」)。
2585
+ const seen = new Set()
2586
+ const dedup = flat.filter((e) => {
2587
+ if (seen.has(e.seq)) return false
2588
+ seen.add(e.seq)
2589
+ return true
2590
+ })
2591
+ setEntries((prev) => {
2592
+ if (prev.length === dedup.length && prev.length > 0 && prev[prev.length - 1].seq === dedup[dedup.length - 1].seq) return prev
2593
+ return dedup
2594
+ })
2595
+ }).catch(() => {})
2596
+ }
2597
+ const ACTIVE_MS = 3000
2598
+ const IDLE_MS = 15000
2599
+ let iv = null
2600
+ const schedule = (delay) => {
2601
+ if (iv) clearInterval(iv)
2602
+ iv = setInterval(refresh, delay)
2603
+ }
2604
+ // DOM 变化(新消息/流式渲染):立即刷新 + 切高频;1s 节流防虚拟列表滚动刷屏。
2605
+ let flowObs = null
2606
+ let lastDomRefresh = 0
2607
+ const onDomChange = () => {
2608
+ if (!alive) return
2609
+ const now = Date.now()
2610
+ if (now - lastDomRefresh < 1000) return
2611
+ lastDomRefresh = now
2612
+ refresh()
2613
+ schedule(ACTIVE_MS)
2614
+ }
2615
+ const flowEl = document.querySelector('[data-chat-flow=""]') || document.querySelector('[data-focus-flow=""]')
2616
+ if (flowEl) {
2617
+ flowObs = new MutationObserver(onDomChange)
2618
+ flowObs.observe(flowEl, { childList: true, subtree: true })
2619
+ }
2620
+ refresh()
2621
+ schedule(IDLE_MS)
2622
+ return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
2623
+ }, [currentId])
2624
+
2625
+ // DOM 行工具:官方消息行锚点(dsh-navbar 同款)。
2626
+ const domRows = () => [...document.querySelectorAll('[data-time-hover-root]')]
2627
+ .filter((r) => !r.hasAttribute('data-pending-steering'))
2628
+ .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
2629
+ const textOf = (row) => {
2630
+ const b = row.querySelector('[class*=bubble]')
2631
+ return b ? String(b.textContent || '').replace(/\s+/g, ' ').trim() : ''
2632
+ }
2633
+ // 归一化:剥离 markdown 标记(**加粗**/行内代码/链接/符号),使 host 原始
2634
+ // preview 与 DOM 渲染后的 textContent 可比(否则 startsWith 永远失败 →
2635
+ // 近轮次误判「未找到」+ 加载循环误匹配其他轮次,实测踩坑)。
2636
+ const norm = (s) => String(s || '')
2637
+ .replace(/`{1,3}[^`]*`{1,3}/g, ' ')
2638
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
2639
+ .replace(/[*_#>|~-]{1,3}/g, ' ')
2640
+ .replace(/\s+/g, ' ')
2641
+ .trim()
2642
+ // 条目 → DOM 行匹配:preview 归一化截断前缀与行文本归一化后 includes 比较。
2643
+ const findRow = (entry) => {
2644
+ const core = norm(String(entry.preview || '').replace(/…+$/u, '')).slice(0, 50)
2645
+ if (!core) return null // 无文字内容(纯图片/文件消息)无法文本定位
2646
+ for (const row of domRows()) {
2647
+ const t = norm(textOf(row))
2648
+ if (t && t.includes(core.slice(0, 30))) return row
2649
+ }
2650
+ return null
2651
+ }
2652
+
2653
+ // 点击定位:先查该轮次是否已加载(DOM 中能否匹配到对应行)——
2654
+ // 已加载 → 平滑滚动定位;未加载 → 只提示、不跳转(自动加载循环会让消息流
2655
+ // 乱滚/跳错位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
2656
+ const open = (entry) => {
2657
+ const row = findRow(entry)
2658
+ if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'start' }); return }
2659
+ setJumpFail(true)
2660
+ setTimeout(() => setJumpFail(false), 2600)
2661
+ }
2662
+
2663
+ // active:视口内第一条 DOM 用户行 → 文本匹配条目索引高亮(阅读位置跟随)。
2664
+ useEffect(() => {
2665
+ const compute = () => {
2666
+ if (entries.length === 0) { setActiveIdx(-1); return }
2667
+ const rows = domRows()
2668
+ let target = null
2669
+ let bestTop = Number.POSITIVE_INFINITY
2670
+ for (const row of rows) {
2671
+ const top = row.getBoundingClientRect().top
2672
+ if (top >= 0 && top < bestTop) { bestTop = top; target = row }
2673
+ }
2674
+ if (!target) { setActiveIdx(entries.length - 1); return }
2675
+ const t = norm(textOf(target))
2676
+ const idx = entries.findIndex((e) => {
2677
+ const core = norm(String(e.preview || '').replace(/…+$/u, '')).slice(0, 50)
2678
+ return core && t.includes(core.slice(0, 30))
2679
+ })
2680
+ setActiveIdx(idx)
2681
+ }
2682
+ compute()
2683
+ const iv = setInterval(compute, 800)
2684
+ return () => clearInterval(iv)
2685
+ }, [entries])
2686
+
2687
+ const WINDOW = 12
2688
+ const hidden = entries.length < 2
2689
+ const windowed = entries.slice(winOffset, winOffset + WINDOW)
2690
+ const canUp = winOffset > 0
2691
+ const canDown = winOffset + WINDOW < entries.length
2692
+
2693
+ // 悬浮条滚轮/▲▼:只翻悬浮条自身窗口,绝不影响会话区滚动(用户明确要求)。
2694
+ const onKeyDown = (e) => {
2695
+ if (entries.length === 0) return
2696
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2697
+ e.preventDefault()
2698
+ const dir = e.key === 'ArrowDown' ? 1 : -1
2699
+ setKbGlobal((prev) => {
2700
+ const base = prev === null ? (dir > 0 ? -1 : entries.length) : prev
2701
+ return Math.max(0, Math.min(entries.length - 1, base + dir))
2702
+ })
2703
+ } else if (e.key === 'Enter' && kbGlobal !== null && entries[kbGlobal]) {
2704
+ e.preventDefault()
2705
+ open(entries[kbGlobal])
2706
+ }
2707
+ }
2708
+ useEffect(() => {
2709
+ if (kbGlobal === null) return
2710
+ if (kbGlobal < winOffset) setWinOffset(kbGlobal)
2711
+ else if (kbGlobal >= winOffset + WINDOW) setWinOffset(kbGlobal - WINDOW + 1)
2712
+ }, [kbGlobal])
2713
+
2714
+ // 滚轮翻页:React onWheel 是被动监听器(passive: true),内部 preventDefault
2715
+ // 无效并刷屏告警(实测踩坑)——改原生 addEventListener('wheel', {passive:false})。
2716
+ const stripRef = useRef(null)
2717
+ useEffect(() => {
2718
+ const el = stripRef.current
2719
+ if (el === null) return undefined
2720
+ const onNativeWheel = (e) => {
2721
+ e.preventDefault()
2722
+ e.stopPropagation()
2723
+ if (e.deltaY > 0) setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1))
2724
+ else setWinOffset((o) => Math.max(0, o - 1))
2725
+ }
2726
+ el.addEventListener('wheel', onNativeWheel, { passive: false })
2727
+ return () => el.removeEventListener('wheel', onNativeWheel)
2728
+ }, [entries.length])
2729
+
2730
+ // 右侧信息面板:hover 条目/键盘聚焦时弹出该轮信息(保留弹出面板、移除原生
2731
+ // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目与面板间移动 150ms 宽限。
2732
+ const showDetail = (idx) => {
2733
+ if (detailTimer.current) clearTimeout(detailTimer.current)
2734
+ setDetailIdx(idx)
2735
+ }
2736
+ const hideDetail = () => {
2737
+ if (detailTimer.current) clearTimeout(detailTimer.current)
2738
+ detailTimer.current = setTimeout(() => setDetailIdx(null), 150)
2739
+ }
2740
+ useEffect(() => () => { if (detailTimer.current) clearTimeout(detailTimer.current) }, [])
2741
+ // 键盘聚焦跟随显示面板。
2742
+ useEffect(() => {
2743
+ if (kbGlobal !== null && entries[kbGlobal]) showDetail(kbGlobal)
2744
+ }, [kbGlobal])
2745
+
2746
+ const rootStyle = pos === null ? { display: 'none' } : {
2747
+ left: pos.left + 'px',
2748
+ top: pos.top + 'px',
2749
+ transform: 'translateY(-50%)',
2750
+ display: hidden ? 'none' : undefined,
2751
+ }
2752
+
2753
+ const detailEntry = detailIdx !== null ? entries[detailIdx] : null
2754
+ // 面板位置:absolute 相对悬浮条容器(容器有 translateY(-50%) transform,fixed
2755
+ // 会退化为相对容器——必须按容器内偏移计算)。条目 = strip 直接子元素,
2756
+ // 索引 = detailIdx - winOffset;越界修正按视口坐标换算回容器内。
2757
+ let detailPos = null
2758
+ const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : null
2759
+ if (detailEntry && rootRect) {
2760
+ const el = stripRef.current ? stripRef.current.children[detailIdx - winOffset] : null
2761
+ if (el instanceof Element) {
2762
+ const r = el.getBoundingClientRect()
2763
+ const topV = Math.min(r.top, Math.max(4, window.innerHeight - 320))
2764
+ detailPos = { left: r.right - rootRect.left + 8, top: topV - rootRect.top }
2765
+ }
2766
+ }
2767
+
2768
+ return h('div', { 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle },
2769
+ h('div', { className: 'pk-head' },
2770
+ h('span', { className: 'pk-headLabel' }, STR.pianoTitle),
2771
+ h('div', { className: 'pk-navGroup' },
2772
+ h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => setWinOffset((o) => Math.max(0, o - 1)), title: STR.expanded }, '▲'),
2773
+ h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1)), title: STR.collapsed }, '▼'),
2774
+ ),
2775
+ ),
2776
+ h('div', { className: 'pk-strip', ref: stripRef, tabIndex: 0, onKeyDown, onMouseDown: (e) => { if (e.target === e.currentTarget) e.preventDefault() }, 'aria-label': STR.pianoTitle },
2777
+ windowed.map((entry, idx) => {
2778
+ const gi = winOffset + idx
2779
+ const act = activeIdx === gi
2780
+ return h('button', {
2781
+ key: 'turn-' + gi + '-' + entry.seq,
2782
+ type: 'button',
2783
+ className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
2784
+ // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
2785
+ // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
2786
+ onClick: (e) => { open(entry); e.currentTarget.blur() },
2787
+ onMouseEnter: () => showDetail(gi),
2788
+ onMouseLeave: hideDetail,
2789
+ onContextMenu: (e) => {
2790
+ e.preventDefault()
2791
+ if (onOpenWorkbench) onOpenWorkbench()
2792
+ },
2793
+ },
2794
+ // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
2795
+ h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
2796
+ h('span', { className: 'pk-label' }, entry.preview || '(空)'),
2797
+ )
2798
+ }),
2799
+ ),
2800
+ // 信息面板:跟随 hover 条目右侧展开(fixed,位置由 detailPos 控制);
2801
+ // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
2802
+ h('div', { className: 'pk-detail' + (detailEntry ? '' : ' hidden'), style: detailPos, onMouseEnter: () => showDetail(detailIdx), onMouseLeave: hideDetail },
2803
+ detailEntry && h('div', { key: 'h' + detailEntry.turn, className: 'pk-detailHead' }, 'T' + detailEntry.turn + ' · ' + STR.user),
2804
+ detailEntry && h('div', { key: 'b' + detailEntry.turn, className: 'pk-detailBody' }, detailEntry.preview || '(空)'),
2805
+ ),
2806
+ // 跳转状态提示:常驻渲染 + hidden 类(从下方淡入/淡出);未加载轮次提示。
2807
+ h('div', { className: 'pk-status' + (jumpFail ? '' : ' hidden') + (jumpFail ? ' warn' : '') },
2808
+ jumpFail ? STR.pianoJumpFail : '',
2809
+ ),
2810
+ )
2811
+ }
2812
+
2813
+ // ── M12:详情并入右侧栏(details 槽位轻量视图)────────────────────
2814
+ // 随会话自动切换(槽位 session 作用域 key 重挂载);数据复用 host get/getTurn;
2815
+ // 回合列表直接复用 TurnList(展开 → TimelineTurns 完整渲染)。
2816
+ function DetailsDockView(props) {
2817
+ const { sessionId, connection, onExit } = props
2818
+ const [state, setState] = useState({ phase: 'loading', error: null, data: null })
2819
+ const [collapsed, setCollapsed] = useState(() => new Set())
2820
+ const collapsedInit = useRef(false)
2821
+ const [turnItems, setTurnItems] = useState(() => new Map())
2822
+ const loadingTurns = useRef(new Set())
2823
+
2824
+ useEffect(() => {
2825
+ let alive = true
2826
+ setState({ phase: 'loading', error: null, data: null })
2827
+ setTurnItems(new Map())
2828
+ collapsedInit.current = false
2829
+ if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
2830
+ api('get', { sessionId }).then((json) => {
2831
+ if (!alive) return
2832
+ if (json && json.ok) {
2833
+ setState({ phase: 'ready', error: null, data: json })
2834
+ } else {
2835
+ setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
2836
+ }
2837
+ }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
2838
+ return () => { alive = false }
2839
+ }, [sessionId])
2840
+
2841
+ useEffect(() => {
2842
+ if (state.phase !== 'ready' || collapsedInit.current) return
2843
+ collapsedInit.current = true
2844
+ const all = new Set()
2845
+ for (const lt of state.data.lightTurns || []) all.add('turn-' + lt.turn)
2846
+ setCollapsed(all)
2847
+ }, [state.phase])
2848
+
2849
+ const toggle = (key) => {
2850
+ setCollapsed((prev) => {
2851
+ const next = new Set(prev)
2852
+ if (next.has(key)) next.delete(key)
2853
+ else next.add(key)
2854
+ return next
2855
+ })
2856
+ }
2857
+ const ensureTurn = async (turnNo) => {
2858
+ if (turnItems.has(turnNo) || loadingTurns.current.has(turnNo)) return
2859
+ loadingTurns.current.add(turnNo)
2860
+ try {
2861
+ const json = await api('getTurn', { sessionId, turn: turnNo })
2862
+ if (json && json.ok && json.turn) {
2863
+ setTurnItems((prev) => new Map(prev).set(turnNo, json.turn))
2864
+ }
2865
+ } catch (e) {
2866
+ console.warn('[dsh-session-flow] getTurn failed', turnNo, e)
2867
+ } finally {
2868
+ loadingTurns.current.delete(turnNo)
2869
+ }
2870
+ }
2871
+
2872
+ const data = state.data
2873
+ const lightTurns = (data && data.lightTurns) || []
2874
+ const toolStats = (data && data.toolStats) || []
2875
+ // 规则摘要(与详情页同口径,精简版)。
2876
+ const ruleTaskCount = lightTurns.filter((lt) => lt.userMessages && lt.userMessages.length > 0).length
2877
+ const ruleGoal = (() => {
2878
+ for (const lt of lightTurns) {
2879
+ if (lt.userMessages && lt.userMessages.length > 0) return String(lt.userMessages[0].preview).slice(0, 150)
2880
+ }
2881
+ return ''
2882
+ })()
2883
+ const ruleConclusion = (() => {
2884
+ for (let i = lightTurns.length - 1; i >= 0; i--) {
2885
+ if (lightTurns[i].conclusionPreview) return String(lightTurns[i].conclusionPreview).slice(0, 220)
2886
+ }
2887
+ return ''
2888
+ })()
2889
+ const ruleTools = toolStats.slice(0, 6).map((t) => t.name + '·' + t.count).join(' ')
2890
+
2891
+ return h('div', { className: 'dk-root' },
2892
+ h('div', { className: 'dk-head' },
2893
+ h('span', { className: 'dk-title' }, STR.dockHint),
2894
+ h('button', { className: 'dk-btn', onClick: onExit }, STR.dockExit),
2895
+ ),
2896
+ h('div', { className: 'dk-body' },
2897
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
2898
+ state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
2899
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
2900
+ state.phase === 'ready' && h('div', { className: 'dk-summary' },
2901
+ ruleTaskCount > 0 && h('div', { className: 'dk-summaryRow' },
2902
+ h('span', { className: 'dk-summaryTag' }, STR.summaryTaskCount),
2903
+ h('span', { className: 'dk-summaryText' }, ruleTaskCount),
2904
+ ),
2905
+ ruleGoal && h('div', { className: 'dk-summaryRow' },
2906
+ h('span', { className: 'dk-summaryTag' }, STR.summaryGoal),
2907
+ h('span', { className: 'dk-summaryText' }, ruleGoal),
2908
+ ),
2909
+ ruleConclusion && h('div', { className: 'dk-summaryRow' },
2910
+ h('span', { className: 'dk-summaryTag' }, STR.summaryConclusion),
2911
+ h('span', { className: 'dk-summaryText' }, ruleConclusion),
2912
+ ),
2913
+ ruleTools && h('div', { className: 'dk-summaryRow' },
2914
+ h('span', { className: 'dk-summaryTag' }, STR.summaryTools),
2915
+ h('span', { className: 'dk-summaryText' }, ruleTools),
2916
+ ),
2917
+ data.summary && h('div', { className: 'dk-summaryFull' }, data.summary),
2918
+ ),
2919
+ state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2920
+ state.phase === 'ready' && h(TurnList, {
2921
+ sessionId,
2922
+ lightTurns,
2923
+ collapsed,
2924
+ toggle,
2925
+ ensureTurn,
2926
+ turnItems,
2927
+ }),
2928
+ ),
2929
+ )
2930
+ }
2931
+
2932
+ // ── M11:钢琴键竖条挂载(DOM 注入 + 自愈)────────────────────────
2933
+ // 容器挂 document.body(dsh-navbar 同款模式):完全脱离会话区布局树,
2934
+ // 不干扰 conversation 的 flex/grid 布局;fixed 定位由组件自管理。
2935
+ // MutationObserver 自愈:容器丢失(布局重建)时自动恢复。
2936
+ function mountPianoKeys(controller, sessions, connection) {
2937
+ let root = undefined
2938
+ let container = undefined
2939
+ let waitObserver = null
2940
+
2941
+ // M11×视图:轮次条只在「对话」tab(ChatView)激活时显示——官方 viewArea
2942
+ // 只渲染激活视图(only: active.id),轨迹/Plan图/会话流 tab 下 ChatView 卸载,
2943
+ // `[data-chat-flow]` 随之消失(会话流标签页有自己的 sf-view,不冲突)。
2944
+ const syncChatVisibility = () => {
2945
+ if (container === undefined) return
2946
+ const chatFlow = document.querySelector('[data-chat-flow]')
2947
+ container.style.display = chatFlow !== null ? '' : 'none'
2948
+ }
2949
+
2950
+ const ensure = () => {
2951
+ if (container !== undefined) {
2952
+ if (container.isConnected) return
2953
+ if (root) { try { root.unmount() } catch (e) {} root = undefined }
2954
+ container = undefined
2955
+ }
2956
+ container = document.createElement('div')
2957
+ document.body.appendChild(container)
2958
+ try {
2959
+ root = createRoot(container)
2960
+ root.render(h(TurnKeys, {
2961
+ sessions,
2962
+ connection,
2963
+ onOpenWorkbench: () => controller.open(),
2964
+ }))
2965
+ syncChatVisibility()
2966
+ } catch (e) {
2967
+ console.error('[dsh-session-flow:piano] render failed:', e)
2968
+ if (root) { try { root.unmount() } catch (e2) {} root = undefined }
2969
+ if (container) { container.remove(); container = undefined }
2970
+ }
2971
+ }
2972
+
2973
+ waitObserver = new MutationObserver(() => { ensure(); syncChatVisibility() })
2974
+ waitObserver.observe(document.body, { childList: true, subtree: true })
2975
+ ensure()
2976
+
2977
+ return () => {
2978
+ waitObserver.disconnect()
2979
+ if (root) { try { root.unmount() } catch (e) {} root = undefined }
2980
+ if (container) { container.remove(); container = undefined }
2981
+ }
2982
+ }
2983
+
1674
2984
  // ── 侧边栏入口(DOM 级注入 + 自愈)────────────────────────────────
1675
2985
  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
2986
  function sidebarRoot() {
1678
2987
  const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
1679
2988
  if (column === null) return undefined
@@ -1818,7 +3127,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1818
3127
  }
1819
3128
 
1820
3129
  // ── 插件体 ─────────────────────────────────────────────────────────
1821
- const inject = ['sessions', 'connection']
3130
+ const inject = ['sessions', 'connection', 'slots', 'layout']
1822
3131
 
1823
3132
  function apply(ctx) {
1824
3133
  try {
@@ -1850,6 +3159,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1850
3159
  for (const fn of state.listeners) fn()
1851
3160
  }
1852
3161
  },
3162
+ open: () => {
3163
+ // 二期:嵌入标签页「打开完整工作台」→ 唤起侧边栏工作台。
3164
+ if (!state.open) {
3165
+ state.open = true
3166
+ for (const fn of state.listeners) fn()
3167
+ }
3168
+ },
1853
3169
  }
1854
3170
 
1855
3171
  const onActivate = (event) => {
@@ -1857,6 +3173,187 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1857
3173
  }
1858
3174
  document.addEventListener(ACTIVATE_EVENT, onActivate)
1859
3175
 
3176
+ // 二期:嵌入原生会话页 —— conversation.view 槽位环(会话作用域标签页)。
3177
+ // 参照 ui-trajectory(order 10) / plan-graph(order 20) 的注册模式:
3178
+ // slots.inject 自带生命周期(插件卸载自动移除标签页),无需手动清理。
3179
+ // label 支持字符串或惰性求值函数;order 越大越靠后(对话/轨迹/Plan图 之后)。
3180
+ const slots = ctx.get('slots')
3181
+ if (slots !== undefined && typeof slots.inject === 'function' && typeof slots.register === 'function') {
3182
+ try {
3183
+ slots.inject('conversation.view', () => slots.register({
3184
+ name: 'conversation.view',
3185
+ id: 'session-flow',
3186
+ order: 30,
3187
+ label: STR.entry,
3188
+ }, (props) => h(SessionFlowTab, {
3189
+ sessionId: props ? props.sessionId : undefined,
3190
+ sessions,
3191
+ connection,
3192
+ onOpenWorkbench: () => controller.open(),
3193
+ })))
3194
+ } catch (error) {
3195
+ console.error('[dsh-session-flow] conversation.view registration failed:', error)
3196
+ }
3197
+ // 会话健康芯片:官方 conversation.session.header.actions 槽位(ui-jobs/ui-subagent 同款),
3198
+ // order -9 紧随官方模式标识(agent-preset label, order -10)右侧。
3199
+ // 点击芯片 → workbenchBridge 意图桥直达该会话详情。
3200
+ try {
3201
+ slots.inject('conversation.session.header.actions', () => slots.register({
3202
+ name: 'conversation.session.header.actions',
3203
+ id: 'session-flow-health',
3204
+ order: -9,
3205
+ }, (slotProps) => h(SessionHealthChip, {
3206
+ sessionId: slotProps ? slotProps.sessionId : undefined,
3207
+ sessions,
3208
+ connection,
3209
+ onOpen: () => {
3210
+ const sid = slotProps && slotProps.sessionId
3211
+ if (sid) workbenchBridge.open(sid)
3212
+ controller.open()
3213
+ },
3214
+ })))
3215
+ } catch (error) {
3216
+ console.error('[dsh-session-flow] header health chip registration failed:', error)
3217
+ }
3218
+ }
3219
+
3220
+ // M12:详情并入右侧栏 —— details 槽位动态占用(priority -1 低于官方 tool-details)。
3221
+ // 并入开启时注册 + **打开官方 details 列(layout.openDetails——列默认宽度 0 不可见,
3222
+ // plan-graph 同款;曾漏开列导致「无响应」,实测踩坑)**;退出/卸载注销 + closeDetails。
3223
+ // 官方在会话切换时自动 closeDetails(无法阻止)——并入期间:
3224
+ // ① html 打 data-dsh-dock-active 标记 → CSS 禁用 grid 列宽过渡(切换无「关→开」动画);
3225
+ // ② 高频守护(150ms)快速重开列 → 视觉上右栏内容直接刷新为新会话。
3226
+ const layout = ctx.get('layout')
3227
+ let detailsRegistration = null
3228
+ let detailsGuard = null
3229
+ const setDockActive = (on) => {
3230
+ try {
3231
+ if (on) document.documentElement.setAttribute('data-dsh-dock-active', '')
3232
+ else document.documentElement.removeAttribute('data-dsh-dock-active')
3233
+ } catch (e) {}
3234
+ }
3235
+
3236
+ // M12×aionui 兼容层:官方 details 把手按 3 轨模型定位(left = frameWidth - details,
3237
+ // 假设 details 是最后一轨);aionui-panel 把 frame grid 扩为 5 轨
3238
+ // (sidebar|center|details|preview|explorer)后,真实边界左移 preview+explorer px,
3239
+ // 把手却停在旧位置(悬浮于预览列上方,见 docs/02-design/LAYOUT-COEXIST.md)。
3240
+ // dock 激活期间按实际轨宽重算把手位置:三方写 frame style → MutationObserver;
3241
+ // 纯缩放(grid 字符串不变)→ ResizeObserver。3 轨时公式退化为官方原值,无副作用。
3242
+ const parseGridTracksCompat = (input) => {
3243
+ const tracks = []
3244
+ let depth = 0
3245
+ let current = ''
3246
+ for (const ch of String(input)) {
3247
+ if (ch === '(') depth += 1
3248
+ if (ch === ')') depth = Math.max(0, depth - 1)
3249
+ if (ch === ' ' && depth === 0) {
3250
+ if (current !== '') { tracks.push(current); current = '' }
3251
+ continue
3252
+ }
3253
+ current += ch
3254
+ }
3255
+ if (current !== '') tracks.push(current)
3256
+ return tracks
3257
+ }
3258
+ const detailsHandleCompat = {
3259
+ frame: null,
3260
+ observer: null,
3261
+ sizeObserver: null,
3262
+ start() {
3263
+ if (this.observer !== null) return
3264
+ const frame = document.querySelector('[data-dsh-frame]') ||
3265
+ (document.querySelector('[class*="sidebarCol"]') || {}).parentElement || null
3266
+ if (frame === null) return
3267
+ this.frame = frame
3268
+ this.observer = new MutationObserver(() => this.apply())
3269
+ this.observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
3270
+ this.sizeObserver = new ResizeObserver(() => this.apply())
3271
+ this.sizeObserver.observe(frame)
3272
+ this.apply()
3273
+ },
3274
+ stop() {
3275
+ if (this.observer !== null) { this.observer.disconnect(); this.observer = null }
3276
+ if (this.sizeObserver !== null) { this.sizeObserver.disconnect(); this.sizeObserver = null }
3277
+ this.frame = null
3278
+ },
3279
+ apply() {
3280
+ const frame = this.frame
3281
+ if (frame === null) return
3282
+ const tracks = parseGridTracksCompat(frame.style.gridTemplateColumns)
3283
+ if (tracks.length < 3) return
3284
+ const trackPx = (t) => {
3285
+ const m = /^(-?[\d.]+)px$/.exec(String(t).trim())
3286
+ return m === null ? 0 : Number(m[1])
3287
+ }
3288
+ const details = trackPx(tracks[2])
3289
+ const preview = tracks.length >= 4 ? trackPx(tracks[3]) : 0
3290
+ const explorer = tracks.length >= 5 ? trackPx(tracks[4]) : 0
3291
+ const handle = frame.querySelector('[data-side="details"]')
3292
+ if (handle === null) return
3293
+ handle.style.left = `${Math.round(frame.getBoundingClientRect().width - details - preview - explorer)}px`
3294
+ },
3295
+ }
3296
+ const ensureColumn = () => {
3297
+ if (dockBridge.open && layout !== undefined && typeof layout.openDetails === 'function') {
3298
+ try { layout.openDetails() } catch (e) {}
3299
+ }
3300
+ }
3301
+ const syncDetails = () => {
3302
+ if (dockBridge.open && detailsRegistration === null && slots !== undefined && typeof slots.inject === 'function') {
3303
+ try {
3304
+ detailsRegistration = slots.inject('details', () => slots.register({
3305
+ name: 'details',
3306
+ priority: -1,
3307
+ }, (props) => h(DetailsDockView, {
3308
+ sessionId: props ? props.sessionId : undefined,
3309
+ connection,
3310
+ onExit: () => dockBridge.controller.close(),
3311
+ })))
3312
+ setDockActive(true)
3313
+ ensureColumn()
3314
+ } catch (error) {
3315
+ console.error('[dsh-session-flow] details dock registration failed:', error)
3316
+ detailsRegistration = null
3317
+ }
3318
+ } else if (!dockBridge.open && detailsRegistration !== null) {
3319
+ try { detailsRegistration() } catch (e) {}
3320
+ detailsRegistration = null
3321
+ setDockActive(false)
3322
+ if (layout !== undefined && typeof layout.closeDetails === 'function') {
3323
+ try { layout.closeDetails() } catch (e) {}
3324
+ }
3325
+ }
3326
+ }
3327
+ dockBridge.controller = {
3328
+ getSnapshot: () => ({ open: dockBridge.open }),
3329
+ subscribe: (fn) => {
3330
+ dockBridge.listeners.add(fn)
3331
+ return () => dockBridge.listeners.delete(fn)
3332
+ },
3333
+ open: () => {
3334
+ if (!dockBridge.open) {
3335
+ dockBridge.open = true
3336
+ syncDetails()
3337
+ detailsHandleCompat.start()
3338
+ // 会话切换时官方会自动 closeDetails —— 高频守护快速重开(无动画,内容直接刷新)。
3339
+ if (detailsGuard === null) {
3340
+ detailsGuard = setInterval(() => { ensureColumn() }, 150)
3341
+ }
3342
+ for (const fn of dockBridge.listeners) fn()
3343
+ }
3344
+ },
3345
+ close: () => {
3346
+ if (dockBridge.open) {
3347
+ dockBridge.open = false
3348
+ syncDetails()
3349
+ detailsHandleCompat.stop()
3350
+ if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3351
+ for (const fn of dockBridge.listeners) fn()
3352
+ }
3353
+ },
3354
+ }
3355
+ syncDetails()
3356
+
1860
3357
  // 点击侧边栏会话/工作区行 → 交还中栏给会话。
1861
3358
  const onClickSidebarRow = (event) => {
1862
3359
  if (!state.open) return
@@ -1889,11 +3386,25 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1889
3386
  try {
1890
3387
  disposers.push(mountSidebarEntry(controller))
1891
3388
  disposers.push(mountBoard(controller, sessions, connection))
3389
+ // M11:钢琴键会话快切(会话页左侧常驻竖条);挂载失败不影响其他功能。
3390
+ disposers.push(mountPianoKeys(controller, sessions, connection))
1892
3391
  } catch (error) {
1893
3392
  console.error('[dsh-session-flow] mount failed:', error)
1894
3393
  }
1895
3394
 
1896
3395
  const teardown = () => {
3396
+ // M12:卸载时注销 details 槽位占用(恢复官方 tool-details)+ 关列 + 清守护 + 移除标记。
3397
+ if (detailsRegistration) {
3398
+ try { detailsRegistration() } catch (e) {}
3399
+ detailsRegistration = null
3400
+ }
3401
+ if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3402
+ setDockActive(false)
3403
+ detailsHandleCompat.stop()
3404
+ if (layout !== undefined && typeof layout.closeDetails === 'function') {
3405
+ try { layout.closeDetails() } catch (e) {}
3406
+ }
3407
+ dockBridge.open = false
1897
3408
  document.removeEventListener(ACTIVATE_EVENT, onActivate)
1898
3409
  document.removeEventListener('click', onClickSidebarRow, true)
1899
3410
  document.removeEventListener('scroll', onAnyScroll, true)