dsh-session-flow 1.2.0 → 1.3.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
@@ -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
 
@@ -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 后重试)',
@@ -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
@@ -1721,16 +1896,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1721
1896
  }
1722
1897
  } catch (e) {}
1723
1898
  // 2) 运行时 history 桥接(在线子代理)→ host derive 得 light 结构
1724
- if (connection !== undefined && node.parentId !== undefined) {
1899
+ if (node.parentId !== undefined && sfRemoteRef.current) {
1725
1900
  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)
1901
+ const subVal = await sfRemoteRef.current.pageBySub(node.parentId, node.id, node.mode, 120)
1902
+ const subEvents = historyEventsOf(subVal)
1903
+ if (subEvents.length > 0) {
1904
+ const events = subEvents
1734
1905
  const derived = await api('derive', { events })
1735
1906
  if (derived && derived.ok) {
1736
1907
  // derive 返回完整 timeline——转成 light 结构
@@ -1848,8 +2019,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1848
2019
  })
1849
2020
  const trimmed = String(title || '').trim()
1850
2021
  if (trimmed === '' && session.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1851
- if (trimmed !== '' && canOfficialRename(conn)) {
1852
- return renameViaOfficial(conn, session.id, trimmed)
2022
+ if (trimmed !== '' && sfRemoteRef.current && sfRemoteRef.current.canRename) {
2023
+ return sfRemoteRef.current.rename(session.id, trimmed)
2024
+ .then((accepted) => { if (typeof accepted !== 'string') throw new Error(STR.renameFail); return accepted })
1853
2025
  .then((accepted) => { setTitleOverride(accepted); setRenaming(false); return true })
1854
2026
  .catch(() => legacy())
1855
2027
  }
@@ -1990,18 +2162,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1990
2162
  }
1991
2163
  setLiveState((s) => ({ ...s, phase: 'loading' }))
1992
2164
  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)) {
2165
+ // v0.1.2:session RPC 经双面适配器(remote.session.page / 旧 history)。
2166
+ const val = sfRemoteRef.current ? await sfRemoteRef.current.pageBySession(session.id, 400) : undefined
2167
+ if (val === undefined) {
2001
2168
  setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2002
2169
  return
2003
2170
  }
2004
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2171
+ const events = historyEventsOf(val)
2005
2172
  const derived = await api('derive', { events, now: Date.now() })
2006
2173
  if (!derived || !derived.ok) {
2007
2174
  setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
@@ -2638,19 +2805,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2638
2805
 
2639
2806
  const fetchLive = useRef(async () => {})
2640
2807
  fetchLive.current = async () => {
2641
- if (connection === undefined || connection.api === undefined || connection.api.sessions === undefined) {
2808
+ if (!sfRemoteRef.current) {
2642
2809
  setState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
2643
2810
  return
2644
2811
  }
2645
2812
  setState((s) => ({ ...s, phase: s.timeline === null ? 'loading' : 'ready' }))
2646
2813
  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)) {
2814
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 400)
2815
+ if (val === undefined) {
2650
2816
  setState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2651
2817
  return
2652
2818
  }
2653
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2819
+ const events = historyEventsOf(val)
2654
2820
  const derived = await api('derive', { events, now: Date.now() })
2655
2821
  if (!derived || !derived.ok) {
2656
2822
  setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
@@ -2741,6 +2907,33 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2741
2907
  })()
2742
2908
  return () => ac.abort()
2743
2909
  }, [props.connection])
2910
+ // v0.1.2 起 events.mux 消费面移除 → 改用 sessions.list 订阅:官方 rename 后
2911
+ // 列表行 byId[sessionId].title 由 title projection 即时更新(新控制器
2912
+ // dsh-api-session-controller rename 后 projections.apply('title') 即落列表),
2913
+ // 订阅回调秒级收集全量标题快照,浅比较防抖。与 mux 路径并存(旧版 dsh 仍走 mux)。
2914
+ useEffect(() => {
2915
+ const svc = props.sessions
2916
+ const list = svc && svc.list
2917
+ if (!list || typeof list.getSnapshot !== 'function' || typeof list.subscribe !== 'function') return undefined
2918
+ const collect = () => {
2919
+ const snap = list.getSnapshot()
2920
+ const byId = snap && snap.byId
2921
+ if (!byId) return
2922
+ const map = {}
2923
+ for (const id of Object.keys(byId)) {
2924
+ const t = byId[id] && byId[id].title
2925
+ if (typeof t === 'string' && t !== '') map[id] = t
2926
+ }
2927
+ setLiveTitles((prev) => {
2928
+ const pk = Object.keys(prev)
2929
+ const nk = Object.keys(map)
2930
+ if (pk.length === nk.length && nk.every((k) => prev[k] === map[k])) return prev
2931
+ return map
2932
+ })
2933
+ }
2934
+ collect()
2935
+ return list.subscribe(collect)
2936
+ }, [props.sessions])
2744
2937
 
2745
2938
  return detail === null
2746
2939
  ? h(SessionFlowOverview, {
@@ -2826,17 +3019,74 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2826
3019
  return undefined
2827
3020
  }
2828
3021
 
3022
+ // ── 共享:纯色矢量图标 + 轮次 meta chips(rail 增强层与钢琴键详情框共用)──
3023
+ // stroke=currentColor 随主题取色;不用 emoji(用户裁定 2026-09-05)。
3024
+ const SF_ICON_PATHS = {
3025
+ clock: '<circle cx="8" cy="8" r="6.3"/><path d="M8 4.6V8l2.4 1.5"/>',
3026
+ 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"/>',
3027
+ alert: '<circle cx="8" cy="8" r="6.3"/><path d="M8 5.2v3.2M8 11.2h.01"/>',
3028
+ 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"/>',
3029
+ }
3030
+ const sfIcon = (name) => {
3031
+ const span = document.createElement('span')
3032
+ span.className = 'sf-ico'
3033
+ 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>'
3034
+ return span
3035
+ }
3036
+ // 时长格式化(ms → 42s / 2m 14s;与 host 导出 fmtDur 同风格)。
3037
+ const sfFmtDur = (ms) => {
3038
+ if (!ms || ms <= 0) return ''
3039
+ const s = Math.round(ms / 1000)
3040
+ if (s < 60) return s + 's'
3041
+ const m = Math.floor(s / 60)
3042
+ return s % 60 === 0 ? m + 'm' : m + 'm ' + (s % 60) + 's'
3043
+ }
3044
+ // meta chips:耗时 / 工具调用 / 错误(红)/ 思考标记。无任何信息时返回 null。
3045
+ const buildTurnMeta = (entry) => {
3046
+ const meta = document.createElement('div')
3047
+ meta.className = 'sf-turnMeta'
3048
+ const chip = (icon, text, cls) => {
3049
+ const c = document.createElement('span')
3050
+ c.className = 'sf-turnMetaChip' + (cls ? ' ' + cls : '')
3051
+ c.appendChild(sfIcon(icon))
3052
+ const t = document.createElement('span')
3053
+ t.textContent = text
3054
+ c.appendChild(t)
3055
+ meta.appendChild(c)
3056
+ }
3057
+ if (entry.durMs > 0) chip('clock', sfFmtDur(entry.durMs))
3058
+ if (entry.tools > 0) chip('tool', STR.railMetaCalls.replace('{tools}', String(entry.tools)))
3059
+ if (entry.errors > 0) chip('alert', STR.railMetaErrors.replace('{errors}', String(entry.errors)), 'err')
3060
+ if (entry.thinking) chip('think', STR.railThinking)
3061
+ return meta.childNodes.length > 0 ? meta : null
3062
+ }
3063
+ // lightTurn → 共享字段(tools/errors/durMs/thinking/conclusion)。
3064
+ const ltMeta = (lt) => ({
3065
+ tools: lt.toolCount || 0,
3066
+ errors: lt.errorCount || 0,
3067
+ durMs: (typeof lt.startTime === 'number' && typeof lt.endTime === 'number' && lt.endTime > lt.startTime) ? lt.endTime - lt.startTime : 0,
3068
+ thinking: lt.hasThinking === true,
3069
+ })
3070
+ // getTurn → 工具分布文本(top4:pwsh ×6 · edit ×3);共享给 rail 与钢琴键。
3071
+ const toolDistOf = (turn) => {
3072
+ const counts = new Map()
3073
+ for (const st of (turn && turn.steps) || []) for (const c of st.toolCalls || []) counts.set(c.name, (counts.get(c.name) || 0) + 1)
3074
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4).map(([n, c]) => n + ' ×' + c).join(' · ')
3075
+ }
3076
+
2829
3077
  // ── M11:本会话轮次导航(会话页左侧常驻悬浮条目条,v3)──────────
2830
3078
  // 范围:只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区。
2831
- // 数据:官方消息行锚点 DOM 驱动(data-time-hover-rootdsh-navbar 同款机制,
2832
- // RPC 实时):用户行 = 无 data-turn-tail + 含 [class*=bubble]。
3079
+ // 数据:官方消息行锚点 DOM 驱动(v0.1.2 起 [data-chat-flow] [data-chat-flow-kind=user]
3080
+ // 旧版回退 data-time-hover-root;零 RPC 实时)。
2833
3081
  // 轮次 → 结论预览:按顺序索引映射 host get 的 lightTurns(用户消息摊平)。
2834
3082
  // 交互:点击滚动定位该轮、active 跟随视口第一条、窗口裁剪 + 滚轮/▲▼、
2835
3083
  // 键盘 ↑↓/Enter、hover 预览卡(该轮用户消息 + 结论)、右键唤起会话流工作台。
2836
3084
  // 样式:DeepSeek 网页版右侧悬浮消息导航风格(圆角卡片 + 单行截断文字),移到左侧。
2837
3085
  function TurnKeys(props) {
2838
3086
  const { sessions, onOpenWorkbench } = props
2839
- const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
3087
+ const [entries, setEntries] = useState([]) // [{turn, seq, preview, tools, errors, durMs, thinking}] host 全量用户轮次
3088
+ const distCache = useRef(new Map()) // turn → 工具分布文本(getTurn 懒加载缓存)
3089
+ const [, setDistTick] = useState(0) // distCache 到位后触发详情框重渲染
2840
3090
  const [activeIdx, setActiveIdx] = useState(-1)
2841
3091
  // T5 平滑滚动:连续滚动位置(浮点,单位=行)。替代原整数 winOffset 的「每格硬跳
2842
3092
  // 1 条」——滚轮增量累积(deltaMode 归一)+ 列表 transform 百分比位移(transition
@@ -2918,7 +3168,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2918
3168
  const flat = []
2919
3169
  for (const lt of json.lightTurns || []) {
2920
3170
  for (const u of lt.userMessages || []) {
2921
- flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview })
3171
+ flat.push({ turn: lt.turn, seq: u.seq, preview: u.preview, ...ltMeta(lt) })
2922
3172
  }
2923
3173
  }
2924
3174
  // 防御:同一 seq 只保留一条(避免同一条消息重复渲染 = 「最后一条显示两次」)。
@@ -2962,10 +3212,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2962
3212
  return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
2963
3213
  }, [currentId])
2964
3214
 
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)
3215
+ // DOM 行工具:用户消息行锚点。v0.1.2 起官方会话流重构进 dsh-client-ui-chat:
3216
+ // data-time-hover-root 移除,行结构 = [data-chat-flow] > [data-chat-flow-key]
3217
+ // data-chat-flow-kind 区分 user/steering/turn-process/…,官方内部查行同款
3218
+ // 选择器);旧版 dsh 回退 data-time-hover-root 锚点(dsh-navbar 同款机制)。
3219
+ // 零 RPC 实时。steering(随转消息)不算新轮次,不计入。
3220
+ const domRows = () => {
3221
+ const next = document.querySelectorAll('[data-chat-flow] [data-chat-flow-kind="user"]')
3222
+ if (next.length > 0) return [...next]
3223
+ return [...document.querySelectorAll('[data-time-hover-root]')]
3224
+ .filter((r) => !r.hasAttribute('data-pending-steering'))
3225
+ .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
3226
+ }
2969
3227
  const textOf = (row) => {
2970
3228
  const b = row.querySelector('[class*=bubble]')
2971
3229
  return b ? String(b.textContent || '').replace(/\s+/g, ' ').trim() : ''
@@ -3010,16 +3268,74 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3010
3268
  return null
3011
3269
  }
3012
3270
 
3271
+ // 未加载轮次 → 委托官方轮次导航引擎(v0.1.2+ 吸收官方独有能力):合成 click
3272
+ // 命中目标刻度,官方 navigate 自动加载未载入页并跳转(其引擎含 busy 态/加载锚定,
3273
+ // 优于我们的「只提示」)。合成事件 clientY 按官方 itemAtPointer 几何反解
3274
+ // (offset = clientY - rect.top + scrollTop - 6 = idx*10);官方处理挂在 nav 的
3275
+ // onClick 且只读 event.clientY,与事件落在哪个子元素无关(刻度按钮
3276
+ // pointer-events:none,不能直接 btn.click()——clientY=0 会误算成第一条)。
3277
+ const delegateToRail = (entry) => {
3278
+ try {
3279
+ const nav = document.querySelector(RAIL_SELECTOR)
3280
+ if (!nav) return false
3281
+ const turns = [...nav.querySelectorAll('button[aria-label]')]
3282
+ .map((b) => { const m = /(\d+)/.exec(b.getAttribute('aria-label') || ''); return m ? Number(m[1]) : null })
3283
+ const idx = turns.indexOf(entry.turn)
3284
+ if (idx < 0) return false
3285
+ const rect = nav.getBoundingClientRect()
3286
+ const scroller = nav.firstElementChild
3287
+ const st = scroller ? scroller.scrollTop : 0
3288
+ const y = rect.top + (idx * 10 + 6) - st
3289
+ nav.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: rect.left + 10, clientY: y }))
3290
+ return true
3291
+ } catch (e) { return false }
3292
+ }
3013
3293
  // 点击定位:先查该轮次是否已加载(DOM 中能否匹配到对应行)——
3014
- // 已加载 → 平滑滚动定位;未加载 → 只提示、不跳转(自动加载循环会让消息流
3015
- // 乱滚/跳错位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
3294
+ // 已加载 → 平滑滚动定位;未加载 → 委托官方 rail 引擎加载跳转(v0.1.2+);
3295
+ // rail 缺席(旧版 dsh)→ 只提示、不跳转(自动加载循环会让消息流乱滚/跳错
3296
+ // 位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
3016
3297
  const open = (entry) => {
3017
3298
  const row = findRow(entry)
3018
3299
  if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'start' }); return }
3300
+ if (delegateToRail(entry)) return
3019
3301
  setJumpFail(true)
3020
3302
  setTimeout(() => setJumpFail(false), 2600)
3021
3303
  }
3022
3304
 
3305
+ // 详情框 meta 注入(与 rail 增强层同款信息:矢量 chips + 工具分布行;
3306
+ // imperative ref 填充,data-sig 幂等)。工具分布懒加载:详情驻留 200ms 拉
3307
+ // getTurn 聚合(distCache 跨 hover 缓存,到位后 tick 触发 ref 重填)。
3308
+ const fillDetailMeta = (el, entry) => {
3309
+ if (!el) return
3310
+ const dist = distCache.current.get(entry.turn) || ''
3311
+ const sig = entry.turn + '|' + dist
3312
+ if (el.dataset.sig === sig) return
3313
+ el.dataset.sig = sig
3314
+ el.replaceChildren()
3315
+ const meta = buildTurnMeta(entry)
3316
+ if (meta) el.appendChild(meta)
3317
+ if (dist !== '') {
3318
+ const tools = document.createElement('div')
3319
+ tools.className = 'sf-rail-conclusionTools'
3320
+ tools.textContent = dist
3321
+ el.appendChild(tools)
3322
+ }
3323
+ }
3324
+ useEffect(() => {
3325
+ if (detailIdx === null || !currentId) return undefined
3326
+ const entry = entries[detailIdx]
3327
+ if (!entry || entry.tools === 0 || distCache.current.has(entry.turn)) return undefined
3328
+ const turn = entry.turn
3329
+ const timer = setTimeout(() => {
3330
+ api('getTurn', { sessionId: currentId, turn }).then((json) => {
3331
+ if (!json || !json.ok || !json.turn) return
3332
+ distCache.current.set(turn, toolDistOf(json.turn))
3333
+ setDistTick((n) => n + 1)
3334
+ }).catch(() => {})
3335
+ }, 200)
3336
+ return () => clearTimeout(timer)
3337
+ }, [detailIdx, currentId, entries])
3338
+
3023
3339
  // active:视口内最靠上的可见行 → 映射条目索引高亮(阅读位置跟随)。
3024
3340
  // 可见判定用 bottom 进入视口(含被顶部裁切一半的行)——此前 top>=0 会
3025
3341
  // 跳过被裁切行导致高亮偏晚一轮(T1 修复点二)。
@@ -3328,6 +3644,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3328
3644
  // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
3329
3645
  h('div', { className: 'pk-detail' + (detailEntry ? '' : ' hidden'), style: detailPos, onMouseEnter: () => showDetail(detailIdx), onMouseLeave: hideDetail },
3330
3646
  detailEntry && h('div', { key: 'h' + detailEntry.turn, className: 'pk-detailHead' }, 'T' + detailEntry.turn + ' · ' + STR.user),
3647
+ detailEntry && h('div', { key: 'm' + detailEntry.turn, className: 'pk-detailMeta', ref: (el) => fillDetailMeta(el, detailEntry) }),
3331
3648
  detailEntry && h('div', { key: 'b' + detailEntry.turn, className: 'pk-detailBody' }, detailEntry.preview || '(空)'),
3332
3649
  ),
3333
3650
  // 跳转状态提示:常驻渲染 + hidden 类(从下方淡入/淡出);未加载轮次提示。
@@ -3380,14 +3697,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3380
3697
  }
3381
3698
  if (liveStore.emit !== null) liveStore.emit({ ...liveStateKeep(), phase: 'loading' })
3382
3699
  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)) {
3700
+ const val = sfRemoteRef.current ? await sfRemoteRef.current.pageBySession(sid, 400) : undefined
3701
+ if (val === undefined) {
3386
3702
  liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail }
3387
3703
  if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3388
3704
  return
3389
3705
  }
3390
- const events = val.events.map((entry) => (entry && entry.event) || entry)
3706
+ const events = historyEventsOf(val)
3391
3707
  const derived = await api('derive', { events, now: Date.now() })
3392
3708
  if (!derived || !derived.ok) {
3393
3709
  liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: (derived && derived.error) || STR.liveFail }
@@ -3626,11 +3942,233 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3626
3942
  )
3627
3943
  }
3628
3944
 
3945
+ // ── M11×v0.1.2:官方轮次导航(TurnNavigatorRail)增强层 ─────────────
3946
+ // 官方 rail 无扩展槽位(chat 槽位仅 node/turnTail/assistant-actions/commandview),
3947
+ // 本层走 DOM 观察(家族传统:观察器 + 自愈重挂),挂载我们的独有增量:
3948
+ // 1) hover 结论段注入官方预览卡:该轮「结论摘要」(官方预览只有 prompt/response
3949
+ // 片段)+ 工具统计(错误角标方案已撤——开发会话多数轮次都有工具错误,
3950
+ // 刻度全员红点等于无信息,用户实机裁定 2026-09-05);
3951
+ // 2) 右键 rail → 唤起会话流工作台。
3952
+ // 定位:nav[aria-label=轮次导航|Turn navigation];几何换算复刻官方 itemAtPointer
3953
+ // (TURN_SPACING 10px / RAIL_INSET 6px + scroller.scrollTop);turn 号从刻度按钮
3954
+ // aria-label(跳转到第 N 轮 / Jump to turn N)提取,索引↔turn 精确映射,不依赖
3955
+ // 「index+1 == turn」假设。官方 navigate 引擎(含未载入轮次自动加载跳转)原样保留,
3956
+ // 我们不拦截点击。
3957
+ const RAIL_SELECTOR = 'nav[aria-label="轮次导航"], nav[aria-label="Turn navigation"]'
3958
+ function RailEnhancer(props) {
3959
+ const { sessions, railEl, onOpenWorkbench } = props
3960
+ const [turnData, setTurnData] = useState([]) // [{turn, preview, conclusion, tools, errors}]
3961
+ const [hoverInfo, setHoverInfo] = useState(null) // {turn, clientY, railLeft}
3962
+ const [, force] = useState(0)
3963
+
3964
+ // 订阅会话列表变化:切换会话 → 重拉条目。
3965
+ useEffect(() => {
3966
+ if (sessions && sessions.list && typeof sessions.list.subscribe === 'function') {
3967
+ return sessions.list.subscribe(() => force((n) => n + 1))
3968
+ }
3969
+ return undefined
3970
+ }, [sessions])
3971
+
3972
+ const currentId = (() => {
3973
+ try {
3974
+ const s = sessions && sessions.list && typeof sessions.list.getSnapshot === 'function' ? sessions.list.getSnapshot() : null
3975
+ return s && s.current ? s.current : null
3976
+ } catch (e) { return null }
3977
+ })()
3978
+
3979
+ // 条目数据:host get lightTurns(每轮一条,对齐官方 rail item 粒度)。
3980
+ // 轮询节奏同钢琴键:DOM 变化切 3s 高频,空闲 15s 兜底。
3981
+ useEffect(() => {
3982
+ if (!currentId) { setTurnData([]); return undefined }
3983
+ let alive = true
3984
+ const refresh = () => {
3985
+ api('get', { sessionId: currentId }).then((json) => {
3986
+ if (!alive || !json || !json.ok) return
3987
+ const rows = (json.lightTurns || []).map((lt) => ({
3988
+ turn: lt.turn,
3989
+ preview: lt.userMessages && lt.userMessages[0] ? String(lt.userMessages[0].preview || '') : '',
3990
+ conclusion: String(lt.conclusionPreview || ''),
3991
+ tools: lt.toolCount || 0,
3992
+ errors: lt.errorCount || 0,
3993
+ durMs: (typeof lt.startTime === 'number' && typeof lt.endTime === 'number' && lt.endTime > lt.startTime) ? lt.endTime - lt.startTime : 0,
3994
+ thinking: lt.hasThinking === true,
3995
+ }))
3996
+ setTurnData((prev) => {
3997
+ if (prev.length === rows.length && prev.length > 0 && prev[prev.length - 1].turn === rows[rows.length - 1].turn) return prev
3998
+ return rows
3999
+ })
4000
+ }).catch(() => {})
4001
+ }
4002
+ const ACTIVE_MS = 3000
4003
+ const IDLE_MS = 15000
4004
+ let iv = null
4005
+ const schedule = (delay) => { if (iv) clearInterval(iv); iv = setInterval(refresh, delay) }
4006
+ let flowObs = null
4007
+ let lastDomRefresh = 0
4008
+ const onDomChange = () => {
4009
+ if (!alive) return
4010
+ const now = Date.now()
4011
+ if (now - lastDomRefresh < 1000) return
4012
+ lastDomRefresh = now
4013
+ refresh()
4014
+ schedule(ACTIVE_MS)
4015
+ }
4016
+ const flowEl = document.querySelector('[data-chat-flow=""]') || document.querySelector('[data-chat-flow]')
4017
+ if (flowEl) {
4018
+ flowObs = new MutationObserver(onDomChange)
4019
+ flowObs.observe(flowEl, { childList: true, subtree: true })
4020
+ }
4021
+ refresh()
4022
+ schedule(IDLE_MS)
4023
+ return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
4024
+ }, [currentId])
4025
+
4026
+ // rail 交互:hover 几何换算 + 右键唤起工作台。
4027
+ useEffect(() => {
4028
+ if (!railEl) { setHoverInfo(null); return undefined }
4029
+ const scroller = railEl.firstElementChild // 官方结构:nav.frame > div.scroller
4030
+ const railTurns = () => [...railEl.querySelectorAll('button[aria-label]')]
4031
+ .map((b) => { const m = /(\d+)/.exec(b.getAttribute('aria-label') || ''); return m ? Number(m[1]) : null })
4032
+ .filter((n) => n !== null)
4033
+ const onMove = (e) => {
4034
+ const rect = railEl.getBoundingClientRect()
4035
+ const st = scroller ? scroller.scrollTop : 0
4036
+ const idx = Math.max(0, Math.round((e.clientY - rect.top + st - 6) / 10))
4037
+ const turns = railTurns()
4038
+ const turn = idx < turns.length ? turns[idx] : null
4039
+ setHoverInfo((prev) => {
4040
+ const prevTurn = prev === null ? null : prev.turn
4041
+ return prevTurn === turn ? prev : (turn === null ? null : { turn })
4042
+ })
4043
+ }
4044
+ const onLeave = () => setHoverInfo(null)
4045
+ const onContext = (e) => { e.preventDefault(); onOpenWorkbench() }
4046
+ railEl.addEventListener('pointermove', onMove)
4047
+ railEl.addEventListener('pointerleave', onLeave)
4048
+ railEl.addEventListener('contextmenu', onContext)
4049
+ return () => {
4050
+ railEl.removeEventListener('pointermove', onMove)
4051
+ railEl.removeEventListener('pointerleave', onLeave)
4052
+ railEl.removeEventListener('contextmenu', onContext)
4053
+ }
4054
+ }, [railEl, onOpenWorkbench])
4055
+
4056
+ // 工具分布懒加载(独特信息维度,官方 preview 只有 prompt/response 片段):
4057
+ // hover 驻留 250ms 后拉 getTurn,聚合工具名 ×次数 top4(防扫过时每刻度一请求)。
4058
+ const [toolDist, setToolDist] = useState(null) // {turn, text}
4059
+ useEffect(() => {
4060
+ if (!railEl || hoverInfo === null || !currentId) { setToolDist(null); return undefined }
4061
+ const turn = hoverInfo.turn
4062
+ const timer = setTimeout(() => {
4063
+ api('getTurn', { sessionId: currentId, turn }).then((json) => {
4064
+ if (!json || !json.ok || !json.turn) return
4065
+ const text = toolDistOf(json.turn)
4066
+ setToolDist((prev) => (prev && prev.turn === turn && prev.text === text) ? prev : { turn, text })
4067
+ }).catch(() => {})
4068
+ }, 250)
4069
+ return () => clearTimeout(timer)
4070
+ }, [railEl, hoverInfo, currentId])
4071
+
4072
+ // hover 增强段注入官方预览卡内部(单卡融合——另起一卡会与官方预览上下堆叠成
4073
+ // 两大块,实测很丑)。官方 preview 容器 = rail 内 class 以 _preview 结尾的元素
4074
+ //(CSS module hash 前缀 + preview/previewPrompt/previewResponse 三段,用
4075
+ // /(?:^|_)preview(?:$|\s)/ 精确命中容器);React 复用 preview 元素跨轮次渲染 →
4076
+ // data-sig 核对(轮次+工具分布到位状态),变化即重建;observer 监视 rail 子树,
4077
+ // preview 出现/消失/被重渲染时自愈。**独特性原则(用户裁定 2026-09-05):只给
4078
+ // 官方 preview 没有的信息维度——耗时 / 工具统计与分布 / 错误 / 思考标记;
4079
+ // 结论不再展示(与官方 response 片段重复,用户二次裁定撤下);图标一律纯色
4080
+ // 矢量(stroke=currentColor),不用 emoji**。
4081
+ // 高度解禁:官方 preview 固定 --turn-preview-height:100px 且溢出裁剪——注入段
4082
+ // 会被裁成「残缺卡」(实测踩坑);注入期间内联 height:auto + maxHeight 放开,
4083
+ // 撤注入时还原内联样式。
4084
+ useEffect(() => {
4085
+ if (!railEl) return undefined
4086
+ const findPreview = () => [...railEl.querySelectorAll('[class*=preview]')]
4087
+ .find((el) => /(?:^|_)preview(?:$|\s)/.test(String(el.className)))
4088
+ const cleanup = () => {
4089
+ for (const n of railEl.querySelectorAll('.sf-rail-conclusion')) n.remove()
4090
+ const p = findPreview()
4091
+ if (p) { p.style.height = ''; p.style.maxHeight = ''; p.style.overflow = '' }
4092
+ }
4093
+ const enhance = () => {
4094
+ const preview = findPreview()
4095
+ const turn = hoverInfo === null ? null : hoverInfo.turn
4096
+ const entry = turn === null ? undefined : turnData.find((t) => t.turn === turn)
4097
+ if (!preview || !entry || (entry.durMs === 0 && entry.tools === 0)) { cleanup(); return }
4098
+ preview.style.height = 'auto'
4099
+ preview.style.maxHeight = '280px'
4100
+ preview.style.overflow = 'hidden'
4101
+ let node = preview.querySelector(':scope > .sf-rail-conclusion')
4102
+ if (!node) {
4103
+ node = document.createElement('div')
4104
+ node.className = 'sf-rail-conclusion'
4105
+ preview.appendChild(node)
4106
+ }
4107
+ const dist = toolDist !== null && toolDist.turn === entry.turn ? toolDist.text : ''
4108
+ const sig = entry.turn + '|' + dist
4109
+ if (node.dataset.sig === sig) return
4110
+ node.dataset.sig = sig
4111
+ node.replaceChildren()
4112
+ // meta chips 行(纯色矢量图标):耗时 · n 次工具 · m 错误(红)· 含思考
4113
+ const meta = buildTurnMeta(entry)
4114
+ if (meta) node.appendChild(meta)
4115
+ // 工具分布行(懒加载到位后经 sig 变化重建本节点)
4116
+ if (dist !== '') {
4117
+ const tools = document.createElement('div')
4118
+ tools.className = 'sf-rail-conclusionTools'
4119
+ tools.textContent = dist
4120
+ node.appendChild(tools)
4121
+ }
4122
+ }
4123
+ enhance()
4124
+ const obs = new MutationObserver(enhance)
4125
+ obs.observe(railEl, { childList: true, subtree: true })
4126
+ return () => { obs.disconnect(); cleanup() }
4127
+ }, [railEl, hoverInfo, turnData, toolDist])
4128
+
4129
+ return null
4130
+ }
4131
+
4132
+ // ── 导航总挂载:官方 rail 增强层 ⇄ 经典钢琴键悬浮条 ────────────────
4133
+ // 默认(v0.1.2+,用户裁定 2026-09-05):经典悬浮条默认开启(pianoClassicStrip
4134
+ // 默认 true),与官方 rail 增强层并存——左右双导航各司其职(我们:预览卡/右键
4135
+ // 工作台/滚轮窗口/键盘;官方:完整历史刻度);设置里可各自关闭。旧版 dsh
4136
+ // (无官方 rail)railEl=null 时 classic 恒 true,功能不丢。
4137
+ function NavRoot(props) {
4138
+ const { settingsCtrl } = props
4139
+ const [, force] = useState(0)
4140
+ const [railEl, setRailEl] = useState(null)
4141
+ useEffect(() => (settingsCtrl ? settingsCtrl.subscribe(() => force((n) => n + 1)) : undefined), [settingsCtrl])
4142
+ useEffect(() => {
4143
+ const find = () => {
4144
+ const nav = document.querySelector(RAIL_SELECTOR)
4145
+ setRailEl((prev) => (prev === nav || (prev === null && nav === null) ? prev : nav))
4146
+ }
4147
+ find()
4148
+ const obs = new MutationObserver(find)
4149
+ obs.observe(document.body, { childList: true, subtree: true })
4150
+ return () => obs.disconnect()
4151
+ }, [])
4152
+ const classicWanted = sfSettings.current.pianoClassicStrip === true
4153
+ const enhanceWanted = sfSettings.current.railEnhance !== false
4154
+ // railHideOfficial:完全屏蔽官方刻度条(只留我们的悬浮条);隐藏期间增强层不挂。
4155
+ const hideOfficial = sfSettings.current.railHideOfficial === true
4156
+ useEffect(() => {
4157
+ if (railEl) railEl.style.display = hideOfficial ? 'none' : ''
4158
+ return () => { if (railEl) railEl.style.display = '' }
4159
+ }, [railEl, hideOfficial])
4160
+ const showClassic = classicWanted || railEl === null
4161
+ const showEnhance = enhanceWanted && railEl !== null && !hideOfficial
4162
+ return h(React.Fragment, null,
4163
+ showClassic ? h(TurnKeys, { sessions: props.sessions, connection: props.connection, onOpenWorkbench: props.onOpenWorkbench }) : null,
4164
+ showEnhance ? h(RailEnhancer, { sessions: props.sessions, railEl, onOpenWorkbench: props.onOpenWorkbench }) : null)
4165
+ }
4166
+
3629
4167
  // ── M11:钢琴键竖条挂载(DOM 注入 + 自愈)────────────────────────
3630
4168
  // 容器挂 document.body(dsh-navbar 同款模式):完全脱离会话区布局树,
3631
4169
  // 不干扰 conversation 的 flex/grid 布局;fixed 定位由组件自管理。
3632
4170
  // MutationObserver 自愈:容器丢失(布局重建)时自动恢复。
3633
- function mountPianoKeys(controller, sessions, connection) {
4171
+ function mountPianoKeys(controller, sessions, connection, settingsCtrl) {
3634
4172
  let root = undefined
3635
4173
  let container = undefined
3636
4174
  let waitObserver = null
@@ -3654,9 +4192,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3654
4192
  document.body.appendChild(container)
3655
4193
  try {
3656
4194
  root = createRoot(container)
3657
- root.render(h(TurnKeys, {
4195
+ root.render(h(NavRoot, {
3658
4196
  sessions,
3659
4197
  connection,
4198
+ settingsCtrl: settingsCtrl || null,
3660
4199
  onOpenWorkbench: () => controller.open(),
3661
4200
  }))
3662
4201
  syncChatVisibility()
@@ -3824,7 +4363,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3824
4363
  }
3825
4364
 
3826
4365
  // ── 插件体 ─────────────────────────────────────────────────────────
3827
- const inject = ['sessions', 'connection', 'slots', 'layout']
4366
+ const inject = ['sessions', 'connection', 'slots', 'layout', 'remote', 'remote.session']
3828
4367
 
3829
4368
  function apply(ctx) {
3830
4369
  try {
@@ -3838,6 +4377,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3838
4377
 
3839
4378
  const sessions = ctx.get('sessions')
3840
4379
  const connection = ctx.get('connection')
4380
+ // v0.1.2 RPC 双面适配(remote.session 新面优先,connection 旧面回退)。
4381
+ sfRemoteRef.current = sfBuildRemote(ctx, connection)
4382
+ // 设置控制器引用:settings.section 注册块内创建,挂载钢琴键/导航增强层时
4383
+ // 传给 NavRoot 做设置变更即时切换(不可用则为 null,走默认值)。
4384
+ let sfSettingsCtrl = null
3841
4385
 
3842
4386
  const state = { open: false, listeners: new Set() }
3843
4387
  const controller = {
@@ -3919,7 +4463,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3919
4463
  try {
3920
4464
  const settingsBinder = ctx.get('webUiSettings') ?? ctx.get('settingsScope')
3921
4465
  if (settingsBinder !== undefined && settingsBinder !== null && typeof settingsBinder.bind === 'function') {
3922
- const sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
4466
+ sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
3923
4467
  slots.inject('settings.section', () => {
3924
4468
  const unregister = slots.register({
3925
4469
  name: 'settings.section',
@@ -4047,7 +4591,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
4047
4591
  disposers.push(mountSidebarEntry(controller))
4048
4592
  disposers.push(mountBoard(controller, sessions, connection))
4049
4593
  // M11:钢琴键会话快切(会话页左侧常驻竖条);挂载失败不影响其他功能。
4050
- disposers.push(mountPianoKeys(controller, sessions, connection))
4594
+ disposers.push(mountPianoKeys(controller, sessions, connection, sfSettingsCtrl))
4051
4595
  } catch (error) {
4052
4596
  console.error('[dsh-session-flow] mount failed:', error)
4053
4597
  }
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 侧消费点(卡死阈值)从这里读。 */
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.3.0",
5
5
  "type": "module",
6
6
  "main": "lib/host.js",
7
7
  "exports": {