dsh-session-flow 0.1.0 → 1.0.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: '用户',
@@ -69,6 +74,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
69
74
  cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
70
75
  } : {
71
76
  entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
77
+ pianoTitle: 'Turns', pianoNew: 'New session', pianoFav: 'Favorite', pianoUnfav: 'Unfavorite', pianoFavs: 'Favorites', pianoOpenInFlow: 'Open in Session Flow',
78
+ pianoJumpFail: 'Turn not loaded — scroll up in the conversation to load history, then retry',
79
+ fullText: 'Full-text', fullTextSearching: 'Searching…', fullTextPartial: 'Scanned {N}/{M} sessions (partial)', fullTextNone: 'No full-text matches', fullTextFail: 'Full-text search failed',
80
+ dock: 'Dock right', dockExit: 'Undock', dockHint: 'Session Flow docked · follows current session', dockEmpty: 'Open a session to view its flow here',
81
+ rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
72
82
  search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
73
83
  sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
74
84
  noMatch: 'No matching sessions', noData: 'No session data yet',
@@ -77,10 +87,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
77
87
  duration: 'duration', created: 'created', size: 'size', records: 'records',
78
88
  jumpFailed: 'Cannot open (session not in live list)', unknownTitle: '(untitled session)',
79
89
  loadFailed: 'Load failed',
80
- back: 'Back', close: 'Close', jumpNative: 'Open in native view', expandAll: 'Expand all', collapseAll: 'Collapse all',
90
+ back: 'Back', openWorkbench: 'Open workbench', close: 'Close', jumpNative: 'Open in native view', expandAll: 'Expand all', collapseAll: 'Collapse all',
81
91
  exportMd: 'Export report (ZIP)', exporting: 'Exporting…', exportFail: 'Export failed',
82
92
  live: 'Live', liveActive: 'Live · running', liveExit: 'Exit live', liveLoading: 'Loading live…',
83
93
  liveFail: 'Live unavailable', liveUnavailable: '(live channel unavailable: no connection)',
94
+ liveFallbackHint: 'Session not persisted yet · live view (3s poll)', retryArchive: 'Retry archive view',
84
95
  liveEvents: 'events', liveRefreshing: 'refreshing…', liveLastActive: 'last active',
85
96
  liveTurn: 'Running', liveGenerating: 'Generating…',
86
97
  detailLoading: 'Parsing session…', noTimeline: 'No timeline to show', artifacts: 'Artifacts',
@@ -88,7 +99,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
88
99
  args: 'Args', result: 'Result', thinking: 'Thinking', expanded: 'Expanded', collapsed: 'Collapsed',
89
100
  detailFailed: 'Failed to load session detail', showEmpty: 'Show empty sessions', emptySession: 'Empty',
90
101
  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)',
102
+ conclusion: 'Conclusion', injected: 'Injected', lineage: 'Lineage', noLineage: '(no subagent lineage)', task: 'Task',
92
103
  liveTree: 'Live lineage', offlineTree: 'Archive lineage', modeOneShot: 'one-shot', modeContinuable: 'continuable',
93
104
  subagentDetail: 'Subagent detail', loadDetail: 'Loading subagent detail…', noDetail: 'No visible events for this subagent',
94
105
  viewSubagent: 'View subagent', toolTop: 'Tool Top', issues: 'Problem sessions', issuesHint: 'Only sessions with errors',
@@ -144,6 +155,19 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
144
155
  `.sf-card:hover{border-color:var(--dsw-specific-accent,#4a7dff);box-shadow:0 1px 4px rgba(0,0,0,.06)}`,
145
156
  `.sf-cardTitle{font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}`,
146
157
  `.sf-cardText{color:var(--dsw-alias-label-primary,#222)}`,
158
+ // M8a 会话重命名:hover 显示 ✏️;编辑态行内输入;自定义标题时显示「原名」小字。
159
+ `.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}`,
160
+ `.sf-card:hover .sf-renameBtn,.sf-renameBtn:focus-visible,.sf-renameBtn[data-active='true']{opacity:1}`,
161
+ `.sf-renameBtn:hover{color:var(--dsw-specific-accent,#4a7dff);background:rgba(90,140,255,.1)}`,
162
+ `.sf-renameWrap{flex:1 1 100%;min-width:0;display:inline-flex;align-items:center;gap:6px}`,
163
+ `.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}`,
164
+ `.sf-renameErr{color:#d43b3b;font-size:11px;white-space:nowrap}`,
165
+ `.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}`,
166
+ // 候选 B:卡片结论摘要行(最近结论主行 + 首个任务小字,单行 ellipsis,hover 悬浮全文)。
167
+ `.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)}`,
168
+ `.sf-cardConclusionText,.sf-cardTaskText{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
169
+ `.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}`,
170
+ `.sf-viewTitle .sf-renameBtn{opacity:.55}.sf-viewTitle:hover .sf-renameBtn{opacity:1}`,
147
171
  `.sf-muted{color:var(--dsw-alias-label-secondary,#888);font-size:12px}`,
148
172
  `.sf-mono{font-family:ui-monospace,Consolas,monospace;font-size:12px}`,
149
173
  `.sf-badge{flex:none;border-radius:6px;padding:1px 7px;font-size:11px;line-height:16px}`,
@@ -272,6 +296,31 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
272
296
  `.sf-cacheRow{display:flex;align-items:center;gap:8px;min-width:0}`,
273
297
  `.sf-cacheLabel{flex:none;font-weight:600;color:var(--dsw-alias-label-secondary,#888)}`,
274
298
  `.sf-cacheValue{font-variant-numeric:tabular-nums;font-family:ui-monospace,Consolas,monospace}`,
299
+ // 方向 A:跨会话全文检索结果区(与元数据筛选结果分区展示)。
300
+ `.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}`,
301
+ `.sf-fulltextHead{display:flex;align-items:center;gap:8px;flex:none}`,
302
+ `.sf-fulltextTitle{font-weight:700;font-size:12px;color:var(--dsw-alias-label-primary,#222)}`,
303
+ `.sf-fulltextHit{border-radius:8px;padding:5px 8px;cursor:pointer;display:flex;flex-direction:column;gap:2px;min-width:0}`,
304
+ `.sf-fulltextHit:hover{background:rgba(90,140,255,.1)}`,
305
+ `.sf-fulltextHitTitle{display:flex;align-items:center;gap:8px;min-width:0}`,
306
+ `.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)}`,
307
+ `.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}`,
308
+ `.sf-fulltext .sf-muted{font-size:11px;color:var(--dsw-alias-label-secondary,#666)}`,
309
+ // M12:详情并入右侧栏(details 槽位轻量视图)。
310
+ `.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)}`,
311
+ `.dk-head{flex:none;display:flex;align-items:center;gap:8px;min-width:0}`,
312
+ `.dk-title{flex:1;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
313
+ `.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}`,
314
+ `.dk-btn:hover{border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
315
+ `.dk-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px}`,
316
+ `.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}`,
317
+ `.dk-summaryRow{display:flex;gap:6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);min-width:0}`,
318
+ `.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}`,
319
+ `.dk-summaryText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
320
+ `.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}`,
321
+ // M12:并入期间禁用 layout grid 列宽过渡——官方会话切换会 closeDetails(宽 360→0),
322
+ // 守护重开(0→360)若带过渡动画会产生「关→开」闪烁;禁用后切换直接刷新内容(实测踩坑)。
323
+ `html[data-dsh-dock-active] [class*=frame],html[data-dsh-dock-active] [data-side=details]{transition:none!important}`,
275
324
  // ── M5 会话摘要卡 ──
276
325
  `.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
326
  `.sf-summaryHead{display:flex;align-items:center;gap:8px}`,
@@ -306,14 +355,79 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
306
355
  `.sf-navKind.assistant{background:rgba(120,120,120,.14);color:#666}`,
307
356
  `@keyframes sfFlashBg{0%{background:rgba(90,140,255,.4)}100%{background:transparent}}`,
308
357
  `.sf-flash{animation:sfFlashBg 1.5s ease}`,
358
+ // ── M11 本会话轮次导航(会话页左侧常驻悬浮条目条,DeepSeek 悬浮导航风格)──
359
+ // 只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区;
360
+ // DOM 锚点驱动(data-time-hover-root,dsh-navbar 同款,零 RPC)。
361
+ // 形态:默认收拢为一列短横条(胶囊线,极简不占空间),悬浮/聚焦展开为文字条目卡;
362
+ // 垂直居中悬浮于会话内容区中间(top 由组件算区中心 + translateY(-50%))。
363
+ // 动画体系:一切组件的产生/消失都有「来源与目标」——display 硬切全部改为
364
+ // transform/opacity/max-height 过渡(实测踩坑:display 切换无动画、视觉突兀)。
365
+ `[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}`,
366
+ // 收拢时序:离开时宽度过渡延迟 .22s——信息面板先滑出(.24s),悬浮条主体再收拢
367
+ // (用户要求:收拢第一步先收起详情信息框);进入(hover/focus)无延迟即时展开。
368
+ `[data-dsh-piano-keys]:hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
369
+ `html[${ACTIVE_ATTR}] [data-dsh-piano-keys]{display:none!important}`,
370
+ `.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}`,
371
+ `.pk-strip::-webkit-scrollbar{display:none}`,
372
+ // 收拢态:短横条(胶囊线);overflow:hidden 使 label 文字被高度裁剪(来源=胶囊);
373
+ // ::before 延伸命中区(±4px,点击/悬浮无死区)。
374
+ `.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}`,
375
+ `.pk-key::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px}`,
376
+ `.pk-key:hover{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
377
+ `.pk-key:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);outline-offset:-1px}`,
378
+ `.pk-keyActive{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
379
+ `.pk-keyFocus{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
380
+ // 展开态(悬浮/聚焦):条目变文字卡片;label 常驻(缩略态被条目高度裁剪),
381
+ // 展开时随高度过渡露出文字(来源=胶囊内部,目标=卡片全文)。
382
+ `[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)}`,
383
+ `[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)}`,
384
+ `[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))}`,
385
+ `.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}`,
386
+ `[data-dsh-piano-keys]:hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
387
+ // 轮次序号:缩略态折叠(宽度 0 + 透明),展开态从左侧展开露出(来源=左缘)。
388
+ `.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}`,
389
+ `[data-dsh-piano-keys]:hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
390
+ // hover 条目:在【该条目右侧】展开信息面板(.pk-detail)——absolute 相对悬浮条容器,
391
+ // 坐标按条目位置换算为容器内偏移(注意:容器有 transform:translateY(-50%),fixed 子元素
392
+ // 会被 transform 祖先劫持成相对容器定位——用视口坐标会双重偏移,实测踩坑)。
393
+ `[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)}`,
394
+ // 展开时序:详情框延迟 .28s 再滑入——等悬浮条展开动画完成、条目就位后,
395
+ // 位置才准确(第一次展开时条目仍在变形,立即计算会错位,实测踩坑);
396
+ // 隐藏则立即(无延迟)。
397
+ `.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}`,
398
+ `.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}`,
399
+ `.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}`,
400
+ // 面板内容随条目切换:新内容从右轻滑入(key 变化重新挂载触发 animation)。
401
+ `.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}`,
402
+ `@keyframes pkSlideIn{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}`,
403
+ // 跳转状态提示条(悬浮条下方,与悬浮条等宽):文字在条宽内换行增高显示完整
404
+ // 文案(不加宽),从下方淡入/淡出(来源=悬浮条底缘);毛玻璃。
405
+ `.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}`,
406
+ `.pk-status.hidden{opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none}`,
407
+ `.pk-status.warn{color:#d97706;border-color:rgba(217,119,6,.4)}`,
408
+ // 展开态显示悬浮条细滚动条(缩略态保持无滚动条)。
409
+ `[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}`,
410
+ `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar,[data-dsh-piano-keys]:focus-within .pk-strip::-webkit-scrollbar{width:6px;display:block}`,
411
+ `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,#d7dae0);border-radius:3px}`,
412
+ // 头部:从上方滑入(来源=悬浮条顶缘)——max-height/padding/opacity/transform 过渡,
413
+ // 不占缩略态布局。内容「标题 + ▲▼ 按钮组(并排右上)」。
414
+ `.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}`,
415
+ `[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)}`,
416
+ `.pk-headLabel{flex:1;min-width:0;font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
417
+ `.pk-navGroup{flex:none;display:flex;gap:3px}`,
418
+ `.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}`,
419
+ `.pk-navBtn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
420
+ `.pk-navBtn:disabled{opacity:.35;cursor:default}`,
421
+ `@keyframes sfPianoPulse{0%,100%{opacity:.35}50%{opacity:1}}`,
309
422
  ].join('\n')
310
423
 
311
424
  // ── API ─────────────────────────────────────────────────────────────
312
- function api(method, params) {
425
+ function api(method, params, signal) {
313
426
  return fetch('/api/session-flow', {
314
427
  method: 'POST',
315
428
  headers: { 'Content-Type': 'application/json' },
316
429
  body: JSON.stringify(Object.assign({ method }, params || {})),
430
+ signal,
317
431
  }).then((r) => r.json())
318
432
  }
319
433
 
@@ -404,6 +518,50 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
404
518
  return (bytes / 1024 / 1024).toFixed(1) + ' MiB'
405
519
  }
406
520
 
521
+ // ── M8a 会话重命名:行内编辑(总览卡片 / 详情页标题复用)─────────────
522
+ // props: { initial, onSave: (title) => Promise, onCancel, placeholder }
523
+ // 保存成功(ok truthy / 无返回)由父组件退出编辑态;失败(reject / ok===false)留在编辑态显示错误。
524
+ function RenameInline(props) {
525
+ const [value, setValue] = useState(props.initial || '')
526
+ const [saving, setSaving] = useState(false)
527
+ const [err, setErr] = useState('')
528
+ const inputRef = useRef(null)
529
+ useEffect(() => {
530
+ const el = inputRef.current
531
+ if (el) { el.focus(); el.select() }
532
+ }, [])
533
+ const commit = () => {
534
+ if (saving) return
535
+ const v = value.trim()
536
+ if (v === String(props.initial || '').trim()) { props.onCancel(); return }
537
+ setSaving(true)
538
+ setErr('')
539
+ Promise.resolve(props.onSave(v)).then((ok) => {
540
+ if (ok === false) setSaving(false)
541
+ }).catch((e) => {
542
+ setSaving(false)
543
+ setErr(String(e && e.message || e))
544
+ })
545
+ }
546
+ return h('span', { className: 'sf-renameWrap' },
547
+ h('input', {
548
+ ref: inputRef,
549
+ className: 'sf-renameInput',
550
+ value,
551
+ placeholder: props.placeholder || STR.renamePlaceholder,
552
+ disabled: saving,
553
+ onInput: (e) => setValue(e.target.value),
554
+ onKeyDown: (e) => {
555
+ if (e.key === 'Enter') commit()
556
+ else if (e.key === 'Escape') { e.stopPropagation(); props.onCancel() }
557
+ },
558
+ onBlur: commit,
559
+ onClick: (e) => e.stopPropagation(),
560
+ }),
561
+ err && h('span', { className: 'sf-renameErr' }, err),
562
+ )
563
+ }
564
+
407
565
  // ── 总览页 ──────────────────────────────────────────────────────────
408
566
  // props: { onOpen: (session) => void, onClose: () => void }
409
567
  function SessionFlowOverview(props) {
@@ -418,6 +576,79 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
418
576
  const [cacheInfo, setCacheInfo] = useState(null)
419
577
  const [cacheBusy, setCacheBusy] = useState(false)
420
578
  const [cacheMsg, setCacheMsg] = useState('')
579
+ // 方向 A:跨会话全文检索(防抖 500ms;AbortController 取消;结果区渲染)。
580
+ const [fullText, setFullText] = useState(null) // {phase:'searching'|'done'|'error', query, results, scanned, total, hasMore, error}
581
+ const fullTextAbort = useRef(null)
582
+ const fullTextTimer = useRef(null)
583
+ // M8a:当前编辑重命名的会话 id(null = 无编辑态)。
584
+ const [renamingId, setRenamingId] = useState(null)
585
+ // M8a:保存自定义标题 → 更新本地列表数据(userTitle 覆盖显示,不整页重刷)。
586
+ const saveRename = (sid, title) => {
587
+ return api('rename', { sessionId: sid, title }).then((json) => {
588
+ if (json && json.ok) {
589
+ setState((prev) => {
590
+ if (!prev || !prev.data) return prev
591
+ return {
592
+ ...prev,
593
+ data: {
594
+ ...prev.data,
595
+ workspaces: (prev.data.workspaces || []).map((g) => ({
596
+ ...g,
597
+ sessions: (g.sessions || []).map((s) => (s.id === sid ? { ...s, userTitle: json.userTitle } : s)),
598
+ })),
599
+ },
600
+ }
601
+ })
602
+ setRenamingId(null)
603
+ return true
604
+ }
605
+ throw new Error((json && json.error) || STR.renameFail)
606
+ })
607
+ }
608
+ useEffect(() => () => {
609
+ if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
610
+ if (fullTextAbort.current) fullTextAbort.current.abort()
611
+ }, [])
612
+ useEffect(() => {
613
+ const q = query.trim()
614
+ const isStructured = /^(tool|file|path|err|error):/i.test(q)
615
+ if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
616
+ if (fullTextAbort.current) { fullTextAbort.current.abort(); fullTextAbort.current = null }
617
+ // 结构化前缀走元数据筛选(已有);自由文本 ≥2 字符才触发全文检索。
618
+ if (q.length < 2 || isStructured) { setFullText(null); return }
619
+ fullTextTimer.current = setTimeout(() => {
620
+ const ctrl = new AbortController()
621
+ fullTextAbort.current = ctrl
622
+ setFullText({ phase: 'searching', query: q, results: null, scanned: 0, total: 0, hasMore: false, error: '' })
623
+ api('searchAll', { query: q, workspace: wsFilter || undefined }, ctrl.signal).then((json) => {
624
+ if (json && json.ok) {
625
+ setFullText({ phase: 'done', query: q, results: json.results || [], scanned: json.scanned || 0, total: json.total || 0, hasMore: json.hasMore === true, error: '' })
626
+ } else {
627
+ 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)
628
+ }
629
+ }).catch((e) => {
630
+ if (e && e.name === 'AbortError') return
631
+ 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)
632
+ })
633
+ }, 500)
634
+ }, [query, wsFilter])
635
+ // 命中跳转:localStorage 桥(会话流标签页读取后执行会话内检索定位)+ 原生打开会话。
636
+ const openFullTextHit = (hit) => {
637
+ try {
638
+ // 预写目标会话的 chat store view='session-flow':会话打开时(store 按会话重建并
639
+ // rehydrate localStorage)自动激活「会话流」标签页,而非停在默认 Chat 标签。
640
+ const storeKey = 'dsh.conversation.chat.' + hit.sessionId
641
+ let st = {}
642
+ try { const raw = localStorage.getItem(storeKey); if (raw) st = JSON.parse(raw) } catch (e) {}
643
+ st.view = 'session-flow'
644
+ localStorage.setItem(storeKey, JSON.stringify(st))
645
+ localStorage.setItem(PENDING_SEARCH_KEY, JSON.stringify({ sessionId: hit.sessionId, query: query.trim() }))
646
+ } catch (e) {}
647
+ try { props.sessions.open(hit.sessionId) } catch (e) { console.warn('[dsh-session-flow] open failed', hit.sessionId, e) }
648
+ // 关闭工作台(中栏交还给会话),用户直接看到目标会话的会话流标签页——
649
+ // 否则工作台仍覆盖中栏,需手动点侧边栏才可见(实测踩坑)。
650
+ try { if (props.onClose) props.onClose() } catch (e) {}
651
+ }
421
652
  const fmtBytes = (b) => {
422
653
  if (b === null || b === undefined) return '—'
423
654
  if (b < 1024) return b + ' B'
@@ -575,6 +806,27 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
575
806
  h('button', { className: 'sf-btn' + (cacheOpen ? ' sf-btnActive' : ''), onClick: () => setCacheOpen(!cacheOpen) }, STR.cache),
576
807
  h('button', { className: 'sf-btn', onClick: props.onClose }, '✕ ' + STR.close),
577
808
  ),
809
+ // 方向 A:跨会话全文检索结果区(防抖触发;与元数据筛选结果分区展示)。
810
+ fullText && h('div', { className: 'sf-fulltext' },
811
+ h('div', { className: 'sf-fulltextHead' },
812
+ h('span', { className: 'sf-fulltextTitle' }, STR.fullText + '「' + fullText.query + '」'),
813
+ fullText.phase === 'searching' && h('span', { className: 'sf-muted' }, STR.fullTextSearching),
814
+ fullText.phase === 'done' && fullText.hasMore && h('span', { className: 'sf-muted' },
815
+ STR.fullTextPartial.replace('{N}', String(fullText.scanned)).replace('{M}', String(fullText.total))),
816
+ ),
817
+ fullText.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.fullTextFail + ': ' + fullText.error),
818
+ fullText.phase === 'done' && fullText.results.length === 0 && h('div', { className: 'sf-hint' }, STR.fullTextNone),
819
+ fullText.phase === 'done' && fullText.results.map((hit) =>
820
+ h('div', { key: hit.sessionId, className: 'sf-fulltextHit', onClick: () => openFullTextHit(hit) },
821
+ h('div', { className: 'sf-fulltextHitTitle' },
822
+ h('span', { className: 'sf-fulltextName' }, hit.userTitle || hit.title),
823
+ h('span', { className: 'sf-fulltextCount' }, hit.matchCount),
824
+ ),
825
+ h('div', { className: 'sf-muted', style: { marginTop: 2 } },
826
+ (hit.matches[0] ? hit.matches[0].preview : '') + ' · ' + hit.workspace),
827
+ ),
828
+ ),
829
+ ),
578
830
  // 缓存管理面板:仅本插件缓存,查看体积 + 分类清理(清理后自动重建,无需重启)。
579
831
  cacheOpen && h('div', { className: 'sf-cachePanel' },
580
832
  h('div', { className: 'sf-muted', style: { flex: 'none' } }, STR.cacheHint),
@@ -650,12 +902,35 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
650
902
  h('span', { className: 'sf-cardWsFilter', dangerouslySetInnerHTML: { __html: FILTER_ICON } }),
651
903
  ),
652
904
  h('div', { className: 'sf-cardTitle' },
653
- h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, s.title || STR.unknownTitle),
905
+ renamingId === s.id
906
+ ? h(RenameInline, {
907
+ initial: s.userTitle || s.title || '',
908
+ onSave: (t) => saveRename(s.id, t),
909
+ onCancel: () => setRenamingId(null),
910
+ })
911
+ : h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
912
+ s.userTitle || s.title || STR.unknownTitle,
913
+ s.userTitle && h('span', { className: 'sf-origTitle', title: s.title || STR.unknownTitle },
914
+ STR.origTitle + ': ' + (s.title || STR.unknownTitle)),
915
+ h('button', {
916
+ className: 'sf-renameBtn', title: STR.rename,
917
+ onClick: (e) => { e.stopPropagation(); setRenamingId(s.id) },
918
+ }, '✎'),
919
+ ),
654
920
  s.isEmpty && h('span', { className: 'sf-badge sf-badgeEnd' }, STR.emptySession),
655
921
  s.running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
656
922
  s.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent + ' · d' + s.delegationDepth),
657
923
  s.toolErrors > 0 && h('span', { className: 'sf-badge sf-badgeErr' }, STR.errors + ': ' + s.toolErrors),
658
924
  ),
925
+ // 候选 B:最近结论(主行)+ 首个任务(小字);无值不渲染;hover 悬浮全文。
926
+ s.lastConclusion && h('div', { className: 'sf-cardConclusion', title: s.lastConclusion },
927
+ h('span', { className: 'sf-cardTag' }, STR.conclusion),
928
+ h('span', { className: 'sf-cardConclusionText' }, s.lastConclusion),
929
+ ),
930
+ s.firstTask && h('div', { className: 'sf-cardTask', title: s.firstTask },
931
+ h('span', { className: 'sf-cardTag' }, STR.task),
932
+ h('span', { className: 'sf-cardTaskText' }, s.firstTask),
933
+ ),
659
934
  h('div', { className: 'sf-muted' },
660
935
  h('span', {}, fmtTime(s.createdAt)),
661
936
  h('span', { style: { marginLeft: 8 } }, STR.duration + ' ' + fmtDuration(s.lastEventTime - s.createdAt)),
@@ -1007,7 +1282,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1007
1282
  const renderNode = (node, depth, isLive) => {
1008
1283
  const active = selected !== null && selected.id === node.id
1009
1284
  const hasChildren = (node.children && node.children.length > 0)
1010
- const label = node.label || node.id
1285
+ // M8a:自定义标题优先(离线节点 host 已透传 userTitle;live 节点无此字段)。
1286
+ const label = node.userTitle || node.label || node.title || node.id
1011
1287
  const meta = isLive
1012
1288
  ? ((node.mode === 'continuable' ? STR.modeContinuable : STR.modeOneShot) + ' · ' + (node.activity === 'running' ? STR.running : STR.ended))
1013
1289
  : ((node.toolCalls || 0) + ' ' + STR.tools + (node.toolErrors ? ' · ' + node.toolErrors + ' ' + STR.errors : ''))
@@ -1078,6 +1354,19 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1078
1354
  // 折叠状态:键集合,has(key) = 折叠。turn-N / tool-<callId> / msg-<seq>
1079
1355
  const [collapsed, setCollapsed] = useState(() => new Set())
1080
1356
  const collapsedInit = useRef(false)
1357
+ // M8a:标题重命名(编辑态 + 保存后的显示覆盖,优先于 session.userTitle)。
1358
+ const [renaming, setRenaming] = useState(false)
1359
+ const [titleOverride, setTitleOverride] = useState(null)
1360
+ const saveRename = (title) => {
1361
+ return api('rename', { sessionId: session.id, title }).then((json) => {
1362
+ if (json && json.ok) {
1363
+ setTitleOverride(json.userTitle) // null = 清除恢复原名
1364
+ setRenaming(false)
1365
+ return true
1366
+ }
1367
+ throw new Error((json && json.error) || STR.renameFail)
1368
+ })
1369
+ }
1081
1370
 
1082
1371
  useEffect(() => {
1083
1372
  // 空会话(新建后未对话):不请求详情,直接显示占位提示。
@@ -1317,6 +1606,61 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1317
1606
  }, 120)
1318
1607
  }
1319
1608
 
1609
+ // ── M7 遗留 L1:跨视图 inspect 握手 ──────────────────────────────
1610
+ // chat 视图工具调用行「Inspect」按钮写入共享 store({callId}),会话流标签
1611
+ // 作为 conversation.view 占用者收到 owner props:inspect/onInspectDone。
1612
+ // 消费:toolStats 里定位该 callId 所属回合 → 展开回合(按需加载)→ 滚动定位
1613
+ // → 闪烁高亮 → onInspectDone() 清除(参照 ui-trajectory 的 inspect 消费方式)。
1614
+ // 时序处理:inspect 可能在数据未就绪时到达(依赖 phase 重跑);liveOn/血缘视图
1615
+ // 时先退出再定位;同一 callId 只处理一次(inspectHandled ref,store 清除后重置)。
1616
+ const inspectHandled = useRef(null)
1617
+ const jumpToCall = (turn, callId) => {
1618
+ setActiveTurn(turn)
1619
+ setCollapsed((prev) => {
1620
+ const next = new Set(prev)
1621
+ next.delete('turn-' + turn)
1622
+ return next
1623
+ })
1624
+ ensureTurn(turn)
1625
+ setTimeout(() => {
1626
+ const el = document.getElementById('sf-tc-' + callId)
1627
+ if (el) {
1628
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
1629
+ el.classList.add('sf-flash')
1630
+ setTimeout(() => el.classList.remove('sf-flash'), 1600)
1631
+ }
1632
+ }, 120)
1633
+ }
1634
+ const applyInspect = (callId) => {
1635
+ if (!callId) return
1636
+ let turn = null
1637
+ for (const t of toolStats) {
1638
+ const c = (t.calls || []).find((x) => x.callId === callId)
1639
+ if (c) { turn = c.turn; break }
1640
+ }
1641
+ if (turn === null) {
1642
+ // toolStats 未命中(理论上不应发生):searchIn 按 callId 全文兜底。
1643
+ api('searchIn', { sessionId: session.id, query: callId }).then((json) => {
1644
+ const m = json && json.ok ? (json.matches || []).find((x) => x.callId === callId) : null
1645
+ if (m && typeof m.turn === 'number') jumpToCall(m.turn, callId)
1646
+ if (props.onInspectDone) props.onInspectDone()
1647
+ }).catch(() => { if (props.onInspectDone) props.onInspectDone() })
1648
+ return
1649
+ }
1650
+ jumpToCall(turn, callId)
1651
+ if (props.onInspectDone) props.onInspectDone()
1652
+ }
1653
+ useEffect(() => {
1654
+ // store 清除(inspect → null)后重置防重标记,允许同一 callId 再次检视。
1655
+ if (!props.inspect || !props.inspect.callId) { inspectHandled.current = null; return }
1656
+ if (state.phase !== 'ready') return
1657
+ if (inspectHandled.current === props.inspect.callId) return
1658
+ inspectHandled.current = props.inspect.callId
1659
+ if (liveOn) exitLive()
1660
+ if (view === 'lineage') setView('timeline')
1661
+ applyInspect(props.inspect.callId)
1662
+ }, [props.inspect, state.phase])
1663
+
1320
1664
  // 定位工具/错误调用:展开所在回合(按需加载)→ 滚动到调用行;同一种再次点击循环。
1321
1665
  const locateCall = (list, cursorKey) => {
1322
1666
  if (!list || list.length === 0) return
@@ -1431,12 +1775,32 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1431
1775
 
1432
1776
  return h('div', { className: 'sf-view' },
1433
1777
  h('div', { className: 'sf-viewHeader' },
1434
- h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + STR.back),
1778
+ h('button', { className: 'sf-btn', onClick: props.onBack }, '← ' + (props.backLabel || STR.back)),
1435
1779
  h('h2', { className: 'sf-viewTitle', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis' } },
1436
- session.title || STR.unknownTitle),
1780
+ renaming
1781
+ ? h(RenameInline, {
1782
+ initial: titleOverride || session.userTitle || session.title || '',
1783
+ onSave: saveRename,
1784
+ onCancel: () => setRenaming(false),
1785
+ })
1786
+ : h('span', { style: { display: 'inline-flex', alignItems: 'center', minWidth: 0 } },
1787
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: (titleOverride || session.userTitle) ? session.title || STR.unknownTitle : undefined },
1788
+ titleOverride || session.userTitle || session.title || STR.unknownTitle),
1789
+ h('button', {
1790
+ className: 'sf-renameBtn', title: STR.rename,
1791
+ onClick: () => setRenaming(true),
1792
+ }, '✎'),
1793
+ ),
1794
+ ),
1437
1795
  running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1438
1796
  session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
1439
1797
  h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
1798
+ // M12:并入右栏(仅工作台详情页;嵌入标签页不显示——避免右栏与标签页重复)。
1799
+ !props.embedded && h('button', {
1800
+ className: 'sf-btn',
1801
+ onClick: () => { if (dockBridge.controller) dockBridge.controller.open() },
1802
+ title: STR.dockHint,
1803
+ }, STR.dock),
1440
1804
  // M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
1441
1805
  props.connection !== undefined && h('button', {
1442
1806
  className: 'sf-btn' + (liveOn ? ' sf-btnActive' : ''),
@@ -1444,7 +1808,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1444
1808
  disabled: liveOn && liveState.phase === 'loading',
1445
1809
  }, liveOn ? STR.liveActive + ' ✕' : STR.live),
1446
1810
  h('button', { className: 'sf-btn', onClick: exportSessionMd, disabled: exporting }, exporting ? STR.exporting : STR.exportMd),
1447
- h('button', { className: 'sf-btn', onClick: jumpNative }, STR.jumpNative),
1811
+ // 嵌入原生会话页模式:无「跳转原生会话」(本页即原生会话页)。
1812
+ !props.embedded && h('button', { className: 'sf-btn', onClick: jumpNative }, STR.jumpNative),
1448
1813
  h('button', { className: 'sf-btn', onClick: expandAll }, STR.expandAll),
1449
1814
  h('button', { className: 'sf-btn', onClick: collapseAll }, STR.collapseAll),
1450
1815
  ),
@@ -1651,6 +2016,189 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1651
2016
  )
1652
2017
  }
1653
2018
 
2019
+ // ── 二期:嵌入原生会话页(conversation.view 槽位标签页)────────────
2020
+ // 槽位环为会话作用域:每个原生会话页内一个「会话流」标签页,框架按
2021
+ // key={sessionId} 重挂载,天然跟随当前会话切换。组件收到的框架标准
2022
+ // props:sessionId + useSession(会话快照);数据源与一期详情页一致
2023
+ // (host get/getTurn/searchIn/summarize/exportMd/lineage + 实时通道)。
2024
+ // 先取 host get 的轻量元信息(标题/计数,用于详情页空会话判定),
2025
+ // 再复用 SessionFlowDetail 完整详情视图(摘要/统计/折叠时间线/四标签导航)。
2026
+ // 嵌入模式差异:返回按钮 →「打开完整工作台」(唤起侧边栏工作台);
2027
+ // 隐藏「跳转原生会话」(本页即原生会话页);onClose 无操作。
2028
+ function SessionFlowTab(props) {
2029
+ const { sessionId } = props
2030
+ const [meta, setMeta] = useState(null) // {id, title, userMessages, toolCalls, turns}
2031
+ const [loadErr, setLoadErr] = useState('')
2032
+ // M7 遗留 L2:档案未命中(会话尚未落盘)时进入实时兜底视图,而非纯报错。
2033
+ const [liveFallback, setLiveFallback] = useState(false)
2034
+ const [retryKey, setRetryKey] = useState(0) // 「重试档案视图」触发重新 get
2035
+ // 方向 A:全文检索命中跳转——读取 pendingSearch 桥(总览命中点击写入),
2036
+ // 匹配当前会话则把检索词带入详情页(initialSearchQuery → 自动执行会话内 searchIn)。
2037
+ const [pendingQuery, setPendingQuery] = useState('')
2038
+ useEffect(() => {
2039
+ if (!sessionId) return
2040
+ try {
2041
+ const raw = localStorage.getItem(PENDING_SEARCH_KEY)
2042
+ if (!raw) return
2043
+ const p = JSON.parse(raw)
2044
+ if (p && p.sessionId === sessionId && p.query) {
2045
+ localStorage.removeItem(PENDING_SEARCH_KEY)
2046
+ setPendingQuery(p.query)
2047
+ }
2048
+ } catch (e) {}
2049
+ }, [sessionId])
2050
+
2051
+ useEffect(() => {
2052
+ let alive = true
2053
+ setMeta(null)
2054
+ setLoadErr('')
2055
+ setLiveFallback(false)
2056
+ if (!sessionId) return undefined
2057
+ const conn = props.connection
2058
+ const canLive = conn !== undefined && conn.api !== undefined && conn.api.sessions !== undefined
2059
+ const toFallback = () => { if (alive && canLive) setLiveFallback(true) }
2060
+ api('get', { sessionId }).then((json) => {
2061
+ if (!alive) return
2062
+ if (json && json.ok) {
2063
+ const counts = json.counts || {}
2064
+ const t = json.title
2065
+ setMeta({
2066
+ id: sessionId,
2067
+ // entry.parsed.title 是 {title, source} 对象(已知陷阱),取 .title。
2068
+ title: t && typeof t === 'object' ? String(t.title || '') : String(t || ''),
2069
+ // M8a:自定义标题覆盖显示(userTitle || title)。
2070
+ userTitle: json.userTitle || null,
2071
+ userMessages: counts['user/message'] || 0,
2072
+ toolCalls: counts['tool/call'] || 0,
2073
+ turns: counts['turn/start'] || 0,
2074
+ })
2075
+ } else {
2076
+ if (canLive) setLiveFallback(true)
2077
+ else setLoadErr((json && json.error) || STR.detailFailed)
2078
+ }
2079
+ }).catch((e) => {
2080
+ if (!alive) return
2081
+ if (canLive) setLiveFallback(true)
2082
+ else setLoadErr(String(e && e.message || e))
2083
+ })
2084
+ return () => { alive = false }
2085
+ }, [sessionId, retryKey])
2086
+
2087
+ if (liveFallback) {
2088
+ return h(SessionFlowLiveFallback, {
2089
+ sessionId,
2090
+ connection: props.connection,
2091
+ onRetry: () => setRetryKey((k) => k + 1),
2092
+ })
2093
+ }
2094
+ if (loadErr) {
2095
+ return h('div', { className: 'sf-view' },
2096
+ h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + loadErr))
2097
+ }
2098
+ if (meta === null) {
2099
+ return h('div', { className: 'sf-view' }, h('div', { className: 'sf-hint' }, STR.detailLoading))
2100
+ }
2101
+ return h(SessionFlowDetail, {
2102
+ session: meta,
2103
+ sessions: props.sessions,
2104
+ connection: props.connection,
2105
+ onBack: props.onOpenWorkbench,
2106
+ backLabel: STR.openWorkbench,
2107
+ embedded: true,
2108
+ onClose: () => {},
2109
+ initialSearchQuery: pendingQuery,
2110
+ // M7 遗留 L1:透传跨视图 inspect 握手(chat Inspect 按钮 → 会话流定位)。
2111
+ inspect: props.inspect,
2112
+ onInspectDone: props.onInspectDone,
2113
+ })
2114
+ }
2115
+
2116
+ // ── M7 遗留 L2:会话未落盘时的实时兜底视图 ────────────────────────
2117
+ // 档案通道未命中(host get 404)时,用实时通道(sessions.history → derive)
2118
+ // 渲染折叠时间线 + 3s 轮询,并保留「重试档案视图」入口。逻辑与 SessionFlowDetail
2119
+ // 内实时通道同构(结构信号运行判定 / 只折叠新回合 / 不传 hideHead),自包含实现。
2120
+ function SessionFlowLiveFallback(props) {
2121
+ const { sessionId, connection } = props
2122
+ const [state, setState] = useState({ phase: 'loading', error: null, timeline: null, eventCount: 0, running: false })
2123
+ const [collapsed, setCollapsed] = useState(() => new Set())
2124
+ const [activeTurn, setActiveTurn] = useState(null)
2125
+ const liveSeenTurns = useRef(new Set())
2126
+ const timer = useRef(null)
2127
+
2128
+ const fetchLive = useRef(async () => {})
2129
+ fetchLive.current = async () => {
2130
+ if (connection === undefined || connection.api === undefined || connection.api.sessions === undefined) {
2131
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
2132
+ return
2133
+ }
2134
+ setState((s) => ({ ...s, phase: s.timeline === null ? 'loading' : 'ready' }))
2135
+ try {
2136
+ const resp = await connection.api.sessions.history({ sessionId, mode: 'full', maxMessages: 400 })
2137
+ const val = resp && resp.result && resp.result.value
2138
+ if (val === undefined || !Array.isArray(val.events)) {
2139
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2140
+ return
2141
+ }
2142
+ const events = val.events.map((entry) => (entry && entry.event) || entry)
2143
+ const derived = await api('derive', { events })
2144
+ if (!derived || !derived.ok) {
2145
+ setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
2146
+ return
2147
+ }
2148
+ const tl = (derived.timeline && derived.timeline.turns) || []
2149
+ setState({
2150
+ phase: 'ready', error: null, timeline: tl, eventCount: events.length,
2151
+ running: derived.running === true,
2152
+ })
2153
+ // 只折叠新出现的回合(默认折叠),已展开的保持用户状态(M6 经验)。
2154
+ const newTurns = tl.filter((t) => !liveSeenTurns.current.has(t.turn))
2155
+ for (const t of tl) liveSeenTurns.current.add(t.turn)
2156
+ if (newTurns.length > 0) {
2157
+ setCollapsed((prev) => {
2158
+ const next = new Set(prev)
2159
+ for (const t of newTurns) next.add('turn-' + t.turn)
2160
+ return next
2161
+ })
2162
+ }
2163
+ } catch (e) {
2164
+ setState((s) => ({ ...s, phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }))
2165
+ }
2166
+ }
2167
+
2168
+ useEffect(() => {
2169
+ liveSeenTurns.current.clear()
2170
+ fetchLive.current()
2171
+ if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, 3000)
2172
+ return () => { if (timer.current !== null) { clearInterval(timer.current); timer.current = null } }
2173
+ }, [sessionId])
2174
+
2175
+ const toggle = (key) => {
2176
+ setCollapsed((prev) => {
2177
+ const next = new Set(prev)
2178
+ if (next.has(key)) next.delete(key)
2179
+ else next.add(key)
2180
+ return next
2181
+ })
2182
+ }
2183
+
2184
+ return h('div', { className: 'sf-view' },
2185
+ h('div', { className: 'sf-viewHeader' },
2186
+ h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
2187
+ h('span', { className: 'sf-liveText' }, STR.liveFallbackHint),
2188
+ h('button', { className: 'sf-btn sf-liveExitBtn', onClick: props.onRetry }, STR.retryArchive),
2189
+ ),
2190
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.liveLoading),
2191
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, state.error),
2192
+ state.phase === 'ready' && state.timeline !== null && state.timeline.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2193
+ state.phase === 'ready' && state.timeline !== null && state.timeline.length > 0 && h(TimelineTurns, {
2194
+ // 不传 hideHead:折叠回合需保留展开入口(M6 经验)。
2195
+ turns: state.timeline, collapsed, toggle,
2196
+ liveActive: state.running === true,
2197
+ activeTurn, setActiveTurn,
2198
+ }),
2199
+ )
2200
+ }
2201
+
1654
2202
  // ── 主视图(总览 ⇄ 详情)───────────────────────────────────────────
1655
2203
  function SessionFlowView(props) {
1656
2204
  const [detail, setDetail] = useState(null) // { session, searchQuery }
@@ -1660,6 +2208,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1660
2208
  // M5c 方案C:总览检索词随会话带入详情页,自动执行会话内检索。
1661
2209
  onOpen: (session, searchQuery) => setDetail({ session, searchQuery }),
1662
2210
  onClose: props.onClose,
2211
+ sessions: props.sessions,
1663
2212
  })
1664
2213
  : h(SessionFlowDetail, {
1665
2214
  session: detail.session,
@@ -1671,9 +2220,522 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1671
2220
  })
1672
2221
  }
1673
2222
 
2223
+ // ── M11:钢琴键会话快切(会话页左侧常驻竖条)─────────────────────
2224
+ // 数据:ctx.sessions.list(ObservableSnapshot 订阅)→ 过滤(排除 subagent、隐藏 blank)
2225
+ // → 排序(updatedAt 降序,收藏置顶)→ 分组(连续 cwd 同段,段间黑白交替)。
2226
+ // 点击 sessions.open(id) 切换;会话切换由框架重挂载会话组件(M7 已验证)。
2227
+ // 错误角标:挂载时拉一次 host list 索引(toolErrors 映射)。
2228
+ // 预览卡:hover 200ms 防抖 → host get 单个会话(缓存秒回)→ fixed 浮层(不裁剪)。
2229
+ // 交互:滚轮/箭头滚动窗口(默认 15 键,兼容大量会话)、键盘 ↑↓/Enter、
2230
+ // 收藏星标(localStorage)、右键「在会话流中打开」(工作台唤起)。
2231
+ const PIANO_FAVS_KEY = 'dsh.sessionFlow.pianoFavs' // 预留(收藏键 v2 迁移用;当前轮次导航不使用)
2232
+ const PENDING_SEARCH_KEY = 'dsh.sessionFlow.pendingSearch' // 方向 A:全文命中跳转桥
2233
+ // M12:并入右栏状态桥——挂 window 全局共享(跨 factory 实例/热重载稳定;
2234
+ // 曾用模块闭包 const dockState,热重载后新 apply 闭包访问不到 → ReferenceError 踩坑)。
2235
+ const dockBridge = (typeof window !== 'undefined' && window.__dshSessionFlowDock__) ||
2236
+ (typeof window !== 'undefined'
2237
+ ? (window.__dshSessionFlowDock__ = { open: false, listeners: new Set(), controller: null })
2238
+ : { open: false, listeners: new Set(), controller: null })
2239
+ const PIANO_WINDOW = 15
2240
+
2241
+ // 定位目标:会话内容区。注意必须选**滚动容器** scrollBody(data-conversation-scroll):
2242
+ // viewArea 在滚动场景下 rect 是「内容全尺寸」(如 y=-25687, h=26823),用它定位会把
2243
+ // 竖条甩到视口外(实测踩坑)。scrollBody 才有正确的视口内矩形。
2244
+ // 优先级:scrollBody(滚动容器)→ viewArea(仅无 scrollBody 时兜底)。
2245
+ function pianoViewArea() {
2246
+ const col = document.querySelector(CONVERSATION_SELECTOR)
2247
+ if (col === null) return undefined
2248
+ const body = col.querySelector('[data-conversation-scroll]')
2249
+ if (body instanceof Element) return body
2250
+ const area = col.querySelector('[class*=viewArea]')
2251
+ if (area instanceof Element) return area
2252
+ return undefined
2253
+ }
2254
+
2255
+ // ── M11:本会话轮次导航(会话页左侧常驻悬浮条目条,v3)──────────
2256
+ // 范围:只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区。
2257
+ // 数据:官方消息行锚点 DOM 驱动(data-time-hover-root,dsh-navbar 同款机制,
2258
+ // 零 RPC 实时):用户行 = 无 data-turn-tail + 含 [class*=bubble]。
2259
+ // 轮次 → 结论预览:按顺序索引映射 host get 的 lightTurns(用户消息摊平)。
2260
+ // 交互:点击滚动定位该轮、active 跟随视口第一条、窗口裁剪 + 滚轮/▲▼、
2261
+ // 键盘 ↑↓/Enter、hover 预览卡(该轮用户消息 + 结论)、右键唤起会话流工作台。
2262
+ // 样式:DeepSeek 网页版右侧悬浮消息导航风格(圆角卡片 + 单行截断文字),移到左侧。
2263
+ function TurnKeys(props) {
2264
+ const { sessions, onOpenWorkbench } = props
2265
+ const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
2266
+ const [activeIdx, setActiveIdx] = useState(-1)
2267
+ const [winOffset, setWinOffset] = useState(0)
2268
+ const [kbGlobal, setKbGlobal] = useState(null)
2269
+ const [detailIdx, setDetailIdx] = useState(null) // 右侧信息面板显示的条目索引
2270
+ const detailTimer = useRef(null)
2271
+ const rootRef = useRef(null) // 悬浮条根容器(面板定位换算基准)
2272
+ const [jumpFail, setJumpFail] = useState(false) // 未加载轮次提示
2273
+ const [, force] = useState(0)
2274
+
2275
+ // 定位自管理:垂直居中悬浮于会话内容区(scrollBody 矩形中心 + translateY(-50%))。
2276
+ const [pos, setPos] = useState(null) // {left, top(区中心)}
2277
+ useEffect(() => {
2278
+ const compute = () => {
2279
+ const area = pianoViewArea()
2280
+ if (area === undefined) { setPos(null); return }
2281
+ const r = area.getBoundingClientRect()
2282
+ if (r.width === 0 && r.height === 0) return
2283
+ const seat = area.querySelector('[data-composer-seat]')
2284
+ const seatH = seat ? seat.getBoundingClientRect().height : 0
2285
+ setPos({ left: r.left, top: r.top + Math.max(0, r.height - seatH) / 2 })
2286
+ }
2287
+ compute()
2288
+ window.addEventListener('resize', compute)
2289
+ const iv = setInterval(compute, 2000)
2290
+ return () => { window.removeEventListener('resize', compute); clearInterval(iv) }
2291
+ }, [])
2292
+
2293
+ // 订阅会话列表变化:切换会话(current 变化)→ 重渲染刷新条目。
2294
+ useEffect(() => {
2295
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
2296
+ return sessions.list.subscribe(() => force((n) => n + 1))
2297
+ }
2298
+ return undefined
2299
+ }, [sessions])
2300
+
2301
+ // 默认定位到【最新一轮】:首次拿到条目后窗口初始 offset 指向末尾
2302
+ // (此前默认从最旧开始,看最新轮次需翻页——已修正)。之后用户手动
2303
+ // 翻页/新消息到达都不再重置(不打断用户当前窗口位置)。
2304
+ const initTailDone = useRef(false)
2305
+ useEffect(() => {
2306
+ if (initTailDone.current || entries.length <= WINDOW) return
2307
+ initTailDone.current = true
2308
+ setWinOffset(entries.length - WINDOW)
2309
+ }, [entries])
2310
+
2311
+ // 当前会话 id(仅取 current,不展示跨会话列表)。
2312
+ const currentId = (() => {
2313
+ try {
2314
+ const s = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list.getSnapshot() : null
2315
+ return s && s.current ? s.current : null
2316
+ } catch (e) { return null }
2317
+ })()
2318
+
2319
+ // 条目数据:host get lightTurns 全量用户轮次(含往期,不受 DOM 虚拟列表窗口限制)。
2320
+ // 轮询降载(PERF-ANALYSIS §4.1):大会话 get 每轮 ~383ms(缓存 JSON.parse)——
2321
+ // 观察消息流 DOM 变化(新消息/流式渲染)→ 立即刷新并切高频(3s);
2322
+ // 空闲时低频兜底(15s),避免常驻高频轮询空转。
2323
+ useEffect(() => {
2324
+ if (!currentId) { setEntries([]); return undefined }
2325
+ let alive = true
2326
+ // 切换会话后重置「已定位」标记,下次加载重新定位到新会话末尾。
2327
+ initTailDone.current = false
2328
+ const refresh = () => {
2329
+ api('get', { sessionId: currentId }).then((json) => {
2330
+ if (!alive || !json || !json.ok) return
2331
+ const flat = []
2332
+ for (const lt of json.lightTurns || []) {
2333
+ for (const u of lt.userMessages || []) {
2334
+ flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview })
2335
+ }
2336
+ }
2337
+ // 防御:同一 seq 只保留一条(避免同一条消息重复渲染 = 「最后一条显示两次」)。
2338
+ const seen = new Set()
2339
+ const dedup = flat.filter((e) => {
2340
+ if (seen.has(e.seq)) return false
2341
+ seen.add(e.seq)
2342
+ return true
2343
+ })
2344
+ setEntries((prev) => {
2345
+ if (prev.length === dedup.length && prev.length > 0 && prev[prev.length - 1].seq === dedup[dedup.length - 1].seq) return prev
2346
+ return dedup
2347
+ })
2348
+ }).catch(() => {})
2349
+ }
2350
+ const ACTIVE_MS = 3000
2351
+ const IDLE_MS = 15000
2352
+ let iv = null
2353
+ const schedule = (delay) => {
2354
+ if (iv) clearInterval(iv)
2355
+ iv = setInterval(refresh, delay)
2356
+ }
2357
+ // DOM 变化(新消息/流式渲染):立即刷新 + 切高频;1s 节流防虚拟列表滚动刷屏。
2358
+ let flowObs = null
2359
+ let lastDomRefresh = 0
2360
+ const onDomChange = () => {
2361
+ if (!alive) return
2362
+ const now = Date.now()
2363
+ if (now - lastDomRefresh < 1000) return
2364
+ lastDomRefresh = now
2365
+ refresh()
2366
+ schedule(ACTIVE_MS)
2367
+ }
2368
+ const flowEl = document.querySelector('[data-chat-flow=""]') || document.querySelector('[data-focus-flow=""]')
2369
+ if (flowEl) {
2370
+ flowObs = new MutationObserver(onDomChange)
2371
+ flowObs.observe(flowEl, { childList: true, subtree: true })
2372
+ }
2373
+ refresh()
2374
+ schedule(IDLE_MS)
2375
+ return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
2376
+ }, [currentId])
2377
+
2378
+ // DOM 行工具:官方消息行锚点(dsh-navbar 同款)。
2379
+ const domRows = () => [...document.querySelectorAll('[data-time-hover-root]')]
2380
+ .filter((r) => !r.hasAttribute('data-pending-steering'))
2381
+ .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
2382
+ const textOf = (row) => {
2383
+ const b = row.querySelector('[class*=bubble]')
2384
+ return b ? String(b.textContent || '').replace(/\s+/g, ' ').trim() : ''
2385
+ }
2386
+ // 归一化:剥离 markdown 标记(**加粗**/行内代码/链接/符号),使 host 原始
2387
+ // preview 与 DOM 渲染后的 textContent 可比(否则 startsWith 永远失败 →
2388
+ // 近轮次误判「未找到」+ 加载循环误匹配其他轮次,实测踩坑)。
2389
+ const norm = (s) => String(s || '')
2390
+ .replace(/`{1,3}[^`]*`{1,3}/g, ' ')
2391
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
2392
+ .replace(/[*_#>|~-]{1,3}/g, ' ')
2393
+ .replace(/\s+/g, ' ')
2394
+ .trim()
2395
+ // 条目 → DOM 行匹配:preview 归一化截断前缀与行文本归一化后 includes 比较。
2396
+ const findRow = (entry) => {
2397
+ const core = norm(String(entry.preview || '').replace(/…+$/u, '')).slice(0, 50)
2398
+ if (!core) return null // 无文字内容(纯图片/文件消息)无法文本定位
2399
+ for (const row of domRows()) {
2400
+ const t = norm(textOf(row))
2401
+ if (t && t.includes(core.slice(0, 30))) return row
2402
+ }
2403
+ return null
2404
+ }
2405
+
2406
+ // 点击定位:先查该轮次是否已加载(DOM 中能否匹配到对应行)——
2407
+ // 已加载 → 平滑滚动定位;未加载 → 只提示、不跳转(自动加载循环会让消息流
2408
+ // 乱滚/跳错位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
2409
+ const open = (entry) => {
2410
+ const row = findRow(entry)
2411
+ if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'start' }); return }
2412
+ setJumpFail(true)
2413
+ setTimeout(() => setJumpFail(false), 2600)
2414
+ }
2415
+
2416
+ // active:视口内第一条 DOM 用户行 → 文本匹配条目索引高亮(阅读位置跟随)。
2417
+ useEffect(() => {
2418
+ const compute = () => {
2419
+ if (entries.length === 0) { setActiveIdx(-1); return }
2420
+ const rows = domRows()
2421
+ let target = null
2422
+ let bestTop = Number.POSITIVE_INFINITY
2423
+ for (const row of rows) {
2424
+ const top = row.getBoundingClientRect().top
2425
+ if (top >= 0 && top < bestTop) { bestTop = top; target = row }
2426
+ }
2427
+ if (!target) { setActiveIdx(entries.length - 1); return }
2428
+ const t = norm(textOf(target))
2429
+ const idx = entries.findIndex((e) => {
2430
+ const core = norm(String(e.preview || '').replace(/…+$/u, '')).slice(0, 50)
2431
+ return core && t.includes(core.slice(0, 30))
2432
+ })
2433
+ setActiveIdx(idx)
2434
+ }
2435
+ compute()
2436
+ const iv = setInterval(compute, 800)
2437
+ return () => clearInterval(iv)
2438
+ }, [entries])
2439
+
2440
+ const WINDOW = 12
2441
+ const hidden = entries.length < 2
2442
+ const windowed = entries.slice(winOffset, winOffset + WINDOW)
2443
+ const canUp = winOffset > 0
2444
+ const canDown = winOffset + WINDOW < entries.length
2445
+
2446
+ // 悬浮条滚轮/▲▼:只翻悬浮条自身窗口,绝不影响会话区滚动(用户明确要求)。
2447
+ const onKeyDown = (e) => {
2448
+ if (entries.length === 0) return
2449
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
2450
+ e.preventDefault()
2451
+ const dir = e.key === 'ArrowDown' ? 1 : -1
2452
+ setKbGlobal((prev) => {
2453
+ const base = prev === null ? (dir > 0 ? -1 : entries.length) : prev
2454
+ return Math.max(0, Math.min(entries.length - 1, base + dir))
2455
+ })
2456
+ } else if (e.key === 'Enter' && kbGlobal !== null && entries[kbGlobal]) {
2457
+ e.preventDefault()
2458
+ open(entries[kbGlobal])
2459
+ }
2460
+ }
2461
+ useEffect(() => {
2462
+ if (kbGlobal === null) return
2463
+ if (kbGlobal < winOffset) setWinOffset(kbGlobal)
2464
+ else if (kbGlobal >= winOffset + WINDOW) setWinOffset(kbGlobal - WINDOW + 1)
2465
+ }, [kbGlobal])
2466
+
2467
+ // 滚轮翻页:React onWheel 是被动监听器(passive: true),内部 preventDefault
2468
+ // 无效并刷屏告警(实测踩坑)——改原生 addEventListener('wheel', {passive:false})。
2469
+ const stripRef = useRef(null)
2470
+ useEffect(() => {
2471
+ const el = stripRef.current
2472
+ if (el === null) return undefined
2473
+ const onNativeWheel = (e) => {
2474
+ e.preventDefault()
2475
+ e.stopPropagation()
2476
+ if (e.deltaY > 0) setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1))
2477
+ else setWinOffset((o) => Math.max(0, o - 1))
2478
+ }
2479
+ el.addEventListener('wheel', onNativeWheel, { passive: false })
2480
+ return () => el.removeEventListener('wheel', onNativeWheel)
2481
+ }, [entries.length])
2482
+
2483
+ // 右侧信息面板:hover 条目/键盘聚焦时弹出该轮信息(保留弹出面板、移除原生
2484
+ // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目与面板间移动 150ms 宽限。
2485
+ const showDetail = (idx) => {
2486
+ if (detailTimer.current) clearTimeout(detailTimer.current)
2487
+ setDetailIdx(idx)
2488
+ }
2489
+ const hideDetail = () => {
2490
+ if (detailTimer.current) clearTimeout(detailTimer.current)
2491
+ detailTimer.current = setTimeout(() => setDetailIdx(null), 150)
2492
+ }
2493
+ useEffect(() => () => { if (detailTimer.current) clearTimeout(detailTimer.current) }, [])
2494
+ // 键盘聚焦跟随显示面板。
2495
+ useEffect(() => {
2496
+ if (kbGlobal !== null && entries[kbGlobal]) showDetail(kbGlobal)
2497
+ }, [kbGlobal])
2498
+
2499
+ const rootStyle = pos === null ? { display: 'none' } : {
2500
+ left: pos.left + 'px',
2501
+ top: pos.top + 'px',
2502
+ transform: 'translateY(-50%)',
2503
+ display: hidden ? 'none' : undefined,
2504
+ }
2505
+
2506
+ const detailEntry = detailIdx !== null ? entries[detailIdx] : null
2507
+ // 面板位置:absolute 相对悬浮条容器(容器有 translateY(-50%) transform,fixed
2508
+ // 会退化为相对容器——必须按容器内偏移计算)。条目 = strip 直接子元素,
2509
+ // 索引 = detailIdx - winOffset;越界修正按视口坐标换算回容器内。
2510
+ let detailPos = null
2511
+ const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : null
2512
+ if (detailEntry && rootRect) {
2513
+ const el = stripRef.current ? stripRef.current.children[detailIdx - winOffset] : null
2514
+ if (el instanceof Element) {
2515
+ const r = el.getBoundingClientRect()
2516
+ const topV = Math.min(r.top, Math.max(4, window.innerHeight - 320))
2517
+ detailPos = { left: r.right - rootRect.left + 8, top: topV - rootRect.top }
2518
+ }
2519
+ }
2520
+
2521
+ return h('div', { 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle },
2522
+ h('div', { className: 'pk-head' },
2523
+ h('span', { className: 'pk-headLabel' }, STR.pianoTitle),
2524
+ h('div', { className: 'pk-navGroup' },
2525
+ h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => setWinOffset((o) => Math.max(0, o - 1)), title: STR.expanded }, '▲'),
2526
+ h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1)), title: STR.collapsed }, '▼'),
2527
+ ),
2528
+ ),
2529
+ h('div', { className: 'pk-strip', ref: stripRef, tabIndex: 0, onKeyDown, onMouseDown: (e) => { if (e.target === e.currentTarget) e.preventDefault() }, 'aria-label': STR.pianoTitle },
2530
+ windowed.map((entry, idx) => {
2531
+ const gi = winOffset + idx
2532
+ const act = activeIdx === gi
2533
+ return h('button', {
2534
+ key: 'turn-' + gi + '-' + entry.seq,
2535
+ type: 'button',
2536
+ className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
2537
+ // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
2538
+ // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
2539
+ onClick: (e) => { open(entry); e.currentTarget.blur() },
2540
+ onMouseEnter: () => showDetail(gi),
2541
+ onMouseLeave: hideDetail,
2542
+ onContextMenu: (e) => {
2543
+ e.preventDefault()
2544
+ if (onOpenWorkbench) onOpenWorkbench()
2545
+ },
2546
+ },
2547
+ // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
2548
+ h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
2549
+ h('span', { className: 'pk-label' }, entry.preview || '(空)'),
2550
+ )
2551
+ }),
2552
+ ),
2553
+ // 信息面板:跟随 hover 条目右侧展开(fixed,位置由 detailPos 控制);
2554
+ // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
2555
+ h('div', { className: 'pk-detail' + (detailEntry ? '' : ' hidden'), style: detailPos, onMouseEnter: () => showDetail(detailIdx), onMouseLeave: hideDetail },
2556
+ detailEntry && h('div', { key: 'h' + detailEntry.turn, className: 'pk-detailHead' }, 'T' + detailEntry.turn + ' · ' + STR.user),
2557
+ detailEntry && h('div', { key: 'b' + detailEntry.turn, className: 'pk-detailBody' }, detailEntry.preview || '(空)'),
2558
+ ),
2559
+ // 跳转状态提示:常驻渲染 + hidden 类(从下方淡入/淡出);未加载轮次提示。
2560
+ h('div', { className: 'pk-status' + (jumpFail ? '' : ' hidden') + (jumpFail ? ' warn' : '') },
2561
+ jumpFail ? STR.pianoJumpFail : '',
2562
+ ),
2563
+ )
2564
+ }
2565
+
2566
+ // ── M12:详情并入右侧栏(details 槽位轻量视图)────────────────────
2567
+ // 随会话自动切换(槽位 session 作用域 key 重挂载);数据复用 host get/getTurn;
2568
+ // 回合列表直接复用 TurnList(展开 → TimelineTurns 完整渲染)。
2569
+ function DetailsDockView(props) {
2570
+ const { sessionId, connection, onExit } = props
2571
+ const [state, setState] = useState({ phase: 'loading', error: null, data: null })
2572
+ const [collapsed, setCollapsed] = useState(() => new Set())
2573
+ const collapsedInit = useRef(false)
2574
+ const [turnItems, setTurnItems] = useState(() => new Map())
2575
+ const loadingTurns = useRef(new Set())
2576
+
2577
+ useEffect(() => {
2578
+ let alive = true
2579
+ setState({ phase: 'loading', error: null, data: null })
2580
+ setTurnItems(new Map())
2581
+ collapsedInit.current = false
2582
+ if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
2583
+ api('get', { sessionId }).then((json) => {
2584
+ if (!alive) return
2585
+ if (json && json.ok) {
2586
+ setState({ phase: 'ready', error: null, data: json })
2587
+ } else {
2588
+ setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
2589
+ }
2590
+ }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
2591
+ return () => { alive = false }
2592
+ }, [sessionId])
2593
+
2594
+ useEffect(() => {
2595
+ if (state.phase !== 'ready' || collapsedInit.current) return
2596
+ collapsedInit.current = true
2597
+ const all = new Set()
2598
+ for (const lt of state.data.lightTurns || []) all.add('turn-' + lt.turn)
2599
+ setCollapsed(all)
2600
+ }, [state.phase])
2601
+
2602
+ const toggle = (key) => {
2603
+ setCollapsed((prev) => {
2604
+ const next = new Set(prev)
2605
+ if (next.has(key)) next.delete(key)
2606
+ else next.add(key)
2607
+ return next
2608
+ })
2609
+ }
2610
+ const ensureTurn = async (turnNo) => {
2611
+ if (turnItems.has(turnNo) || loadingTurns.current.has(turnNo)) return
2612
+ loadingTurns.current.add(turnNo)
2613
+ try {
2614
+ const json = await api('getTurn', { sessionId, turn: turnNo })
2615
+ if (json && json.ok && json.turn) {
2616
+ setTurnItems((prev) => new Map(prev).set(turnNo, json.turn))
2617
+ }
2618
+ } catch (e) {
2619
+ console.warn('[dsh-session-flow] getTurn failed', turnNo, e)
2620
+ } finally {
2621
+ loadingTurns.current.delete(turnNo)
2622
+ }
2623
+ }
2624
+
2625
+ const data = state.data
2626
+ const lightTurns = (data && data.lightTurns) || []
2627
+ const toolStats = (data && data.toolStats) || []
2628
+ // 规则摘要(与详情页同口径,精简版)。
2629
+ const ruleTaskCount = lightTurns.filter((lt) => lt.userMessages && lt.userMessages.length > 0).length
2630
+ const ruleGoal = (() => {
2631
+ for (const lt of lightTurns) {
2632
+ if (lt.userMessages && lt.userMessages.length > 0) return String(lt.userMessages[0].preview).slice(0, 150)
2633
+ }
2634
+ return ''
2635
+ })()
2636
+ const ruleConclusion = (() => {
2637
+ for (let i = lightTurns.length - 1; i >= 0; i--) {
2638
+ if (lightTurns[i].conclusionPreview) return String(lightTurns[i].conclusionPreview).slice(0, 220)
2639
+ }
2640
+ return ''
2641
+ })()
2642
+ const ruleTools = toolStats.slice(0, 6).map((t) => t.name + '·' + t.count).join(' ')
2643
+
2644
+ return h('div', { className: 'dk-root' },
2645
+ h('div', { className: 'dk-head' },
2646
+ h('span', { className: 'dk-title' }, STR.dockHint),
2647
+ h('button', { className: 'dk-btn', onClick: onExit }, STR.dockExit),
2648
+ ),
2649
+ h('div', { className: 'dk-body' },
2650
+ state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
2651
+ state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
2652
+ state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
2653
+ state.phase === 'ready' && h('div', { className: 'dk-summary' },
2654
+ ruleTaskCount > 0 && h('div', { className: 'dk-summaryRow' },
2655
+ h('span', { className: 'dk-summaryTag' }, STR.summaryTaskCount),
2656
+ h('span', { className: 'dk-summaryText' }, ruleTaskCount),
2657
+ ),
2658
+ ruleGoal && h('div', { className: 'dk-summaryRow' },
2659
+ h('span', { className: 'dk-summaryTag' }, STR.summaryGoal),
2660
+ h('span', { className: 'dk-summaryText' }, ruleGoal),
2661
+ ),
2662
+ ruleConclusion && h('div', { className: 'dk-summaryRow' },
2663
+ h('span', { className: 'dk-summaryTag' }, STR.summaryConclusion),
2664
+ h('span', { className: 'dk-summaryText' }, ruleConclusion),
2665
+ ),
2666
+ ruleTools && h('div', { className: 'dk-summaryRow' },
2667
+ h('span', { className: 'dk-summaryTag' }, STR.summaryTools),
2668
+ h('span', { className: 'dk-summaryText' }, ruleTools),
2669
+ ),
2670
+ data.summary && h('div', { className: 'dk-summaryFull' }, data.summary),
2671
+ ),
2672
+ state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2673
+ state.phase === 'ready' && h(TurnList, {
2674
+ sessionId,
2675
+ lightTurns,
2676
+ collapsed,
2677
+ toggle,
2678
+ ensureTurn,
2679
+ turnItems,
2680
+ }),
2681
+ ),
2682
+ )
2683
+ }
2684
+
2685
+ // ── M11:钢琴键竖条挂载(DOM 注入 + 自愈)────────────────────────
2686
+ // 容器挂 document.body(dsh-navbar 同款模式):完全脱离会话区布局树,
2687
+ // 不干扰 conversation 的 flex/grid 布局;fixed 定位由组件自管理。
2688
+ // MutationObserver 自愈:容器丢失(布局重建)时自动恢复。
2689
+ function mountPianoKeys(controller, sessions, connection) {
2690
+ let root = undefined
2691
+ let container = undefined
2692
+ let waitObserver = null
2693
+
2694
+ // M11×视图:轮次条只在「对话」tab(ChatView)激活时显示——官方 viewArea
2695
+ // 只渲染激活视图(only: active.id),轨迹/Plan图/会话流 tab 下 ChatView 卸载,
2696
+ // `[data-chat-flow]` 随之消失(会话流标签页有自己的 sf-view,不冲突)。
2697
+ const syncChatVisibility = () => {
2698
+ if (container === undefined) return
2699
+ const chatFlow = document.querySelector('[data-chat-flow]')
2700
+ container.style.display = chatFlow !== null ? '' : 'none'
2701
+ }
2702
+
2703
+ const ensure = () => {
2704
+ if (container !== undefined) {
2705
+ if (container.isConnected) return
2706
+ if (root) { try { root.unmount() } catch (e) {} root = undefined }
2707
+ container = undefined
2708
+ }
2709
+ container = document.createElement('div')
2710
+ document.body.appendChild(container)
2711
+ try {
2712
+ root = createRoot(container)
2713
+ root.render(h(TurnKeys, {
2714
+ sessions,
2715
+ connection,
2716
+ onOpenWorkbench: () => controller.open(),
2717
+ }))
2718
+ syncChatVisibility()
2719
+ } catch (e) {
2720
+ console.error('[dsh-session-flow:piano] render failed:', e)
2721
+ if (root) { try { root.unmount() } catch (e2) {} root = undefined }
2722
+ if (container) { container.remove(); container = undefined }
2723
+ }
2724
+ }
2725
+
2726
+ waitObserver = new MutationObserver(() => { ensure(); syncChatVisibility() })
2727
+ waitObserver.observe(document.body, { childList: true, subtree: true })
2728
+ ensure()
2729
+
2730
+ return () => {
2731
+ waitObserver.disconnect()
2732
+ if (root) { try { root.unmount() } catch (e) {} root = undefined }
2733
+ if (container) { container.remove(); container = undefined }
2734
+ }
2735
+ }
2736
+
1674
2737
  // ── 侧边栏入口(DOM 级注入 + 自愈)────────────────────────────────
1675
2738
  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
2739
  function sidebarRoot() {
1678
2740
  const column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
1679
2741
  if (column === null) return undefined
@@ -1818,7 +2880,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1818
2880
  }
1819
2881
 
1820
2882
  // ── 插件体 ─────────────────────────────────────────────────────────
1821
- const inject = ['sessions', 'connection']
2883
+ const inject = ['sessions', 'connection', 'slots', 'layout']
1822
2884
 
1823
2885
  function apply(ctx) {
1824
2886
  try {
@@ -1850,6 +2912,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1850
2912
  for (const fn of state.listeners) fn()
1851
2913
  }
1852
2914
  },
2915
+ open: () => {
2916
+ // 二期:嵌入标签页「打开完整工作台」→ 唤起侧边栏工作台。
2917
+ if (!state.open) {
2918
+ state.open = true
2919
+ for (const fn of state.listeners) fn()
2920
+ }
2921
+ },
1853
2922
  }
1854
2923
 
1855
2924
  const onActivate = (event) => {
@@ -1857,6 +2926,166 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1857
2926
  }
1858
2927
  document.addEventListener(ACTIVATE_EVENT, onActivate)
1859
2928
 
2929
+ // 二期:嵌入原生会话页 —— conversation.view 槽位环(会话作用域标签页)。
2930
+ // 参照 ui-trajectory(order 10) / plan-graph(order 20) 的注册模式:
2931
+ // slots.inject 自带生命周期(插件卸载自动移除标签页),无需手动清理。
2932
+ // label 支持字符串或惰性求值函数;order 越大越靠后(对话/轨迹/Plan图 之后)。
2933
+ const slots = ctx.get('slots')
2934
+ if (slots !== undefined && typeof slots.inject === 'function' && typeof slots.register === 'function') {
2935
+ try {
2936
+ slots.inject('conversation.view', () => slots.register({
2937
+ name: 'conversation.view',
2938
+ id: 'session-flow',
2939
+ order: 30,
2940
+ label: STR.entry,
2941
+ }, (props) => h(SessionFlowTab, {
2942
+ sessionId: props ? props.sessionId : undefined,
2943
+ sessions,
2944
+ connection,
2945
+ onOpenWorkbench: () => controller.open(),
2946
+ })))
2947
+ } catch (error) {
2948
+ console.error('[dsh-session-flow] conversation.view registration failed:', error)
2949
+ }
2950
+ }
2951
+
2952
+ // M12:详情并入右侧栏 —— details 槽位动态占用(priority -1 低于官方 tool-details)。
2953
+ // 并入开启时注册 + **打开官方 details 列(layout.openDetails——列默认宽度 0 不可见,
2954
+ // plan-graph 同款;曾漏开列导致「无响应」,实测踩坑)**;退出/卸载注销 + closeDetails。
2955
+ // 官方在会话切换时自动 closeDetails(无法阻止)——并入期间:
2956
+ // ① html 打 data-dsh-dock-active 标记 → CSS 禁用 grid 列宽过渡(切换无「关→开」动画);
2957
+ // ② 高频守护(150ms)快速重开列 → 视觉上右栏内容直接刷新为新会话。
2958
+ const layout = ctx.get('layout')
2959
+ let detailsRegistration = null
2960
+ let detailsGuard = null
2961
+ const setDockActive = (on) => {
2962
+ try {
2963
+ if (on) document.documentElement.setAttribute('data-dsh-dock-active', '')
2964
+ else document.documentElement.removeAttribute('data-dsh-dock-active')
2965
+ } catch (e) {}
2966
+ }
2967
+
2968
+ // M12×aionui 兼容层:官方 details 把手按 3 轨模型定位(left = frameWidth - details,
2969
+ // 假设 details 是最后一轨);aionui-panel 把 frame grid 扩为 5 轨
2970
+ // (sidebar|center|details|preview|explorer)后,真实边界左移 preview+explorer px,
2971
+ // 把手却停在旧位置(悬浮于预览列上方,见 docs/02-design/LAYOUT-COEXIST.md)。
2972
+ // dock 激活期间按实际轨宽重算把手位置:三方写 frame style → MutationObserver;
2973
+ // 纯缩放(grid 字符串不变)→ ResizeObserver。3 轨时公式退化为官方原值,无副作用。
2974
+ const parseGridTracksCompat = (input) => {
2975
+ const tracks = []
2976
+ let depth = 0
2977
+ let current = ''
2978
+ for (const ch of String(input)) {
2979
+ if (ch === '(') depth += 1
2980
+ if (ch === ')') depth = Math.max(0, depth - 1)
2981
+ if (ch === ' ' && depth === 0) {
2982
+ if (current !== '') { tracks.push(current); current = '' }
2983
+ continue
2984
+ }
2985
+ current += ch
2986
+ }
2987
+ if (current !== '') tracks.push(current)
2988
+ return tracks
2989
+ }
2990
+ const detailsHandleCompat = {
2991
+ frame: null,
2992
+ observer: null,
2993
+ sizeObserver: null,
2994
+ start() {
2995
+ if (this.observer !== null) return
2996
+ const frame = document.querySelector('[data-dsh-frame]') ||
2997
+ (document.querySelector('[class*="sidebarCol"]') || {}).parentElement || null
2998
+ if (frame === null) return
2999
+ this.frame = frame
3000
+ this.observer = new MutationObserver(() => this.apply())
3001
+ this.observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
3002
+ this.sizeObserver = new ResizeObserver(() => this.apply())
3003
+ this.sizeObserver.observe(frame)
3004
+ this.apply()
3005
+ },
3006
+ stop() {
3007
+ if (this.observer !== null) { this.observer.disconnect(); this.observer = null }
3008
+ if (this.sizeObserver !== null) { this.sizeObserver.disconnect(); this.sizeObserver = null }
3009
+ this.frame = null
3010
+ },
3011
+ apply() {
3012
+ const frame = this.frame
3013
+ if (frame === null) return
3014
+ const tracks = parseGridTracksCompat(frame.style.gridTemplateColumns)
3015
+ if (tracks.length < 3) return
3016
+ const trackPx = (t) => {
3017
+ const m = /^(-?[\d.]+)px$/.exec(String(t).trim())
3018
+ return m === null ? 0 : Number(m[1])
3019
+ }
3020
+ const details = trackPx(tracks[2])
3021
+ const preview = tracks.length >= 4 ? trackPx(tracks[3]) : 0
3022
+ const explorer = tracks.length >= 5 ? trackPx(tracks[4]) : 0
3023
+ const handle = frame.querySelector('[data-side="details"]')
3024
+ if (handle === null) return
3025
+ handle.style.left = `${Math.round(frame.getBoundingClientRect().width - details - preview - explorer)}px`
3026
+ },
3027
+ }
3028
+ const ensureColumn = () => {
3029
+ if (dockBridge.open && layout !== undefined && typeof layout.openDetails === 'function') {
3030
+ try { layout.openDetails() } catch (e) {}
3031
+ }
3032
+ }
3033
+ const syncDetails = () => {
3034
+ if (dockBridge.open && detailsRegistration === null && slots !== undefined && typeof slots.inject === 'function') {
3035
+ try {
3036
+ detailsRegistration = slots.inject('details', () => slots.register({
3037
+ name: 'details',
3038
+ priority: -1,
3039
+ }, (props) => h(DetailsDockView, {
3040
+ sessionId: props ? props.sessionId : undefined,
3041
+ connection,
3042
+ onExit: () => dockBridge.controller.close(),
3043
+ })))
3044
+ setDockActive(true)
3045
+ ensureColumn()
3046
+ } catch (error) {
3047
+ console.error('[dsh-session-flow] details dock registration failed:', error)
3048
+ detailsRegistration = null
3049
+ }
3050
+ } else if (!dockBridge.open && detailsRegistration !== null) {
3051
+ try { detailsRegistration() } catch (e) {}
3052
+ detailsRegistration = null
3053
+ setDockActive(false)
3054
+ if (layout !== undefined && typeof layout.closeDetails === 'function') {
3055
+ try { layout.closeDetails() } catch (e) {}
3056
+ }
3057
+ }
3058
+ }
3059
+ dockBridge.controller = {
3060
+ getSnapshot: () => ({ open: dockBridge.open }),
3061
+ subscribe: (fn) => {
3062
+ dockBridge.listeners.add(fn)
3063
+ return () => dockBridge.listeners.delete(fn)
3064
+ },
3065
+ open: () => {
3066
+ if (!dockBridge.open) {
3067
+ dockBridge.open = true
3068
+ syncDetails()
3069
+ detailsHandleCompat.start()
3070
+ // 会话切换时官方会自动 closeDetails —— 高频守护快速重开(无动画,内容直接刷新)。
3071
+ if (detailsGuard === null) {
3072
+ detailsGuard = setInterval(() => { ensureColumn() }, 150)
3073
+ }
3074
+ for (const fn of dockBridge.listeners) fn()
3075
+ }
3076
+ },
3077
+ close: () => {
3078
+ if (dockBridge.open) {
3079
+ dockBridge.open = false
3080
+ syncDetails()
3081
+ detailsHandleCompat.stop()
3082
+ if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3083
+ for (const fn of dockBridge.listeners) fn()
3084
+ }
3085
+ },
3086
+ }
3087
+ syncDetails()
3088
+
1860
3089
  // 点击侧边栏会话/工作区行 → 交还中栏给会话。
1861
3090
  const onClickSidebarRow = (event) => {
1862
3091
  if (!state.open) return
@@ -1889,11 +3118,25 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1889
3118
  try {
1890
3119
  disposers.push(mountSidebarEntry(controller))
1891
3120
  disposers.push(mountBoard(controller, sessions, connection))
3121
+ // M11:钢琴键会话快切(会话页左侧常驻竖条);挂载失败不影响其他功能。
3122
+ disposers.push(mountPianoKeys(controller, sessions, connection))
1892
3123
  } catch (error) {
1893
3124
  console.error('[dsh-session-flow] mount failed:', error)
1894
3125
  }
1895
3126
 
1896
3127
  const teardown = () => {
3128
+ // M12:卸载时注销 details 槽位占用(恢复官方 tool-details)+ 关列 + 清守护 + 移除标记。
3129
+ if (detailsRegistration) {
3130
+ try { detailsRegistration() } catch (e) {}
3131
+ detailsRegistration = null
3132
+ }
3133
+ if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3134
+ setDockActive(false)
3135
+ detailsHandleCompat.stop()
3136
+ if (layout !== undefined && typeof layout.closeDetails === 'function') {
3137
+ try { layout.closeDetails() } catch (e) {}
3138
+ }
3139
+ dockBridge.open = false
1897
3140
  document.removeEventListener(ACTIVATE_EVENT, onActivate)
1898
3141
  document.removeEventListener('click', onClickSidebarRow, true)
1899
3142
  document.removeEventListener('scroll', onAnyScroll, true)