dsh-session-flow 1.2.0 → 1.4.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/archive.js CHANGED
@@ -1,7 +1,10 @@
1
1
  // lib/archive.js — 会话存档核心:扫描 / 解码 / 解析 / 统计。
2
2
  //
3
- // 数据来源:~/.dsh/sessions/<workspace>/session-<uuid>/session.jsonl.zstd
3
+ // 数据来源:~/.dsh/sessions/<workspace>/session-<uuid>/session[.v2].jsonl.zstd
4
4
  // (zstd 压缩的多帧 JSONL;compression: none 时为明文 session.jsonl)。
5
+ // dsh v0.1.3 起存档升级 v2(session.v2.jsonl.zstd,行信封 {type,seq,time,data} 不变,
6
+ // header version:2 + 新增 session/end-seed 等事件):v1 文件冻结在迁移时刻、v2 持续
7
+ // 写入——读取必须优先 v2,否则读到冻结旧数据、纯 v2 新会话不可见(0.1.3 适配)。
5
8
  //
6
9
  // 解码使用 Node 内置 node:zlib 的 zstd 支持(Node >= 22.19),零第三方依赖;
7
10
  // 帧扫描算法借鉴自 @deepseek-ai/dsh-session-persistence-jsonl (MIT)。
@@ -246,8 +249,12 @@ export function summarizeParsed({ header, title, events }) {
246
249
  }
247
250
  }
248
251
 
249
- /** 定位一个会话目录里的存档文件(优先 zstd,回退明文)。 */
252
+ /** 定位一个会话目录里的存档文件(优先 v2,回退 v1 zstd,再回退明文)。
253
+ * dsh v0.1.3 起 v2 存档与 v1 并存:v1 冻结在迁移时刻、v2 持续写入——
254
+ * 必须优先 v2,否则老会话读到冻结数据、纯 v2 新会话直接漏掉(0.1.3 适配)。 */
250
255
  export function sessionFileOf(sessionDir) {
256
+ const v2 = join(sessionDir, 'session.v2.jsonl.zstd')
257
+ if (existsSync(v2)) return v2
251
258
  const zstd = join(sessionDir, 'session.jsonl.zstd')
252
259
  if (existsSync(zstd)) return zstd
253
260
  const plain = join(sessionDir, 'session.jsonl')
package/lib/client.js CHANGED
@@ -24,7 +24,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
24
24
  const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
25
25
  const ACTIVATE_EVENT = 'dsh-panel-activate'
26
26
  const PANEL_NAME = 'sessionflow'
27
- const CONVERSATION_SELECTOR = '[data-pane="conversation"]'
27
+ // 会话面板容器选择器:dsh v0.1.2 起官方移除 data-pane="conversation"(会话流
28
+ // 重构进 dsh-client-ui-chat),兜底命中布局中栏(CenterColumn,类名含 centerCol
29
+ // 哈希后缀稳定片段)。工作台/标签切换/钢琴键定位共用此锚。
30
+ const CONVERSATION_SELECTOR = '[data-pane="conversation"], [class*=centerCol]'
28
31
  const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
29
32
  const ACTIVE_WINDOW_MS = 15 * 60 * 1000 // 最近 15 分钟有事件 → 视为进行中(归档/总览徽标用)
30
33
 
@@ -44,7 +47,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
44
47
  dockPinNotice: '📌 直播已钉住原会话(当前已切换会话)', dockPinRebind: '转播当前会话',
45
48
  rename: '重命名', renamePlaceholder: '输入新标题…', renameFail: '重命名失败', origTitle: '原名',
46
49
  search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
47
- sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
50
+ sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长', sortSize: '占用最大',
48
51
  noMatch: '没有匹配的会话', noData: '暂无会话数据',
49
52
  running: '进行中', ended: '已结束', subagent: '子代理',
50
53
  turns: '回合', steps: '步骤', tools: '工具', errors: '错误', msgs: '消息',
@@ -65,7 +68,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
65
68
  emptyHint: '此会话已创建但尚未开始对话', userNav: '用户发言', noUserNav: '(无用户发言)',
66
69
  conclusion: '结论', injected: '注入', lineage: '血缘', noLineage: '(无子代理血缘信息)', task: '任务', liveTree: '运行时血缘', offlineTree: '档案血缘', modeOneShot: '一次性', modeContinuable: '可续',
67
70
  subagentDetail: '子代理详情', loadDetail: '加载子代理详情…', noDetail: '该子代理无可见事件',
68
- viewSubagent: '查看子代理', toolTop: '工具 Top', issues: '问题会话', issuesHint: '只显示有错误记录的会话',
71
+ viewSubagent: '查看子代理', toolTop: '工具 Top', issues: '问题会话', issuesHint: '只显示有错误记录的会话', storage: '存储', storageHint: '当前范围会话存档总占用(只读)',
69
72
  searchTab: '检索', searchInPlaceholder: '会话内检索…', noMatches: '无匹配位置', matches: '命中', navUsers: '用户',
70
73
  cache: '缓存管理', cacheTotal: '缓存总量', cacheIndex: '会话索引', cacheTimeline: '时间线缓存',
71
74
  cleanTimeline: '清理时间线缓存', cleanAll: '清理全部缓存', cacheDone: '已清理', cacheEmpty: '(无缓存文件)',
@@ -74,12 +77,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
74
77
  summaryStale: '⚠ 摘要生成后有新对话,内容可能已不准确,建议重新生成',
75
78
  llmSummary: '生成 LLM 摘要', llmGenerating: '生成中…', llmFail: '生成失败', summaryRuleTag: '规则', summaryLlmTag: 'LLM',
76
79
  cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
77
- healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死',
80
+ healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死', healthIdle: '空闲',
81
+ agoJustNow: '刚刚', agoMin: '{N} 分钟前', agoHour: '{N} 小时前', agoDay: '{N} 天前',
78
82
  healthSilentMin: '静默 {N} 分钟', healthSilentSec: '静默 {N} 秒',
79
83
  healthChipTitle: '会话健康状态(会话流)· 点击打开详情',
80
84
  renameClearBlocked: '该会话标题由官方会话管理:请输入新标题(不支持清空)',
81
- settingsTitle: '会话流', settingsDesc: '调整轮次悬浮条与实时跟踪的行为参数。保存后立即生效(轮次悬浮条行数在重进会话页后完全生效)。',
82
- setGroupPiano: '轮次悬浮条', setGroupLive: '实时跟踪', setGroupHealth: '健康监控',
85
+ settingsTitle: '会话流', settingsDesc: '调整轮次导航、实时跟踪与健康监控的行为参数。保存后立即生效(轮次悬浮条行数在重进会话页后完全生效)。',
86
+ setGroupPiano: '轮次悬浮条', setGroupLive: '实时跟踪', setGroupHealth: '健康监控', setGroupNav: '轮次导航(v0.1.2+)',
83
87
  setPianoWindow: '可见行数', setPianoWindowHint: '悬浮条同时显示的轮次条数(6–18)',
84
88
  setPianoWheelSpeed: '滚轮灵敏度', setPianoWheelSpeedHint: '每像素滚动的行数(0.005–0.03),越大滚得越快',
85
89
  setPianoSnapMs: '静止吸附延迟(ms)', setPianoSnapMsHint: '滚轮停止多少毫秒后吸附到整行(100–400)',
@@ -87,6 +91,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
87
91
  setLiveFollowPx: '吸底阈值(px)', setLiveFollowPxHint: '距底部多少像素内视为「在底部」并跟随滚动(20–120)',
88
92
  setLiveHistoryTurns: '保留历史回合', setLiveHistoryTurnsHint: '实时模式下保留的历史回合数(0–10),下次开播生效',
89
93
  setStallThresholdMin: '卡死阈值(分钟)', setStallThresholdMinHint: '运行中但静默超过该时长判定为疑似卡死(1–10)',
94
+ setRailEnhance: '官方轮次导航增强', setRailEnhanceHint: '在官方右侧轮次导航上叠加:预览卡内追加结论摘要、右键唤起工作台',
95
+ setPianoClassic: '经典左侧悬浮条', setPianoClassicHint: '插件自带的左侧轮次悬浮条(默认开,与官方右侧导航并存;点击未载入轮次可委托官方引擎自动加载跳转)',
96
+ setRailHide: '隐藏官方轮次导航', setRailHideHint: '完全屏蔽官方右侧刻度条,只保留我们的悬浮条(开启后「官方轮次导航增强」自动不生效)',
97
+ railMetaCalls: '{tools} 次工具', railMetaErrors: '{errors} 错误', railThinking: '含思考',
90
98
  setSave: '保存', setDiscard: '放弃更改', setSaveFail: '部分设置未保存成功,请检查后重试',
91
99
  setReset: '恢复默认', setOverridden: '已修改', setInvalid: '超出范围或格式无效',
92
100
  setUnavailable: '当前环境未暴露设置服务,无法编辑(请升级 dsh 后重试)',
@@ -100,7 +108,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
100
108
  dockPinNotice: '📌 Live pinned to previous session (session switched)', dockPinRebind: 'Follow current session',
101
109
  rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
102
110
  search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
103
- sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
111
+ sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest', sortSize: 'Largest',
104
112
  noMatch: 'No matching sessions', noData: 'No session data yet',
105
113
  running: 'Running', ended: 'Ended', subagent: 'subagent',
106
114
  turns: 'turns', steps: 'steps', tools: 'tools', errors: 'errors', msgs: 'msgs',
@@ -122,7 +130,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
122
130
  conclusion: 'Conclusion', injected: 'Injected', lineage: 'Lineage', noLineage: '(no subagent lineage)', task: 'Task',
123
131
  liveTree: 'Live lineage', offlineTree: 'Archive lineage', modeOneShot: 'one-shot', modeContinuable: 'continuable',
124
132
  subagentDetail: 'Subagent detail', loadDetail: 'Loading subagent detail…', noDetail: 'No visible events for this subagent',
125
- viewSubagent: 'View subagent', toolTop: 'Tool Top', issues: 'Problem sessions', issuesHint: 'Only sessions with errors',
133
+ viewSubagent: 'View subagent', toolTop: 'Tool Top', issues: 'Problem sessions', issuesHint: 'Only sessions with errors', storage: 'Storage', storageHint: 'Total archive size of sessions in scope (read-only)',
126
134
  searchTab: 'Search', searchInPlaceholder: 'Search in session…', noMatches: 'No matches', matches: 'matches', navUsers: 'User',
127
135
  cache: 'Cache Mgmt', cacheTotal: 'Cache total', cacheIndex: 'Index', cacheTimeline: 'Timeline cache',
128
136
  cleanTimeline: 'Clean timeline cache', cleanAll: 'Clean all cache', cacheDone: 'Cleaned', cacheEmpty: '(no cache files)',
@@ -131,12 +139,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
131
139
  summaryStale: '⚠ New conversation since summary was generated; content may be outdated — regenerate',
132
140
  llmSummary: 'Generate LLM summary', llmGenerating: 'Generating…', llmFail: 'Failed', summaryRuleTag: 'rule', summaryLlmTag: 'LLM',
133
141
  cacheHint: "Only this plugin's cache (auto-rebuilds); never touches DSH session archives or other data", cacheLimit: 'Timeline cache limit',
134
- healthActive: 'Active', healthToolWait: 'Tool running', healthQuiet: 'Quiet', healthStalled: 'Likely stalled',
142
+ healthActive: 'Active', healthToolWait: 'Tool running', healthQuiet: 'Quiet', healthStalled: 'Likely stalled', healthIdle: 'Idle',
143
+ agoJustNow: 'just now', agoMin: '{N}m ago', agoHour: '{N}h ago', agoDay: '{N}d ago',
135
144
  healthSilentMin: 'silent {N} min', healthSilentSec: 'silent {N} s',
136
145
  healthChipTitle: 'Session health (Session Flow) · click to open detail',
137
146
  renameClearBlocked: 'Title is managed by the official session: enter a new title (clearing unsupported)',
138
- settingsTitle: 'Session Flow', settingsDesc: 'Tune the piano-keys strip and live-tracking behavior. Applies on save (strip row count fully applies after re-entering a session page).',
139
- setGroupPiano: 'Turn strip', setGroupLive: 'Live tracking', setGroupHealth: 'Health monitor',
147
+ settingsTitle: 'Session Flow', settingsDesc: 'Tune turn navigation, live-tracking, and health-monitor behavior. Applies on save (strip row count fully applies after re-entering a session page).',
148
+ setGroupPiano: 'Turn strip', setGroupLive: 'Live tracking', setGroupHealth: 'Health monitor', setGroupNav: 'Turn navigation (v0.1.2+)',
140
149
  setPianoWindow: 'Visible rows', setPianoWindowHint: 'How many turn keys the strip shows at once (6–18)',
141
150
  setPianoWheelSpeed: 'Wheel speed', setPianoWheelSpeedHint: 'Rows scrolled per pixel (0.005–0.03); larger scrolls faster',
142
151
  setPianoSnapMs: 'Snap delay (ms)', setPianoSnapMsHint: 'Delay after the wheel stops before snapping to a whole row (100–400)',
@@ -144,6 +153,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
144
153
  setLiveFollowPx: 'Follow threshold (px)', setLiveFollowPxHint: 'Distance from the bottom that still counts as "at bottom" and follows (20–120)',
145
154
  setLiveHistoryTurns: 'History turns kept', setLiveHistoryTurnsHint: 'History turns kept in live mode (0–10); applies to the next live session',
146
155
  setStallThresholdMin: 'Stall threshold (min)', setStallThresholdMinHint: 'Running but silent beyond this counts as likely stalled (1–10)',
156
+ setRailEnhance: 'Enhance official turn navigator', setRailEnhanceHint: 'Overlay on the official right-side rail: conclusion section inside its preview card, right-click to open the workbench',
157
+ setPianoClassic: 'Classic left strip', setPianoClassicHint: 'The plugin\'s own left turn strip (on by default, coexists with the official right rail; clicking an unloaded turn delegates to the official auto-load navigation)',
158
+ setRailHide: 'Hide official turn navigator', setRailHideHint: 'Fully hide the official right-side rail and keep only our strip (the rail enhancement is inert while this is on)',
159
+ railMetaCalls: '{tools} tools', railMetaErrors: '{errors} errors', railThinking: 'thinking',
147
160
  setSave: 'Save', setDiscard: 'Discard', setSaveFail: 'Some settings were not saved — check and retry',
148
161
  setReset: 'Reset', setOverridden: 'Modified', setInvalid: 'Out of range or invalid format',
149
162
  setUnavailable: 'The settings service is not exposed in this environment (upgrade dsh and retry)',
@@ -153,12 +166,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
153
166
  // Host 注册 settings 命名空间 'session-flow'(lib/host.js SETTINGS_DEFAULTS 同源);
154
167
  // client 经 settingsScope 读写(pet 同款:优先 webUiSettings 桥,回退 ctx.settingsScope)。
155
168
  // 全部消费点读 sfSettings.current.xxx,缺省 = 原硬编码值——无设置时行为零变化。
156
- const SETTINGS_DEFAULTS = { pianoWindow: 12, pianoWheelSpeed: 0.012, pianoSnapMs: 170, livePollMs: 3000, liveFollowPx: 40, liveHistoryTurns: 3, stallThresholdMin: 3 }
169
+ const SETTINGS_DEFAULTS = { pianoWindow: 12, pianoWheelSpeed: 0.012, pianoSnapMs: 170, livePollMs: 3000, liveFollowPx: 40, liveHistoryTurns: 3, stallThresholdMin: 3, railEnhance: true, railHideOfficial: false, pianoClassicStrip: true }
157
170
  const sfSettings = { current: { ...SETTINGS_DEFAULTS } }
158
171
  const SETTING_GROUPS = [
159
172
  { titleKey: 'setGroupPiano', fields: ['pianoWindow', 'pianoWheelSpeed', 'pianoSnapMs'] },
160
173
  { titleKey: 'setGroupLive', fields: ['livePollMs', 'liveFollowPx', 'liveHistoryTurns'] },
161
174
  { titleKey: 'setGroupHealth', fields: ['stallThresholdMin'] },
175
+ { titleKey: 'setGroupNav', fields: ['railEnhance', 'railHideOfficial', 'pianoClassicStrip'] },
162
176
  ]
163
177
  const SETTING_FIELD_SPECS = {
164
178
  pianoWindow: { min: 6, max: 18, integer: true, labelKey: 'setPianoWindow', hintKey: 'setPianoWindowHint' },
@@ -168,9 +182,16 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
168
182
  liveFollowPx: { min: 20, max: 120, integer: true, labelKey: 'setLiveFollowPx', hintKey: 'setLiveFollowPxHint' },
169
183
  liveHistoryTurns: { min: 0, max: 10, integer: true, labelKey: 'setLiveHistoryTurns', hintKey: 'setLiveHistoryTurnsHint' },
170
184
  stallThresholdMin: { min: 1, max: 10, integer: true, labelKey: 'setStallThresholdMin', hintKey: 'setStallThresholdMinHint' },
185
+ railEnhance: { kind: 'bool', labelKey: 'setRailEnhance', hintKey: 'setRailEnhanceHint' },
186
+ pianoClassicStrip: { kind: 'bool', labelKey: 'setPianoClassic', hintKey: 'setPianoClassicHint' },
187
+ railHideOfficial: { kind: 'bool', labelKey: 'setRailHide', hintKey: 'setRailHideHint' },
171
188
  }
172
189
  function formatSettingValue(v) { return v === undefined || v === null ? '' : String(v) }
173
190
  function parseSettingValue(spec, text) {
191
+ if (spec.kind === 'bool') {
192
+ const t = String(text).trim()
193
+ return t === 'true' ? true : t === 'false' ? false : undefined
194
+ }
174
195
  const n = Number(String(text).trim())
175
196
  if (!Number.isFinite(n) || n < spec.min || n > spec.max) return undefined
176
197
  if (spec.integer && !Number.isInteger(n)) return undefined
@@ -298,12 +319,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
298
319
  field.overridden ? h('span', { className: 'sfset-overridden' }, STR.setOverridden) : null),
299
320
  h('div', { className: 'sfset-hint' }, STR[spec.hintKey])),
300
321
  h('div', { className: 'sfset-controls' },
301
- h('input', {
302
- className: 'sfset-input', type: 'number',
303
- min: spec.min, max: spec.max, step: spec.integer ? 1 : 0.001,
304
- value: field.text, disabled,
305
- onChange: (e) => controller.edit(key, e.target.value),
306
- }),
322
+ spec.kind === 'bool'
323
+ ? h('input', {
324
+ className: 'sfset-check', type: 'checkbox',
325
+ checked: field.text === 'true', disabled,
326
+ onChange: (e) => controller.edit(key, String(e.target.checked)),
327
+ })
328
+ : h('input', {
329
+ className: 'sfset-input', type: 'number',
330
+ min: spec.min, max: spec.max, step: spec.integer ? 1 : 0.001,
331
+ value: field.text, disabled,
332
+ onChange: (e) => controller.edit(key, e.target.value),
333
+ }),
307
334
  h('button', { type: 'button', className: 'sfset-reset', disabled, onClick: () => controller.resetField(key) }, STR.setReset))))
308
335
  }
309
336
  }
@@ -340,6 +367,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
340
367
  `.sfset-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}`,
341
368
  `.sfset-discard:disabled{opacity:.4;cursor:default}`,
342
369
  `.sfset-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}`,
370
+ `.sfset-check{width:16px;height:16px;accent-color:var(--dsw-alias-label-primary);cursor:pointer}`,
343
371
  `.sfset-unavailable{font-size:13px;line-height:1.5;color:var(--dsw-alias-state-warn-primary)}`,
344
372
  ].join('\n')
345
373
 
@@ -358,6 +386,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
358
386
  // 这样 top 栏(flex:none + sticky)天然固定,不会随内容滚出视口。
359
387
  const STYLE = [
360
388
  `[data-pane=conversation]{position:relative}`,
389
+ `[class*=centerCol]{position:relative}`,
390
+ `.sf-rail-conclusion{margin-top:8px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.25));font-size:12px;line-height:1.5;color:var(--dsw-alias-label-primary,#222)}`,
391
+ `.sf-turnMeta{display:flex;flex-wrap:wrap;gap:3px 10px;font-size:11px;font-weight:600;color:var(--dsw-alias-label-secondary,#555)}`,
392
+ `.sf-turnMetaChip{display:inline-flex;align-items:center;gap:3px}`,
393
+ `.sf-turnMetaChip.err{color:var(--dsw-alias-label-error,#d43b3b)}`,
394
+ `.sf-ico{display:inline-flex}`,
395
+ `.sf-rail-conclusionTools{margin-top:3px;font-size:11px;color:var(--dsw-alias-label-tertiary,#999);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}`,
361
396
  `${VIEW_SELECTOR}{z-index:60;background:var(--dsw-alias-bg-base,#fff);display:none!important;position:absolute;inset:0;overflow:hidden}`,
362
397
  `html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) ${VIEW_SELECTOR}{display:block!important}`,
363
398
  `html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane=conversation]>:not(${VIEW_SELECTOR}),html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*=centerCol]>:not(${VIEW_SELECTOR}){display:none!important}`,
@@ -438,6 +473,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
438
473
  `.sf-healthChip.hc-wait .sf-hcLabel{color:#a87b00}`,
439
474
  `.sf-healthChip.hc-stall .sf-hcBar{background:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
440
475
  `.sf-healthChip.hc-stall .sf-hcLabel{color:#d43b3b}`,
476
+ `.sf-healthChip.hc-idle .sf-hcBar{background:var(--dsw-alias-label-tertiary,#b8bcc4);opacity:.55}`,
477
+ `.sf-healthChip.hc-idle .sf-hcLabel{color:var(--dsw-alias-label-tertiary,#999)}`,
478
+ `.sf-healthChip.hc-idle:hover,.sf-healthChip.hc-idle:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(128,128,128,.12));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#9aa0a8)}`,
441
479
  `.sf-statsRow{display:flex;gap:12px;flex-wrap:wrap;margin-top:3px}`,
442
480
  `.sf-hint{color:var(--dsw-alias-label-secondary,#888);font-size:12px;padding:2px 2px 0}`,
443
481
  // ── 工作区筛选 tabs(独立一行,醒目)──
@@ -703,6 +741,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
703
741
  `.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}`,
704
742
  `.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}`,
705
743
  `.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}`,
744
+ `.pk-detailMeta{flex:none;margin:3px 0 2px}`,
706
745
  // 面板内容随条目切换:新内容从右轻滑入(key 变化重新挂载触发 animation)。
707
746
  `.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}`,
708
747
  `@keyframes pkSlideIn{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}`,
@@ -875,6 +914,79 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
875
914
  }
876
915
  const canOfficialRename = (conn) =>
877
916
  conn !== undefined && conn.api !== undefined && conn.api.sessions !== undefined && typeof conn.api.sessions.rename === 'function'
917
+ // ── v0.1.2 兼容(三):session RPC 双面适配器 ─────────────────────
918
+ // 新面:ctx.remote.session.*(inject 需声明 remote/remote.session,否则代理抛
919
+ // 「cannot get property remote.session without inject」);信封顶层 {ok,value};
920
+ // history 改名 page({address, maxMessages,...}) → {records, hasMore};rename/list
921
+ // 仍在。旧面:connection.api.sessions.*({result:{ok,value}};subagents.history)。
922
+ // apply 时探测(ctx.get('remote') 旧宿主抛错 → 回退旧面)。
923
+ const sfRemoteRef = { current: null }
924
+ const sfBuildRemote = (ctx, connection) => {
925
+ try {
926
+ const remote = ctx && typeof ctx.get === 'function' ? ctx.get('remote') : null
927
+ const sess = remote && remote.session
928
+ if (sess && typeof sess.page === 'function') {
929
+ // 宿主硬校验:throughSeq > 日志 cursor 直接 gateway/bad-request——不存在
930
+ // 「取最新」的大数哨兵。cursor 来源 = list items[].projections.asOfSeq
931
+ //(投影截至 seq,恒 ≤ 真实日志 cursor,安全)。2s TTL 缓存防探测并发打爆 list。
932
+ let listCache = null
933
+ const cursorOf = async (sessionId) => {
934
+ const now = Date.now()
935
+ if (listCache === null || now - listCache.at > 2000) {
936
+ const r = await sess.list({})
937
+ listCache = { at: now, items: r && r.ok && r.value && Array.isArray(r.value.items) ? r.value.items : [] }
938
+ }
939
+ const it = listCache.items.find((x) => x.sessionId === sessionId)
940
+ return it && it.projections && typeof it.projections.asOfSeq === 'number' ? it.projections.asOfSeq : -1
941
+ }
942
+ return {
943
+ kind: 'remote',
944
+ async pageBySession(sessionId, maxMessages) {
945
+ const throughSeq = await cursorOf(sessionId)
946
+ if (throughSeq < 0) return undefined
947
+ const r = await sess.page({ address: { kind: 'session', sessionId }, throughSeq, maxMessages })
948
+ return r && r.ok ? r.value : undefined
949
+ },
950
+ async pageBySub(parentSessionId, childSessionId, mode, maxMessages) {
951
+ const throughSeq = await cursorOf(childSessionId)
952
+ if (throughSeq < 0) return undefined
953
+ const r = await sess.page({ address: { kind: 'subagent', parentSessionId, childSessionId, mode }, throughSeq, maxMessages })
954
+ return r && r.ok ? r.value : undefined
955
+ },
956
+ async listSessions() { if (typeof sess.list !== 'function') return undefined; const r = await sess.list({}); return r && r.ok ? r.value : undefined },
957
+ canRename: typeof sess.rename === 'function',
958
+ async rename(sessionId, title) { const r = await sess.rename({ sessionId, title }); return r && r.ok && r.value && typeof r.value.title === 'string' ? r.value.title : undefined },
959
+ }
960
+ }
961
+ } catch (e) {}
962
+ if (connection && connection.api && connection.api.sessions && typeof connection.api.sessions.history === 'function') {
963
+ const envVal = (r) => (r && r.result && r.result.ok ? r.result.value : (r && r.ok ? r.value : undefined))
964
+ return {
965
+ kind: 'legacy',
966
+ async pageBySession(sessionId, maxMessages) { return envVal(await connection.api.sessions.history({ sessionId, maxMessages })) },
967
+ async pageBySub(parentSessionId, childSessionId, mode, maxMessages) { return envVal(await connection.api.subagents.history({ parentSessionId, childSessionId, mode, maxMessages })) },
968
+ async listSessions() { return envVal(await connection.api.sessions.list({})) },
969
+ canRename: canOfficialRename(connection),
970
+ async rename(sessionId, title) { const t = await renameViaOfficial(connection, sessionId, title).catch(() => undefined); return t },
971
+ }
972
+ }
973
+ return null
974
+ }
975
+ // v0.1.2 兼容(一):sessions.history 返回值从 {events} 改为 {records, hasMore}
976
+ //(记录 = {type:'event', event} 或 chunk 行 {type:'chunks', event:{type:'chunkrow/…'}})。
977
+ // 旧版 {events} 每项亦可能包 {event}。统一解析为事件数组;丢弃 chunkrow 合成
978
+ // 事件(derive 只认真实事件类型,混入会污染分类)。全部 6 个消费点共用。
979
+ const historyEventsOf = (val) => {
980
+ if (!val) return []
981
+ const list = Array.isArray(val.events) ? val.events : (Array.isArray(val.records) ? val.records : [])
982
+ const out = []
983
+ for (const r of list) {
984
+ const e = (r && r.event) || r
985
+ if (!e || typeof e.type !== 'string' || e.type.startsWith('chunkrow/')) continue
986
+ out.push(e)
987
+ }
988
+ return out
989
+ }
878
990
  /** 静默时长文本({N} 占位)。 */
879
991
  const fmtSilent = (idleMs) => {
880
992
  if (idleMs === null || idleMs === undefined) return ''
@@ -883,6 +995,16 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
883
995
  ? STR.healthSilentMin.replace('{N}', String(min))
884
996
  : STR.healthSilentSec.replace('{N}', String(Math.max(1, Math.round(idleMs / 1000))))
885
997
  }
998
+ /** 相对时间文本(空闲胶囊「N 分钟前」;分钟粒度足够,不追秒级新鲜度)。 */
999
+ const fmtAgo = (ms) => {
1000
+ if (!ms || ms < 0) return ''
1001
+ const min = Math.floor(ms / 60000)
1002
+ if (min < 1) return STR.agoJustNow
1003
+ if (min < 60) return STR.agoMin.replace('{N}', String(min))
1004
+ const h = Math.floor(min / 60)
1005
+ if (h < 24) return STR.agoHour.replace('{N}', String(h))
1006
+ return STR.agoDay.replace('{N}', String(Math.floor(h / 24)))
1007
+ }
886
1008
  /** 健康徽标(详情实时条 / 总览卡片共用);active/ended/unknown 不渲染(已有进行中/已结束徽标)。 */
887
1009
  const healthBadge = (health) => {
888
1010
  if (!health) return null
@@ -893,24 +1015,41 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
893
1015
  return null
894
1016
  }
895
1017
 
896
- // ── 会话页头部健康芯片(官方 conversation.session.header.actions 槽位)──
1018
+ // ── 会话页头部健康胶囊(官方 conversation.session.header.actions 槽位)──
897
1019
  // 位置:官方模式标识(agent-preset label, order -10)右侧(order -9)。
898
- // 数据:sessions.list 快照跟踪 running(官方真实状态,随 host 流即时翻转)→
899
- // running 10s 轮询 tail history → host derive 健康分类(与总览探测同管线)。
1020
+ // 常驻四态(用户需求 2026-09-05:非运行也展示):
1021
+ // 空闲(灰条 +「N 分钟前」相对时间,零 RPC,来自 byId.updatedAt)
1022
+ // 运行·活跃(绿)/ 运行·工具执行中·静默中(黄)/ 运行·疑似卡死(红,脉冲)
1023
+ // —— 运行时 10s 轮询 tail history → host derive 健康分类;probe 未返回前
1024
+ // 乐观显示「活跃」(避免胶囊闪烁)。空白新会话不显示。
900
1025
  // 点击:唤起会话流工作台并直达该会话详情(workbenchBridge 意图桥)。
1026
+ // 槽位 props 兜底:v0.1.2 若 sessionId 标准 prop 管道变化,退回 sessions.list
1027
+ // 快照的 current(头部胶囊面对的就是当前会话)。
1028
+ const currentSessionIdOf = (sessions) => {
1029
+ try {
1030
+ const snap = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list.getSnapshot() : null
1031
+ return snap && snap.current ? snap.current : undefined
1032
+ } catch (e) { return undefined }
1033
+ }
1034
+ // 【临时诊断】chipDbg 已于 v1.3.0 验收后撤除;probe error 静默(与总览探测同口径)。
901
1035
  function SessionHealthChip(props) {
902
- const { sessionId, connection, sessions } = props
1036
+ const { connection, sessions } = props
1037
+ const sid = props.sessionId || currentSessionIdOf(sessions)
1038
+ const sessionId = sid
903
1039
  const [running, setRunning] = useState(false)
1040
+ const [meta, setMeta] = useState({ updatedAt: 0, blank: false })
904
1041
  const [health, setHealth] = useState(null)
905
1042
  useEffect(() => {
906
1043
  if (!sessionId || !sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
907
1044
  const compute = () => {
908
1045
  try {
909
- // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running)——
910
- // 不是 { items }(那是 workspace 浏览器用的另一个视图快照,曾误用导致芯片恒 null)。
1046
+ // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running/updatedAt/blank)。
911
1047
  const snap = sessions.list.getSnapshot()
912
1048
  const it = snap && snap.byId ? snap.byId[sessionId] : undefined
913
1049
  setRunning(it ? it.running === true : false)
1050
+ const u = it && typeof it.updatedAt === 'number' ? it.updatedAt : 0
1051
+ const b = it ? it.blank === true : false
1052
+ setMeta((prev) => (prev.updatedAt === u && prev.blank === b ? prev : { updatedAt: u, blank: b }))
914
1053
  } catch (e) { setRunning(false) }
915
1054
  }
916
1055
  compute()
@@ -918,13 +1057,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
918
1057
  return undefined
919
1058
  }, [sessions, sessionId])
920
1059
  useEffect(() => {
921
- if (!running || !sessionId || !canOfficialRename(connection)) { setHealth(null); return undefined }
1060
+ if (!running || !sessionId || !sfRemoteRef.current) { setHealth(null); return undefined }
922
1061
  let alive = true
923
1062
  const probe = async () => {
924
1063
  try {
925
- const hr = await connection.api.sessions.history({ sessionId, maxMessages: 3 })
926
- const val = hr && hr.result && hr.result.value
927
- const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
1064
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 3)
1065
+ const events = historyEventsOf(val)
928
1066
  if (events.length === 0) { if (alive) setHealth(null); return }
929
1067
  const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
930
1068
  if (alive && d && d.ok) setHealth(d.health || null)
@@ -934,22 +1072,61 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
934
1072
  const iv = setInterval(probe, 10000)
935
1073
  return () => { alive = false; clearInterval(iv) }
936
1074
  }, [running, sessionId, connection])
937
- if (!running || !health) return null
938
- const kind = health.kind
939
- const silent = fmtSilent(health.idleMs)
940
- // 收拢态胶囊条颜色语义:绿=活跃 / 黄=工具执行中·静默中 / 红=疑似卡死(脉冲)。
941
- let text = STR.healthActive, cls = 'hc-active'
942
- if (kind === 'tool-wait') { text = STR.healthToolWait; cls = 'hc-wait' }
943
- else if (kind === 'quiet') { text = STR.healthQuiet; cls = 'hc-wait' }
944
- else if (kind === 'stalled') { text = STR.healthStalled; cls = 'hc-stall' }
945
- else if (kind !== 'active') return null // ended/unknown 不显示
1075
+ // 空闲态每 30s tick 一次,驱动「N 分钟前」相对时间保鲜。
1076
+ const [, setTick] = useState(0)
1077
+ useEffect(() => {
1078
+ if (running) return undefined
1079
+ const iv = setInterval(() => setTick((n) => n + 1), 30000)
1080
+ return () => clearInterval(iv)
1081
+ }, [running])
1082
+ // 空闲计时基准 = 最后一条事件的 time(= 会话结束时刻)。byId.updatedAt
1083
+ // 「列表投影上次变化」时间戳——运行中投影值不变就停在上次用户消息,语义不对
1084
+ //(用户实测裁定)。空闲期间该时刻不再变化 → 会话切入空闲时拉一次即可。
1085
+ const [idleSince, setIdleSince] = useState(0)
1086
+ useEffect(() => {
1087
+ if (running || !sessionId || !sfRemoteRef.current) { setIdleSince(0); return undefined }
1088
+ let alive = true
1089
+ ;(async () => {
1090
+ try {
1091
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 3)
1092
+ const events = historyEventsOf(val)
1093
+ const last = events.length > 0 ? events[events.length - 1] : null
1094
+ const t = last && typeof last.time === 'number' ? last.time : 0
1095
+ if (alive) setIdleSince(t)
1096
+ } catch (e) { if (alive) setIdleSince(0) }
1097
+ })()
1098
+ return () => { alive = false }
1099
+ }, [running, sessionId])
1100
+ if (!sessionId) return null
1101
+ // 空闲常驻态:灰条 + 「距最后事件」相对时间。空白新会话不显示(避免噪音)。
1102
+ if (!running) {
1103
+ if (meta.blank) return null
1104
+ const ago = idleSince > 0 ? fmtAgo(Date.now() - idleSince) : ''
1105
+ return h('button', {
1106
+ className: 'sf-healthChip hc-idle',
1107
+ title: STR.healthChipTitle,
1108
+ onClick: (e) => { e.stopPropagation(); if (props.onOpen) props.onOpen() },
1109
+ },
1110
+ h('span', { className: 'sf-hcBar' }),
1111
+ h('span', { className: 'sf-hcLabel' }, STR.healthIdle + (ago ? ' · ' + ago : '')),
1112
+ )
1113
+ }
1114
+ // 运行态:probe 未返回前乐观显示「活跃」(防闪烁);derive ended/unknown 亦按活跃渲染。
1115
+ let text = STR.healthActive, cls = 'hc-active', silent = ''
1116
+ if (health) {
1117
+ const kind = health.kind
1118
+ silent = fmtSilent(health.idleMs)
1119
+ if (kind === 'tool-wait') { text = STR.healthToolWait; cls = 'hc-wait' }
1120
+ else if (kind === 'quiet') { text = STR.healthQuiet; cls = 'hc-wait' }
1121
+ else if (kind === 'stalled') { text = STR.healthStalled; cls = 'hc-stall' }
1122
+ }
946
1123
  return h('button', {
947
1124
  className: 'sf-healthChip ' + cls,
948
1125
  title: STR.healthChipTitle,
949
1126
  onClick: (e) => { e.stopPropagation(); if (props.onOpen) props.onOpen() },
950
1127
  },
951
1128
  h('span', { className: 'sf-hcBar' }),
952
- h('span', { className: 'sf-hcLabel' }, text + (silent && kind !== 'active' ? ' · ' + silent : '')),
1129
+ h('span', { className: 'sf-hcLabel' }, text + (silent && cls !== 'hc-active' ? ' · ' + silent : '')),
953
1130
  )
954
1131
  }
955
1132
 
@@ -1015,8 +1192,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1015
1192
  const target = all.find((x) => x.id === sid)
1016
1193
  if (target && target.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1017
1194
  }
1018
- if (trimmed !== '' && canOfficialRename(conn)) {
1019
- return renameViaOfficial(conn, sid, trimmed)
1195
+ if (trimmed !== '' && sfRemoteRef.current && sfRemoteRef.current.canRename) {
1196
+ return sfRemoteRef.current.rename(sid, trimmed)
1197
+ .then((accepted) => { if (typeof accepted !== 'string') throw new Error(STR.renameFail); return accepted })
1020
1198
  .then((accepted) => applyLocal(accepted))
1021
1199
  .catch(() => legacy())
1022
1200
  }
@@ -1028,16 +1206,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1028
1206
  const [healthMap, setHealthMap] = useState({})
1029
1207
  const [liveRunning, setLiveRunning] = useState(null) // null=未探测;Set<sessionId>
1030
1208
  useEffect(() => {
1031
- const conn = props.connection
1032
- if (!canOfficialRename(conn) || state.phase !== 'ready') return undefined
1209
+ if (!sfRemoteRef.current || state.phase !== 'ready') return undefined
1033
1210
  let alive = true
1034
1211
  let probing = false
1035
1212
  const probe = async () => {
1036
1213
  if (probing) return
1037
1214
  probing = true
1038
1215
  try {
1039
- const resp = await conn.api.sessions.list({})
1040
- const items = (resp && resp.result && resp.result.value && resp.result.value.items) || []
1216
+ const val = await sfRemoteRef.current.listSessions()
1217
+ const items = (val && val.items) || []
1041
1218
  const runningIds = items.filter((i) => i.running === true).map((i) => i.sessionId)
1042
1219
  if (!alive) return
1043
1220
  setLiveRunning(new Set(runningIds))
@@ -1045,9 +1222,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1045
1222
  for (let i = 0; i < Math.min(runningIds.length, 8); i += 3) {
1046
1223
  await Promise.all(runningIds.slice(i, i + 3).map(async (sid) => {
1047
1224
  try {
1048
- const hr = await conn.api.sessions.history({ sessionId: sid, maxMessages: 3 })
1049
- const val = hr && hr.result && hr.result.value
1050
- const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
1225
+ const events = historyEventsOf(await sfRemoteRef.current.pageBySession(sid, 3))
1051
1226
  if (events.length === 0) return
1052
1227
  const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
1053
1228
  if (d && d.ok && d.health) next[sid] = d.health
@@ -1223,6 +1398,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1223
1398
  if (sort === 'oldest') return r.createdAt || 0
1224
1399
  if (sort === 'tools') return r.toolCalls || 0
1225
1400
  if (sort === 'longest') return (r.lastEventTime || 0) - (r.createdAt || 0)
1401
+ if (sort === 'size') return r.sizeBytes || 0
1226
1402
  return 0
1227
1403
  }
1228
1404
  const ascending = sort === 'oldest'
@@ -1235,17 +1411,19 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1235
1411
  const toolAgg = new Map()
1236
1412
  let issues = 0
1237
1413
  let roots = 0
1414
+ let totalSize = 0
1238
1415
  for (const g of (state.data && state.data.workspaces) || []) {
1239
1416
  if (wsFilter !== '' && wsFilter !== g.name) continue // 统计范围 = 当前选中的工作区
1240
1417
  for (const s of g.sessions) {
1241
1418
  if (s.parentSession) continue
1242
1419
  roots++
1243
1420
  if (s.toolErrors > 0) issues++
1421
+ totalSize += s.sizeBytes || 0
1244
1422
  for (const n of s.toolNames || []) toolAgg.set(n, (toolAgg.get(n) || 0) + 1)
1245
1423
  }
1246
1424
  }
1247
1425
  const topTools = [...toolAgg.entries()].sort((a, b) => b[1] - a[1]).slice(0, 6)
1248
- return { topTools, issues, roots }
1426
+ return { topTools, issues, roots, totalSize }
1249
1427
  }, [state.data, wsFilter])
1250
1428
 
1251
1429
  const wsNames = ((state.data && state.data.workspaces) || []).map((g) => g.name)
@@ -1270,6 +1448,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1270
1448
  h('option', { value: 'oldest' }, STR.sortOldest),
1271
1449
  h('option', { value: 'tools' }, STR.sortTools),
1272
1450
  h('option', { value: 'longest' }, STR.sortLongest),
1451
+ h('option', { value: 'size' }, STR.sortSize),
1273
1452
  ),
1274
1453
  h('label', { className: 'sf-muted', style: { display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' } },
1275
1454
  h('input', { type: 'checkbox', checked: showEmpty, onChange: (e) => setShowEmpty(e.target.checked) }),
@@ -1356,6 +1535,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1356
1535
  h('span', {}, '⚠ ' + STR.issues),
1357
1536
  h('span', { className: 'sf-statCount err' }, stats.issues),
1358
1537
  ),
1538
+ h('span', { className: 'sf-statChip', title: STR.storageHint },
1539
+ h('span', {}, STR.storage),
1540
+ h('span', { className: 'sf-statCount' }, fmtSize(stats.totalSize)),
1541
+ ),
1359
1542
  ),
1360
1543
  state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.scanning),
1361
1544
  state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.loadFailed + ': ' + String(state.error)),
@@ -1721,16 +1904,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1721
1904
  }
1722
1905
  } catch (e) {}
1723
1906
  // 2) 运行时 history 桥接(在线子代理)→ host derive 得 light 结构
1724
- if (connection !== undefined && node.parentId !== undefined) {
1907
+ if (node.parentId !== undefined && sfRemoteRef.current) {
1725
1908
  try {
1726
- const resp = await connection.api.subagents.history({
1727
- parentSessionId: node.parentId,
1728
- childSessionId: node.id,
1729
- mode: node.mode,
1730
- maxMessages: 120,
1731
- })
1732
- if (resp && resp.result && resp.result.ok && resp.result.value && Array.isArray(resp.result.value.events)) {
1733
- const events = resp.result.value.events.map((entry) => entry.event)
1909
+ const subVal = await sfRemoteRef.current.pageBySub(node.parentId, node.id, node.mode, 120)
1910
+ const subEvents = historyEventsOf(subVal)
1911
+ if (subEvents.length > 0) {
1912
+ const events = subEvents
1734
1913
  const derived = await api('derive', { events })
1735
1914
  if (derived && derived.ok) {
1736
1915
  // derive 返回完整 timeline——转成 light 结构
@@ -1848,8 +2027,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1848
2027
  })
1849
2028
  const trimmed = String(title || '').trim()
1850
2029
  if (trimmed === '' && session.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1851
- if (trimmed !== '' && canOfficialRename(conn)) {
1852
- return renameViaOfficial(conn, session.id, trimmed)
2030
+ if (trimmed !== '' && sfRemoteRef.current && sfRemoteRef.current.canRename) {
2031
+ return sfRemoteRef.current.rename(session.id, trimmed)
2032
+ .then((accepted) => { if (typeof accepted !== 'string') throw new Error(STR.renameFail); return accepted })
1853
2033
  .then((accepted) => { setTitleOverride(accepted); setRenaming(false); return true })
1854
2034
  .catch(() => legacy())
1855
2035
  }
@@ -1990,18 +2170,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1990
2170
  }
1991
2171
  setLiveState((s) => ({ ...s, phase: 'loading' }))
1992
2172
  try {
1993
- // 会话地址未知时走普通 sessions 通道(当前会话);若连接层要求显式地址则回退。
1994
- const resp = await conn.api.sessions.history({
1995
- sessionId: session.id,
1996
- mode: 'full',
1997
- maxMessages: 400,
1998
- })
1999
- const val = resp && resp.result && resp.result.value
2000
- if (val === undefined || !Array.isArray(val.events)) {
2173
+ // v0.1.2:session RPC 经双面适配器(remote.session.page / 旧 history)。
2174
+ const val = sfRemoteRef.current ? await sfRemoteRef.current.pageBySession(session.id, 400) : undefined
2175
+ if (val === undefined) {
2001
2176
  setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2002
2177
  return
2003
2178
  }
2004
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2179
+ const events = historyEventsOf(val)
2005
2180
  const derived = await api('derive', { events, now: Date.now() })
2006
2181
  if (!derived || !derived.ok) {
2007
2182
  setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
@@ -2638,19 +2813,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2638
2813
 
2639
2814
  const fetchLive = useRef(async () => {})
2640
2815
  fetchLive.current = async () => {
2641
- if (connection === undefined || connection.api === undefined || connection.api.sessions === undefined) {
2816
+ if (!sfRemoteRef.current) {
2642
2817
  setState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
2643
2818
  return
2644
2819
  }
2645
2820
  setState((s) => ({ ...s, phase: s.timeline === null ? 'loading' : 'ready' }))
2646
2821
  try {
2647
- const resp = await connection.api.sessions.history({ sessionId, mode: 'full', maxMessages: 400 })
2648
- const val = resp && resp.result && resp.result.value
2649
- if (val === undefined || !Array.isArray(val.events)) {
2822
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 400)
2823
+ if (val === undefined) {
2650
2824
  setState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2651
2825
  return
2652
2826
  }
2653
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2827
+ const events = historyEventsOf(val)
2654
2828
  const derived = await api('derive', { events, now: Date.now() })
2655
2829
  if (!derived || !derived.ok) {
2656
2830
  setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
@@ -2741,6 +2915,33 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2741
2915
  })()
2742
2916
  return () => ac.abort()
2743
2917
  }, [props.connection])
2918
+ // v0.1.2 起 events.mux 消费面移除 → 改用 sessions.list 订阅:官方 rename 后
2919
+ // 列表行 byId[sessionId].title 由 title projection 即时更新(新控制器
2920
+ // dsh-api-session-controller rename 后 projections.apply('title') 即落列表),
2921
+ // 订阅回调秒级收集全量标题快照,浅比较防抖。与 mux 路径并存(旧版 dsh 仍走 mux)。
2922
+ useEffect(() => {
2923
+ const svc = props.sessions
2924
+ const list = svc && svc.list
2925
+ if (!list || typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return undefined
2926
+ const collect = () => {
2927
+ const snap = list.getSnapshot()
2928
+ const byId = snap && snap.byId
2929
+ if (!byId) return
2930
+ const map = {}
2931
+ for (const id of Object.keys(byId)) {
2932
+ const t = byId[id] && byId[id].title
2933
+ if (typeof t === 'string' && t !== '') map[id] = t
2934
+ }
2935
+ setLiveTitles((prev) => {
2936
+ const pk = Object.keys(prev)
2937
+ const nk = Object.keys(map)
2938
+ if (pk.length === nk.length && nk.every((k) => prev[k] === map[k])) return prev
2939
+ return map
2940
+ })
2941
+ }
2942
+ collect()
2943
+ return list.subscribe(collect)
2944
+ }, [props.sessions])
2744
2945
 
2745
2946
  return detail === null
2746
2947
  ? h(SessionFlowOverview, {
@@ -2826,17 +3027,74 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2826
3027
  return undefined
2827
3028
  }
2828
3029
 
3030
+ // ── 共享:纯色矢量图标 + 轮次 meta chips(rail 增强层与钢琴键详情框共用)──
3031
+ // stroke=currentColor 随主题取色;不用 emoji(用户裁定 2026-09-05)。
3032
+ const SF_ICON_PATHS = {
3033
+ clock: '<circle cx="8" cy="8" r="6.3"/><path d="M8 4.6V8l2.4 1.5"/>',
3034
+ tool: '<rect x="2.4" y="3" width="11.2" height="10" rx="1.6"/><path d="M5 7l2.2 2L5 11M8.6 11h2.6"/>',
3035
+ alert: '<circle cx="8" cy="8" r="6.3"/><path d="M8 5.2v3.2M8 11.2h.01"/>',
3036
+ think: '<path d="M8 2.2a4 4 0 0 0-4 4c0 1.7.9 2.7 1.6 3.5.3.4.4 1.3.4 1.3h4s.1-.9.4-1.3c.7-.8 1.6-1.8 1.6-3.5a4 4 0 0 0-4-4z"/><path d="M6.4 13.6h3.2"/>',
3037
+ }
3038
+ const sfIcon = (name) => {
3039
+ const span = document.createElement('span')
3040
+ span.className = 'sf-ico'
3041
+ span.innerHTML = '<svg viewBox="0 0 16 16" width="11" height="11" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + SF_ICON_PATHS[name] + '</svg>'
3042
+ return span
3043
+ }
3044
+ // 时长格式化(ms → 42s / 2m 14s;与 host 导出 fmtDur 同风格)。
3045
+ const sfFmtDur = (ms) => {
3046
+ if (!ms || ms <= 0) return ''
3047
+ const s = Math.round(ms / 1000)
3048
+ if (s < 60) return s + 's'
3049
+ const m = Math.floor(s / 60)
3050
+ return s % 60 === 0 ? m + 'm' : m + 'm ' + (s % 60) + 's'
3051
+ }
3052
+ // meta chips:耗时 / 工具调用 / 错误(红)/ 思考标记。无任何信息时返回 null。
3053
+ const buildTurnMeta = (entry) => {
3054
+ const meta = document.createElement('div')
3055
+ meta.className = 'sf-turnMeta'
3056
+ const chip = (icon, text, cls) => {
3057
+ const c = document.createElement('span')
3058
+ c.className = 'sf-turnMetaChip' + (cls ? ' ' + cls : '')
3059
+ c.appendChild(sfIcon(icon))
3060
+ const t = document.createElement('span')
3061
+ t.textContent = text
3062
+ c.appendChild(t)
3063
+ meta.appendChild(c)
3064
+ }
3065
+ if (entry.durMs > 0) chip('clock', sfFmtDur(entry.durMs))
3066
+ if (entry.tools > 0) chip('tool', STR.railMetaCalls.replace('{tools}', String(entry.tools)))
3067
+ if (entry.errors > 0) chip('alert', STR.railMetaErrors.replace('{errors}', String(entry.errors)), 'err')
3068
+ if (entry.thinking) chip('think', STR.railThinking)
3069
+ return meta.childNodes.length > 0 ? meta : null
3070
+ }
3071
+ // lightTurn → 共享字段(tools/errors/durMs/thinking/conclusion)。
3072
+ const ltMeta = (lt) => ({
3073
+ tools: lt.toolCount || 0,
3074
+ errors: lt.errorCount || 0,
3075
+ durMs: (typeof lt.startTime === 'number' && typeof lt.endTime === 'number' && lt.endTime > lt.startTime) ? lt.endTime - lt.startTime : 0,
3076
+ thinking: lt.hasThinking === true,
3077
+ })
3078
+ // getTurn → 工具分布文本(top4:pwsh ×6 · edit ×3);共享给 rail 与钢琴键。
3079
+ const toolDistOf = (turn) => {
3080
+ const counts = new Map()
3081
+ for (const st of (turn && turn.steps) || []) for (const c of st.toolCalls || []) counts.set(c.name, (counts.get(c.name) || 0) + 1)
3082
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([n, c]) => n + ' ×' + c).join(' · ')
3083
+ }
3084
+
2829
3085
  // ── M11:本会话轮次导航(会话页左侧常驻悬浮条目条,v3)──────────
2830
3086
  // 范围:只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区。
2831
- // 数据:官方消息行锚点 DOM 驱动(data-time-hover-rootdsh-navbar 同款机制,
2832
- // RPC 实时):用户行 = 无 data-turn-tail + 含 [class*=bubble]。
3087
+ // 数据:官方消息行锚点 DOM 驱动(v0.1.2 起 [data-chat-flow] [data-chat-flow-kind=user]
3088
+ // 旧版回退 data-time-hover-root;零 RPC 实时)。
2833
3089
  // 轮次 → 结论预览:按顺序索引映射 host get 的 lightTurns(用户消息摊平)。
2834
3090
  // 交互:点击滚动定位该轮、active 跟随视口第一条、窗口裁剪 + 滚轮/▲▼、
2835
3091
  // 键盘 ↑↓/Enter、hover 预览卡(该轮用户消息 + 结论)、右键唤起会话流工作台。
2836
3092
  // 样式:DeepSeek 网页版右侧悬浮消息导航风格(圆角卡片 + 单行截断文字),移到左侧。
2837
3093
  function TurnKeys(props) {
2838
3094
  const { sessions, onOpenWorkbench } = props
2839
- const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
3095
+ const [entries, setEntries] = useState([]) // [{turn, seq, preview, tools, errors, durMs, thinking}] host 全量用户轮次
3096
+ const distCache = useRef(new Map()) // turn → 工具分布文本(getTurn 懒加载缓存)
3097
+ const [, setDistTick] = useState(0) // distCache 到位后触发详情框重渲染
2840
3098
  const [activeIdx, setActiveIdx] = useState(-1)
2841
3099
  // T5 平滑滚动:连续滚动位置(浮点,单位=行)。替代原整数 winOffset 的「每格硬跳
2842
3100
  // 1 条」——滚轮增量累积(deltaMode 归一)+ 列表 transform 百分比位移(transition
@@ -2918,7 +3176,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2918
3176
  const flat = []
2919
3177
  for (const lt of json.lightTurns || []) {
2920
3178
  for (const u of lt.userMessages || []) {
2921
- flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview })
3179
+ flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview, ...ltMeta(lt) })
2922
3180
  }
2923
3181
  }
2924
3182
  // 防御:同一 seq 只保留一条(避免同一条消息重复渲染 = 「最后一条显示两次」)。
@@ -2962,10 +3220,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2962
3220
  return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
2963
3221
  }, [currentId])
2964
3222
 
2965
- // DOM 行工具:官方消息行锚点(dsh-navbar 同款)。
2966
- const domRows = () => [...document.querySelectorAll('[data-time-hover-root]')]
2967
- .filter((r) => !r.hasAttribute('data-pending-steering'))
2968
- .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
3223
+ // DOM 行工具:用户消息行锚点。v0.1.2 起官方会话流重构进 dsh-client-ui-chat:
3224
+ // data-time-hover-root 移除,行结构 = [data-chat-flow] > [data-chat-flow-key]
3225
+ // data-chat-flow-kind 区分 user/steering/turn-process/…,官方内部查行同款
3226
+ // 选择器);旧版 dsh 回退 data-time-hover-root 锚点(dsh-navbar 同款机制)。
3227
+ // 零 RPC 实时。steering(随转消息)不算新轮次,不计入。
3228
+ const domRows = () => {
3229
+ const next = document.querySelectorAll('[data-chat-flow] [data-chat-flow-kind="user"]')
3230
+ if (next.length > 0) return [...next]
3231
+ return [...document.querySelectorAll('[data-time-hover-root]')]
3232
+ .filter((r) => !r.hasAttribute('data-pending-steering'))
3233
+ .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
3234
+ }
2969
3235
  const textOf = (row) => {
2970
3236
  const b = row.querySelector('[class*=bubble]')
2971
3237
  return b ? String(b.textContent || '').replace(/\s+/g, ' ').trim() : ''
@@ -3010,16 +3276,74 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3010
3276
  return null
3011
3277
  }
3012
3278
 
3279
+ // 未加载轮次 → 委托官方轮次导航引擎(v0.1.2+ 吸收官方独有能力):合成 click
3280
+ // 命中目标刻度,官方 navigate 自动加载未载入页并跳转(其引擎含 busy 态/加载锚定,
3281
+ // 优于我们的「只提示」)。合成事件 clientY 按官方 itemAtPointer 几何反解
3282
+ // (offset = clientY - rect.top + scrollTop - 6 = idx*10);官方处理挂在 nav 的
3283
+ // onClick 且只读 event.clientY,与事件落在哪个子元素无关(刻度按钮
3284
+ // pointer-events:none,不能直接 btn.click()——clientY=0 会误算成第一条)。
3285
+ const delegateToRail = (entry) => {
3286
+ try {
3287
+ const nav = document.querySelector(RAIL_SELECTOR)
3288
+ if (!nav) return false
3289
+ const turns = [...nav.querySelectorAll('button[aria-label]')]
3290
+ .map((b) => { const m = /(\d+)/.exec(b.getAttribute('aria-label') || ''); return m ? Number(m[1]) : null })
3291
+ const idx = turns.indexOf(entry.turn)
3292
+ if (idx < 0) return false
3293
+ const rect = nav.getBoundingClientRect()
3294
+ const scroller = nav.firstElementChild
3295
+ const st = scroller ? scroller.scrollTop : 0
3296
+ const y = rect.top + (idx * 10 + 6) - st
3297
+ nav.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: rect.left + 10, clientY: y }))
3298
+ return true
3299
+ } catch (e) { return false }
3300
+ }
3013
3301
  // 点击定位:先查该轮次是否已加载(DOM 中能否匹配到对应行)——
3014
- // 已加载 → 平滑滚动定位;未加载 → 只提示、不跳转(自动加载循环会让消息流
3015
- // 乱滚/跳错位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
3302
+ // 已加载 → 平滑滚动定位;未加载 → 委托官方 rail 引擎加载跳转(v0.1.2+);
3303
+ // rail 缺席(旧版 dsh)→ 只提示、不跳转(自动加载循环会让消息流乱滚/跳错
3304
+ // 位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
3016
3305
  const open = (entry) => {
3017
3306
  const row = findRow(entry)
3018
3307
  if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'start' }); return }
3308
+ if (delegateToRail(entry)) return
3019
3309
  setJumpFail(true)
3020
3310
  setTimeout(() => setJumpFail(false), 2600)
3021
3311
  }
3022
3312
 
3313
+ // 详情框 meta 注入(与 rail 增强层同款信息:矢量 chips + 工具分布行;
3314
+ // imperative ref 填充,data-sig 幂等)。工具分布懒加载:详情驻留 200ms 拉
3315
+ // getTurn 聚合(distCache 跨 hover 缓存,到位后 tick 触发 ref 重填)。
3316
+ const fillDetailMeta = (el, entry) => {
3317
+ if (!el) return
3318
+ const dist = distCache.current.get(entry.turn) || ''
3319
+ const sig = entry.turn + '|' + dist
3320
+ if (el.dataset.sig === sig) return
3321
+ el.dataset.sig = sig
3322
+ el.replaceChildren()
3323
+ const meta = buildTurnMeta(entry)
3324
+ if (meta) el.appendChild(meta)
3325
+ if (dist !== '') {
3326
+ const tools = document.createElement('div')
3327
+ tools.className = 'sf-rail-conclusionTools'
3328
+ tools.textContent = dist
3329
+ el.appendChild(tools)
3330
+ }
3331
+ }
3332
+ useEffect(() => {
3333
+ if (detailIdx === null || !currentId) return undefined
3334
+ const entry = entries[detailIdx]
3335
+ if (!entry || entry.tools === 0 || distCache.current.has(entry.turn)) return undefined
3336
+ const turn = entry.turn
3337
+ const timer = setTimeout(() => {
3338
+ api('getTurn', { sessionId: currentId, turn }).then((json) => {
3339
+ if (!json || !json.ok || !json.turn) return
3340
+ distCache.current.set(turn, toolDistOf(json.turn))
3341
+ setDistTick((n) => n + 1)
3342
+ }).catch(() => {})
3343
+ }, 200)
3344
+ return () => clearTimeout(timer)
3345
+ }, [detailIdx, currentId, entries])
3346
+
3023
3347
  // active:视口内最靠上的可见行 → 映射条目索引高亮(阅读位置跟随)。
3024
3348
  // 可见判定用 bottom 进入视口(含被顶部裁切一半的行)——此前 top>=0 会
3025
3349
  // 跳过被裁切行导致高亮偏晚一轮(T1 修复点二)。
@@ -3328,6 +3652,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3328
3652
  // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
3329
3653
  h('div', { className: 'pk-detail' + (detailEntry ? '' : ' hidden'), style: detailPos, onMouseEnter: () => showDetail(detailIdx), onMouseLeave: hideDetail },
3330
3654
  detailEntry && h('div', { key: 'h' + detailEntry.turn, className: 'pk-detailHead' }, 'T' + detailEntry.turn + ' · ' + STR.user),
3655
+ detailEntry && h('div', { key: 'm' + detailEntry.turn, className: 'pk-detailMeta', ref: (el) => fillDetailMeta(el, detailEntry) }),
3331
3656
  detailEntry && h('div', { key: 'b' + detailEntry.turn, className: 'pk-detailBody' }, detailEntry.preview || '(空)'),
3332
3657
  ),
3333
3658
  // 跳转状态提示:常驻渲染 + hidden 类(从下方淡入/淡出);未加载轮次提示。
@@ -3380,14 +3705,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3380
3705
  }
3381
3706
  if (liveStore.emit !== null) liveStore.emit({ ...liveStateKeep(), phase: 'loading' })
3382
3707
  try {
3383
- const resp = await conn.api.sessions.history({ sessionId: sid, mode: 'full', maxMessages: 400 })
3384
- const val = resp && resp.result && resp.result.value
3385
- if (val === undefined || !Array.isArray(val.events)) {
3708
+ const val = sfRemoteRef.current ? await sfRemoteRef.current.pageBySession(sid, 400) : undefined
3709
+ if (val === undefined) {
3386
3710
  liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail }
3387
3711
  if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3388
3712
  return
3389
3713
  }
3390
- const events = val.events.map((entry) => (entry && entry.event) || entry)
3714
+ const events = historyEventsOf(val)
3391
3715
  const derived = await api('derive', { events, now: Date.now() })
3392
3716
  if (!derived || !derived.ok) {
3393
3717
  liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: (derived && derived.error) || STR.liveFail }
@@ -3626,11 +3950,233 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3626
3950
  )
3627
3951
  }
3628
3952
 
3953
+ // ── M11×v0.1.2:官方轮次导航(TurnNavigatorRail)增强层 ─────────────
3954
+ // 官方 rail 无扩展槽位(chat 槽位仅 node/turnTail/assistant-actions/commandview),
3955
+ // 本层走 DOM 观察(家族传统:观察器 + 自愈重挂),挂载我们的独有增量:
3956
+ // 1) hover 结论段注入官方预览卡:该轮「结论摘要」(官方预览只有 prompt/response
3957
+ // 片段)+ 工具统计(错误角标方案已撤——开发会话多数轮次都有工具错误,
3958
+ // 刻度全员红点等于无信息,用户实机裁定 2026-09-05);
3959
+ // 2) 右键 rail → 唤起会话流工作台。
3960
+ // 定位:nav[aria-label=轮次导航|Turn navigation];几何换算复刻官方 itemAtPointer
3961
+ // (TURN_SPACING 10px / RAIL_INSET 6px + scroller.scrollTop);turn 号从刻度按钮
3962
+ // aria-label(跳转到第 N 轮 / Jump to turn N)提取,索引↔turn 精确映射,不依赖
3963
+ // 「index+1 == turn」假设。官方 navigate 引擎(含未载入轮次自动加载跳转)原样保留,
3964
+ // 我们不拦截点击。
3965
+ const RAIL_SELECTOR = 'nav[aria-label="轮次导航"], nav[aria-label="Turn navigation"]'
3966
+ function RailEnhancer(props) {
3967
+ const { sessions, railEl, onOpenWorkbench } = props
3968
+ const [turnData, setTurnData] = useState([]) // [{turn, preview, conclusion, tools, errors}]
3969
+ const [hoverInfo, setHoverInfo] = useState(null) // {turn, clientY, railLeft}
3970
+ const [, force] = useState(0)
3971
+
3972
+ // 订阅会话列表变化:切换会话 → 重拉条目。
3973
+ useEffect(() => {
3974
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
3975
+ return sessions.list.subscribe(() => force((n) => n + 1))
3976
+ }
3977
+ return undefined
3978
+ }, [sessions])
3979
+
3980
+ const currentId = (() => {
3981
+ try {
3982
+ const s = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list.getSnapshot() : null
3983
+ return s && s.current ? s.current : null
3984
+ } catch (e) { return null }
3985
+ })()
3986
+
3987
+ // 条目数据:host get lightTurns(每轮一条,对齐官方 rail item 粒度)。
3988
+ // 轮询节奏同钢琴键:DOM 变化切 3s 高频,空闲 15s 兜底。
3989
+ useEffect(() => {
3990
+ if (!currentId) { setTurnData([]); return undefined }
3991
+ let alive = true
3992
+ const refresh = () => {
3993
+ api('get', { sessionId: currentId }).then((json) => {
3994
+ if (!alive || !json || !json.ok) return
3995
+ const rows = (json.lightTurns || []).map((lt) => ({
3996
+ turn: lt.turn,
3997
+ preview: lt.userMessages && lt.userMessages[0] ? String(lt.userMessages[0].preview || '') : '',
3998
+ conclusion: String(lt.conclusionPreview || ''),
3999
+ tools: lt.toolCount || 0,
4000
+ errors: lt.errorCount || 0,
4001
+ durMs: (typeof lt.startTime === 'number' && typeof lt.endTime === 'number' && lt.endTime > lt.startTime) ? lt.endTime - lt.startTime : 0,
4002
+ thinking: lt.hasThinking === true,
4003
+ }))
4004
+ setTurnData((prev) => {
4005
+ if (prev.length === rows.length && prev.length > 0 && prev[prev.length - 1].turn === rows[rows.length - 1].turn) return prev
4006
+ return rows
4007
+ })
4008
+ }).catch(() => {})
4009
+ }
4010
+ const ACTIVE_MS = 3000
4011
+ const IDLE_MS = 15000
4012
+ let iv = null
4013
+ const schedule = (delay) => { if (iv) clearInterval(iv); iv = setInterval(refresh, delay) }
4014
+ let flowObs = null
4015
+ let lastDomRefresh = 0
4016
+ const onDomChange = () => {
4017
+ if (!alive) return
4018
+ const now = Date.now()
4019
+ if (now - lastDomRefresh < 1000) return
4020
+ lastDomRefresh = now
4021
+ refresh()
4022
+ schedule(ACTIVE_MS)
4023
+ }
4024
+ const flowEl = document.querySelector('[data-chat-flow=""]') || document.querySelector('[data-chat-flow]')
4025
+ if (flowEl) {
4026
+ flowObs = new MutationObserver(onDomChange)
4027
+ flowObs.observe(flowEl, { childList: true, subtree: true })
4028
+ }
4029
+ refresh()
4030
+ schedule(IDLE_MS)
4031
+ return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
4032
+ }, [currentId])
4033
+
4034
+ // rail 交互:hover 几何换算 + 右键唤起工作台。
4035
+ useEffect(() => {
4036
+ if (!railEl) { setHoverInfo(null); return undefined }
4037
+ const scroller = railEl.firstElementChild // 官方结构:nav.frame > div.scroller
4038
+ const railTurns = () => [...railEl.querySelectorAll('button[aria-label]')]
4039
+ .map((b) => { const m = /(\d+)/.exec(b.getAttribute('aria-label') || ''); return m ? Number(m[1]) : null })
4040
+ .filter((n) => n !== null)
4041
+ const onMove = (e) => {
4042
+ const rect = railEl.getBoundingClientRect()
4043
+ const st = scroller ? scroller.scrollTop : 0
4044
+ const idx = Math.max(0, Math.round((e.clientY - rect.top + st - 6) / 10))
4045
+ const turns = railTurns()
4046
+ const turn = idx < turns.length ? turns[idx] : null
4047
+ setHoverInfo((prev) => {
4048
+ const prevTurn = prev === null ? null : prev.turn
4049
+ return prevTurn === turn ? prev : (turn === null ? null : { turn })
4050
+ })
4051
+ }
4052
+ const onLeave = () => setHoverInfo(null)
4053
+ const onContext = (e) => { e.preventDefault(); onOpenWorkbench() }
4054
+ railEl.addEventListener('pointermove', onMove)
4055
+ railEl.addEventListener('pointerleave', onLeave)
4056
+ railEl.addEventListener('contextmenu', onContext)
4057
+ return () => {
4058
+ railEl.removeEventListener('pointermove', onMove)
4059
+ railEl.removeEventListener('pointerleave', onLeave)
4060
+ railEl.removeEventListener('contextmenu', onContext)
4061
+ }
4062
+ }, [railEl, onOpenWorkbench])
4063
+
4064
+ // 工具分布懒加载(独特信息维度,官方 preview 只有 prompt/response 片段):
4065
+ // hover 驻留 250ms 后拉 getTurn,聚合工具名 ×次数 top4(防扫过时每刻度一请求)。
4066
+ const [toolDist, setToolDist] = useState(null) // {turn, text}
4067
+ useEffect(() => {
4068
+ if (!railEl || hoverInfo === null || !currentId) { setToolDist(null); return undefined }
4069
+ const turn = hoverInfo.turn
4070
+ const timer = setTimeout(() => {
4071
+ api('getTurn', { sessionId: currentId, turn }).then((json) => {
4072
+ if (!json || !json.ok || !json.turn) return
4073
+ const text = toolDistOf(json.turn)
4074
+ setToolDist((prev) => (prev && prev.turn === turn && prev.text === text) ? prev : { turn, text })
4075
+ }).catch(() => {})
4076
+ }, 250)
4077
+ return () => clearTimeout(timer)
4078
+ }, [railEl, hoverInfo, currentId])
4079
+
4080
+ // hover 增强段注入官方预览卡内部(单卡融合——另起一卡会与官方预览上下堆叠成
4081
+ // 两大块,实测很丑)。官方 preview 容器 = rail 内 class 以 _preview 结尾的元素
4082
+ //(CSS module hash 前缀 + preview/previewPrompt/previewResponse 三段,用
4083
+ // /(?:^|_)preview(?:$|\s)/ 精确命中容器);React 复用 preview 元素跨轮次渲染 →
4084
+ // data-sig 核对(轮次+工具分布到位状态),变化即重建;observer 监视 rail 子树,
4085
+ // preview 出现/消失/被重渲染时自愈。**独特性原则(用户裁定 2026-09-05):只给
4086
+ // 官方 preview 没有的信息维度——耗时 / 工具统计与分布 / 错误 / 思考标记;
4087
+ // 结论不再展示(与官方 response 片段重复,用户二次裁定撤下);图标一律纯色
4088
+ // 矢量(stroke=currentColor),不用 emoji**。
4089
+ // 高度解禁:官方 preview 固定 --turn-preview-height:100px 且溢出裁剪——注入段
4090
+ // 会被裁成「残缺卡」(实测踩坑);注入期间内联 height:auto + maxHeight 放开,
4091
+ // 撤注入时还原内联样式。
4092
+ useEffect(() => {
4093
+ if (!railEl) return undefined
4094
+ const findPreview = () => [...railEl.querySelectorAll('[class*=preview]')]
4095
+ .find((el) => /(?:^|_)preview(?:$|\s)/.test(String(el.className)))
4096
+ const cleanup = () => {
4097
+ for (const n of railEl.querySelectorAll('.sf-rail-conclusion')) n.remove()
4098
+ const p = findPreview()
4099
+ if (p) { p.style.height = ''; p.style.maxHeight = ''; p.style.overflow = '' }
4100
+ }
4101
+ const enhance = () => {
4102
+ const preview = findPreview()
4103
+ const turn = hoverInfo === null ? null : hoverInfo.turn
4104
+ const entry = turn === null ? undefined : turnData.find((t) => t.turn === turn)
4105
+ if (!preview || !entry || (entry.durMs === 0 && entry.tools === 0)) { cleanup(); return }
4106
+ preview.style.height = 'auto'
4107
+ preview.style.maxHeight = '280px'
4108
+ preview.style.overflow = 'hidden'
4109
+ let node = preview.querySelector(':scope > .sf-rail-conclusion')
4110
+ if (!node) {
4111
+ node = document.createElement('div')
4112
+ node.className = 'sf-rail-conclusion'
4113
+ preview.appendChild(node)
4114
+ }
4115
+ const dist = toolDist !== null && toolDist.turn === entry.turn ? toolDist.text : ''
4116
+ const sig = entry.turn + '|' + dist
4117
+ if (node.dataset.sig === sig) return
4118
+ node.dataset.sig = sig
4119
+ node.replaceChildren()
4120
+ // meta chips 行(纯色矢量图标):耗时 · n 次工具 · m 错误(红)· 含思考
4121
+ const meta = buildTurnMeta(entry)
4122
+ if (meta) node.appendChild(meta)
4123
+ // 工具分布行(懒加载到位后经 sig 变化重建本节点)
4124
+ if (dist !== '') {
4125
+ const tools = document.createElement('div')
4126
+ tools.className = 'sf-rail-conclusionTools'
4127
+ tools.textContent = dist
4128
+ node.appendChild(tools)
4129
+ }
4130
+ }
4131
+ enhance()
4132
+ const obs = new MutationObserver(enhance)
4133
+ obs.observe(railEl, { childList: true, subtree: true })
4134
+ return () => { obs.disconnect(); cleanup() }
4135
+ }, [railEl, hoverInfo, turnData, toolDist])
4136
+
4137
+ return null
4138
+ }
4139
+
4140
+ // ── 导航总挂载:官方 rail 增强层 ⇄ 经典钢琴键悬浮条 ────────────────
4141
+ // 默认(v0.1.2+,用户裁定 2026-09-05):经典悬浮条默认开启(pianoClassicStrip
4142
+ // 默认 true),与官方 rail 增强层并存——左右双导航各司其职(我们:预览卡/右键
4143
+ // 工作台/滚轮窗口/键盘;官方:完整历史刻度);设置里可各自关闭。旧版 dsh
4144
+ // (无官方 rail)railEl=null 时 classic 恒 true,功能不丢。
4145
+ function NavRoot(props) {
4146
+ const { settingsCtrl } = props
4147
+ const [, force] = useState(0)
4148
+ const [railEl, setRailEl] = useState(null)
4149
+ useEffect(() => (settingsCtrl ? settingsCtrl.subscribe(() => force((n) => n + 1)) : undefined), [settingsCtrl])
4150
+ useEffect(() => {
4151
+ const find = () => {
4152
+ const nav = document.querySelector(RAIL_SELECTOR)
4153
+ setRailEl((prev) => (prev === nav || (prev === null && nav === null) ? prev : nav))
4154
+ }
4155
+ find()
4156
+ const obs = new MutationObserver(find)
4157
+ obs.observe(document.body, { childList: true, subtree: true })
4158
+ return () => obs.disconnect()
4159
+ }, [])
4160
+ const classicWanted = sfSettings.current.pianoClassicStrip === true
4161
+ const enhanceWanted = sfSettings.current.railEnhance !== false
4162
+ // railHideOfficial:完全屏蔽官方刻度条(只留我们的悬浮条);隐藏期间增强层不挂。
4163
+ const hideOfficial = sfSettings.current.railHideOfficial === true
4164
+ useEffect(() => {
4165
+ if (railEl) railEl.style.display = hideOfficial ? 'none' : ''
4166
+ return () => { if (railEl) railEl.style.display = '' }
4167
+ }, [railEl, hideOfficial])
4168
+ const showClassic = classicWanted || railEl === null
4169
+ const showEnhance = enhanceWanted && railEl !== null && !hideOfficial
4170
+ return h(React.Fragment, null,
4171
+ showClassic ? h(TurnKeys, { sessions: props.sessions, connection: props.connection, onOpenWorkbench: props.onOpenWorkbench }) : null,
4172
+ showEnhance ? h(RailEnhancer, { sessions: props.sessions, railEl, onOpenWorkbench: props.onOpenWorkbench }) : null)
4173
+ }
4174
+
3629
4175
  // ── M11:钢琴键竖条挂载(DOM 注入 + 自愈)────────────────────────
3630
4176
  // 容器挂 document.body(dsh-navbar 同款模式):完全脱离会话区布局树,
3631
4177
  // 不干扰 conversation 的 flex/grid 布局;fixed 定位由组件自管理。
3632
4178
  // MutationObserver 自愈:容器丢失(布局重建)时自动恢复。
3633
- function mountPianoKeys(controller, sessions, connection) {
4179
+ function mountPianoKeys(controller, sessions, connection, settingsCtrl) {
3634
4180
  let root = undefined
3635
4181
  let container = undefined
3636
4182
  let waitObserver = null
@@ -3654,9 +4200,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3654
4200
  document.body.appendChild(container)
3655
4201
  try {
3656
4202
  root = createRoot(container)
3657
- root.render(h(TurnKeys, {
4203
+ root.render(h(NavRoot, {
3658
4204
  sessions,
3659
4205
  connection,
4206
+ settingsCtrl: settingsCtrl || null,
3660
4207
  onOpenWorkbench: () => controller.open(),
3661
4208
  }))
3662
4209
  syncChatVisibility()
@@ -3824,7 +4371,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3824
4371
  }
3825
4372
 
3826
4373
  // ── 插件体 ─────────────────────────────────────────────────────────
3827
- const inject = ['sessions', 'connection', 'slots', 'layout']
4374
+ const inject = ['sessions', 'connection', 'slots', 'layout', 'remote', 'remote.session']
3828
4375
 
3829
4376
  function apply(ctx) {
3830
4377
  try {
@@ -3838,6 +4385,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3838
4385
 
3839
4386
  const sessions = ctx.get('sessions')
3840
4387
  const connection = ctx.get('connection')
4388
+ // v0.1.2 RPC 双面适配(remote.session 新面优先,connection 旧面回退)。
4389
+ sfRemoteRef.current = sfBuildRemote(ctx, connection)
4390
+ // 设置控制器引用:settings.section 注册块内创建,挂载钢琴键/导航增强层时
4391
+ // 传给 NavRoot 做设置变更即时切换(不可用则为 null,走默认值)。
4392
+ let sfSettingsCtrl = null
3841
4393
 
3842
4394
  const state = { open: false, listeners: new Set() }
3843
4395
  const controller = {
@@ -3919,7 +4471,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3919
4471
  try {
3920
4472
  const settingsBinder = ctx.get('webUiSettings') ?? ctx.get('settingsScope')
3921
4473
  if (settingsBinder !== undefined && settingsBinder !== null && typeof settingsBinder.bind === 'function') {
3922
- const sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
4474
+ sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
3923
4475
  slots.inject('settings.section', () => {
3924
4476
  const unregister = slots.register({
3925
4477
  name: 'settings.section',
@@ -4047,7 +4599,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
4047
4599
  disposers.push(mountSidebarEntry(controller))
4048
4600
  disposers.push(mountBoard(controller, sessions, connection))
4049
4601
  // M11:钢琴键会话快切(会话页左侧常驻竖条);挂载失败不影响其他功能。
4050
- disposers.push(mountPianoKeys(controller, sessions, connection))
4602
+ disposers.push(mountPianoKeys(controller, sessions, connection, sfSettingsCtrl))
4051
4603
  } catch (error) {
4052
4604
  console.error('[dsh-session-flow] mount failed:', error)
4053
4605
  }
package/lib/host.js CHANGED
@@ -85,6 +85,9 @@ export const SETTINGS_DEFAULTS = {
85
85
  liveFollowPx: 40, // 实时吸底阈值(px)
86
86
  liveHistoryTurns: 3, // 详情页实时模式保留的历史回合数
87
87
  stallThresholdMin: 3, // 疑似卡死阈值(分钟)
88
+ railEnhance: true, // 官方轮次导航(TurnNavigatorRail)增强层开关(v0.1.2+)
89
+ railHideOfficial: false, // 完全屏蔽官方轮次导航(只留我们的悬浮条)
90
+ pianoClassicStrip: true, // 经典左侧轮次悬浮条(默认开,与官方 rail 并存;用户裁定 2026-09-05)
88
91
  }
89
92
 
90
93
  /** schemastery schema:settings 服务 resolve 时调用 schema(merged),并需要 toJSON()(describe 用)。 */
@@ -96,6 +99,9 @@ const SETTINGS_SCHEMA = z.object({
96
99
  liveFollowPx: z.number().step(1).min(20).max(120).default(SETTINGS_DEFAULTS.liveFollowPx),
97
100
  liveHistoryTurns: z.number().step(1).min(0).max(10).default(SETTINGS_DEFAULTS.liveHistoryTurns),
98
101
  stallThresholdMin: z.number().step(1).min(1).max(10).default(SETTINGS_DEFAULTS.stallThresholdMin),
102
+ railEnhance: z.boolean().default(SETTINGS_DEFAULTS.railEnhance),
103
+ railHideOfficial: z.boolean().default(SETTINGS_DEFAULTS.railHideOfficial),
104
+ pianoClassicStrip: z.boolean().default(SETTINGS_DEFAULTS.pianoClassicStrip),
99
105
  })
100
106
 
101
107
  /** 解析后的当前设置值(base + user 层);host 侧消费点(卡死阈值)从这里读。 */
@@ -5,7 +5,7 @@
5
5
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'node:fs'
6
6
  import { join } from 'node:path'
7
7
  import { homedir } from 'node:os'
8
- import { listSessionDirs, summarizeSessionFile } from './archive.js'
8
+ import { listSessionDirs, sessionFileOf, summarizeSessionFile } from './archive.js'
9
9
 
10
10
  export function dshHome() {
11
11
  return process.env.DSH_HOME || join(homedir(), '.dsh')
@@ -22,8 +22,10 @@ export function workspaceIndexFile(home, wsName) {
22
22
  return join(indexRoot(home), `index-${safe}.json`)
23
23
  }
24
24
 
25
- /** 索引格式版本:升级时旧缓存会被自动全量重扫一次(避免字段缺失)。 */
26
- const INDEX_VERSION = 4
25
+ /** 索引格式版本:升级时旧缓存会被自动全量重扫一次(避免字段缺失)。
26
+ * 5(2026-09-08):dsh v0.1.3 存档 v2 布局适配——存档文件改为 session.v2.jsonl.zstd
27
+ * (v1 冻结),强制全量重扫让摘要/统计全部来自 v2。 */
28
+ const INDEX_VERSION = 5
27
29
 
28
30
  export function readIndex(home, wsName) {
29
31
  try {
@@ -107,7 +109,9 @@ export function scanWorkspaceIndex(home, wsName, options = {}) {
107
109
  return { index, scanned, skipped, removed }
108
110
  }
109
111
 
110
- /** 按会话 id 在所有工作区中定位存档(返回 { workspace, dir, file } 或 null)。 */
112
+ /** 按会话 id 在所有工作区中定位存档(返回 { workspace, dir, file } 或 null)。
113
+ * 文件定位统一走 sessionFileOf(v2 优先,0.1.3 适配——此前硬编码 v1 文件名,
114
+ * 纯 v2 新会话全部 404「not found in archives」,钢琴条/详情/检索静默失效)。 */
111
115
  export function findWorkspaceOfSession(home, sessionId) {
112
116
  const root = join(home, 'sessions')
113
117
  if (!existsSync(root)) return null
@@ -115,10 +119,8 @@ export function findWorkspaceOfSession(home, sessionId) {
115
119
  const wsDir = join(root, wsName)
116
120
  try { if (!statSync(wsDir).isDirectory()) continue } catch { continue }
117
121
  const dir = join(wsDir, sessionId)
118
- const zstd = join(dir, 'session.jsonl.zstd')
119
- if (existsSync(zstd)) return { workspace: wsName, dir, file: zstd }
120
- const plain = join(dir, 'session.jsonl')
121
- if (existsSync(plain)) return { workspace: wsName, dir, file: plain }
122
+ const file = sessionFileOf(dir)
123
+ if (file !== null) return { workspace: wsName, dir, file }
122
124
  }
123
125
  return null
124
126
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-session-flow",
3
3
  "description": "DSH 会话回顾与归档插件:可折叠信息流 + 会话级汇总——总览工作台、折叠时间线、血缘树、双模式摘要、全文检索、ZIP 导出、实时跟踪、会话重命名(官方数据源对齐)、健康监控",
4
- "version": "1.2.0",
4
+ "version": "1.4.0",
5
5
  "type": "module",
6
6
  "main": "lib/host.js",
7
7
  "exports": {