dsh-session-flow 1.1.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.
Files changed (3) hide show
  1. package/lib/client.js +1457 -254
  2. package/lib/host.js +69 -5
  3. package/package.json +4 -1
package/lib/client.js CHANGED
@@ -13,7 +13,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
13
13
  var module = { exports: {} }; var exports = module.exports;
14
14
 
15
15
  const React = require('react')
16
- const { useState, useEffect, useMemo, useRef } = React
16
+ const { useState, useEffect, useMemo, useRef, useSyncExternalStore } = React
17
17
  const h = React.createElement
18
18
  const { createRoot } = require('react-dom/client')
19
19
 
@@ -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
 
@@ -40,6 +43,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
40
43
  pianoJumpFail: '该轮次尚未加载:请在会话区向上滚动加载历史后再点击',
41
44
  fullText: '全文检索', fullTextSearching: '搜索中…', fullTextPartial: '已扫描 {N}/{M} 个会话(结果未完整)', fullTextNone: '全文无命中', fullTextFail: '全文检索失败',
42
45
  dock: '并入右栏', dockExit: '退出并入', dockHint: '会话流已并入右侧栏 · 随当前会话切换', dockEmpty: '打开会话后可在此查看会话流',
46
+ dockLiveHint: '实时:只显示开播后的对话,不加载历史回合', dockLiveEmpty: '实时中 · 等待新对话…', dockLiveEnterFail: '当前会话已结束或不可实时',
47
+ dockPinNotice: '📌 直播已钉住原会话(当前已切换会话)', dockPinRebind: '转播当前会话',
43
48
  rename: '重命名', renamePlaceholder: '输入新标题…', renameFail: '重命名失败', origTitle: '原名',
44
49
  search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
45
50
  sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
@@ -72,16 +77,35 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
72
77
  summaryStale: '⚠ 摘要生成后有新对话,内容可能已不准确,建议重新生成',
73
78
  llmSummary: '生成 LLM 摘要', llmGenerating: '生成中…', llmFail: '生成失败', summaryRuleTag: '规则', summaryLlmTag: 'LLM',
74
79
  cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
75
- healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死',
80
+ healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死', healthIdle: '空闲',
81
+ agoJustNow: '刚刚', agoMin: '{N} 分钟前', agoHour: '{N} 小时前', agoDay: '{N} 天前',
76
82
  healthSilentMin: '静默 {N} 分钟', healthSilentSec: '静默 {N} 秒',
77
83
  healthChipTitle: '会话健康状态(会话流)· 点击打开详情',
78
84
  renameClearBlocked: '该会话标题由官方会话管理:请输入新标题(不支持清空)',
85
+ settingsTitle: '会话流', settingsDesc: '调整轮次导航、实时跟踪与健康监控的行为参数。保存后立即生效(轮次悬浮条行数在重进会话页后完全生效)。',
86
+ setGroupPiano: '轮次悬浮条', setGroupLive: '实时跟踪', setGroupHealth: '健康监控', setGroupNav: '轮次导航(v0.1.2+)',
87
+ setPianoWindow: '可见行数', setPianoWindowHint: '悬浮条同时显示的轮次条数(6–18)',
88
+ setPianoWheelSpeed: '滚轮灵敏度', setPianoWheelSpeedHint: '每像素滚动的行数(0.005–0.03),越大滚得越快',
89
+ setPianoSnapMs: '静止吸附延迟(ms)', setPianoSnapMsHint: '滚轮停止多少毫秒后吸附到整行(100–400)',
90
+ setLivePollMs: '轮询间隔(ms)', setLivePollMsHint: '实时模式的刷新间隔(1500–10000),下次开播生效',
91
+ setLiveFollowPx: '吸底阈值(px)', setLiveFollowPxHint: '距底部多少像素内视为「在底部」并跟随滚动(20–120)',
92
+ setLiveHistoryTurns: '保留历史回合', setLiveHistoryTurnsHint: '实时模式下保留的历史回合数(0–10),下次开播生效',
93
+ setStallThresholdMin: '卡死阈值(分钟)', setStallThresholdMinHint: '运行中但静默超过该时长判定为疑似卡死(1–10)',
94
+ setRailEnhance: '官方轮次导航增强', setRailEnhanceHint: '在官方右侧轮次导航上叠加:预览卡内追加结论摘要、右键唤起工作台',
95
+ setPianoClassic: '经典左侧悬浮条', setPianoClassicHint: '插件自带的左侧轮次悬浮条(默认开,与官方右侧导航并存;点击未载入轮次可委托官方引擎自动加载跳转)',
96
+ setRailHide: '隐藏官方轮次导航', setRailHideHint: '完全屏蔽官方右侧刻度条,只保留我们的悬浮条(开启后「官方轮次导航增强」自动不生效)',
97
+ railMetaCalls: '{tools} 次工具', railMetaErrors: '{errors} 错误', railThinking: '含思考',
98
+ setSave: '保存', setDiscard: '放弃更改', setSaveFail: '部分设置未保存成功,请检查后重试',
99
+ setReset: '恢复默认', setOverridden: '已修改', setInvalid: '超出范围或格式无效',
100
+ setUnavailable: '当前环境未暴露设置服务,无法编辑(请升级 dsh 后重试)',
79
101
  } : {
80
102
  entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
81
103
  pianoTitle: 'Turns', pianoNew: 'New session', pianoFav: 'Favorite', pianoUnfav: 'Unfavorite', pianoFavs: 'Favorites', pianoOpenInFlow: 'Open in Session Flow',
82
104
  pianoJumpFail: 'Turn not loaded — scroll up in the conversation to load history, then retry',
83
105
  fullText: 'Full-text', fullTextSearching: 'Searching…', fullTextPartial: 'Scanned {N}/{M} sessions (partial)', fullTextNone: 'No full-text matches', fullTextFail: 'Full-text search failed',
84
106
  dock: 'Dock right', dockExit: 'Undock', dockHint: 'Session Flow docked · follows current session', dockEmpty: 'Open a session to view its flow here',
107
+ dockLiveHint: 'Live: only the conversation since enabled, no history', dockLiveEmpty: 'Live · waiting for new turns…', dockLiveEnterFail: 'Session ended or not live-capable',
108
+ dockPinNotice: '📌 Live pinned to previous session (session switched)', dockPinRebind: 'Follow current session',
85
109
  rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
86
110
  search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
87
111
  sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
@@ -115,12 +139,238 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
115
139
  summaryStale: '⚠ New conversation since summary was generated; content may be outdated — regenerate',
116
140
  llmSummary: 'Generate LLM summary', llmGenerating: 'Generating…', llmFail: 'Failed', summaryRuleTag: 'rule', summaryLlmTag: 'LLM',
117
141
  cacheHint: "Only this plugin's cache (auto-rebuilds); never touches DSH session archives or other data", cacheLimit: 'Timeline cache limit',
118
- healthActive: 'Active', healthToolWait: 'Tool running', healthQuiet: 'Quiet', healthStalled: 'Likely stalled',
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',
119
144
  healthSilentMin: 'silent {N} min', healthSilentSec: 'silent {N} s',
120
145
  healthChipTitle: 'Session health (Session Flow) · click to open detail',
121
146
  renameClearBlocked: 'Title is managed by the official session: enter a new title (clearing unsupported)',
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+)',
149
+ setPianoWindow: 'Visible rows', setPianoWindowHint: 'How many turn keys the strip shows at once (6–18)',
150
+ setPianoWheelSpeed: 'Wheel speed', setPianoWheelSpeedHint: 'Rows scrolled per pixel (0.005–0.03); larger scrolls faster',
151
+ setPianoSnapMs: 'Snap delay (ms)', setPianoSnapMsHint: 'Delay after the wheel stops before snapping to a whole row (100–400)',
152
+ setLivePollMs: 'Poll interval (ms)', setLivePollMsHint: 'Live refresh interval (1500–10000); applies to the next live session',
153
+ setLiveFollowPx: 'Follow threshold (px)', setLiveFollowPxHint: 'Distance from the bottom that still counts as "at bottom" and follows (20–120)',
154
+ setLiveHistoryTurns: 'History turns kept', setLiveHistoryTurnsHint: 'History turns kept in live mode (0–10); applies to the next live session',
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',
160
+ setSave: 'Save', setDiscard: 'Discard', setSaveFail: 'Some settings were not saved — check and retry',
161
+ setReset: 'Reset', setOverridden: 'Modified', setInvalid: 'Out of range or invalid format',
162
+ setUnavailable: 'The settings service is not exposed in this environment (upgrade dsh and retry)',
122
163
  }
123
164
 
165
+ // ── 插件设置(settings.section 独立 tab)──────────────────────────
166
+ // Host 注册 settings 命名空间 'session-flow'(lib/host.js SETTINGS_DEFAULTS 同源);
167
+ // client 经 settingsScope 读写(pet 同款:优先 webUiSettings 桥,回退 ctx.settingsScope)。
168
+ // 全部消费点读 sfSettings.current.xxx,缺省 = 原硬编码值——无设置时行为零变化。
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 }
170
+ const sfSettings = { current: { ...SETTINGS_DEFAULTS } }
171
+ const SETTING_GROUPS = [
172
+ { titleKey: 'setGroupPiano', fields: ['pianoWindow', 'pianoWheelSpeed', 'pianoSnapMs'] },
173
+ { titleKey: 'setGroupLive', fields: ['livePollMs', 'liveFollowPx', 'liveHistoryTurns'] },
174
+ { titleKey: 'setGroupHealth', fields: ['stallThresholdMin'] },
175
+ { titleKey: 'setGroupNav', fields: ['railEnhance', 'railHideOfficial', 'pianoClassicStrip'] },
176
+ ]
177
+ const SETTING_FIELD_SPECS = {
178
+ pianoWindow: { min: 6, max: 18, integer: true, labelKey: 'setPianoWindow', hintKey: 'setPianoWindowHint' },
179
+ pianoWheelSpeed: { min: 0.005, max: 0.03, integer: false, labelKey: 'setPianoWheelSpeed', hintKey: 'setPianoWheelSpeedHint' },
180
+ pianoSnapMs: { min: 100, max: 400, integer: true, labelKey: 'setPianoSnapMs', hintKey: 'setPianoSnapMsHint' },
181
+ livePollMs: { min: 1500, max: 10000, integer: true, labelKey: 'setLivePollMs', hintKey: 'setLivePollMsHint' },
182
+ liveFollowPx: { min: 20, max: 120, integer: true, labelKey: 'setLiveFollowPx', hintKey: 'setLiveFollowPxHint' },
183
+ liveHistoryTurns: { min: 0, max: 10, integer: true, labelKey: 'setLiveHistoryTurns', hintKey: 'setLiveHistoryTurnsHint' },
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' },
188
+ }
189
+ function formatSettingValue(v) { return v === undefined || v === null ? '' : String(v) }
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
+ }
195
+ const n = Number(String(text).trim())
196
+ if (!Number.isFinite(n) || n < spec.min || n > spec.max) return undefined
197
+ if (spec.integer && !Number.isInteger(n)) return undefined
198
+ return n
199
+ }
200
+
201
+ // 设置表单控制器:草稿(staged edits)+ scope 读写(pet CardForm 简化版——
202
+ // 同样是「保存才落盘」语义;getSnapshot 缓存快照供 useSyncExternalStore)。
203
+ class SfSettingsController {
204
+ constructor(scope) {
205
+ this.scope = scope
206
+ this.listeners = new Set()
207
+ this.draft = new Map() // key -> { text, clear }
208
+ this.saving = false
209
+ this.failed = false
210
+ this.cached = null
211
+ this.unsubscribe = scope.subscribe(() => this.notify())
212
+ this.notify()
213
+ }
214
+ dispose() {
215
+ try { if (this.unsubscribe) this.unsubscribe() } catch (e) {}
216
+ this.unsubscribe = null
217
+ this.listeners.clear()
218
+ }
219
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) }
220
+ // 快照值合并进模块级桥(所有功能代码的消费点;settings 变化即时下发)。
221
+ applySnapshotToBridge() {
222
+ try {
223
+ const snap = this.scope.getSnapshot()
224
+ if (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object') {
225
+ sfSettings.current = { ...SETTINGS_DEFAULTS, ...snap.value }
226
+ }
227
+ } catch (e) {}
228
+ }
229
+ notify() {
230
+ this.applySnapshotToBridge()
231
+ this.cached = this.buildSnapshot()
232
+ for (const fn of this.listeners) fn()
233
+ }
234
+ getSnapshot() {
235
+ if (this.cached === null) this.cached = this.buildSnapshot()
236
+ return this.cached
237
+ }
238
+ buildSnapshot() {
239
+ let snap = null
240
+ try { snap = this.scope.getSnapshot() } catch (e) {}
241
+ const status = snap ? String(snap.status || 'unavailable') : 'unavailable'
242
+ const value = { ...SETTINGS_DEFAULTS, ...(snap && snap.value ? snap.value : {}) }
243
+ const user = (snap && snap.user && typeof snap.user === 'object') ? snap.user : {}
244
+ const fields = {}
245
+ for (const key of Object.keys(SETTING_FIELD_SPECS)) {
246
+ const spec = SETTING_FIELD_SPECS[key]
247
+ const staged = this.draft.get(key)
248
+ const stored = Object.prototype.hasOwnProperty.call(user, key)
249
+ if (staged === undefined) {
250
+ fields[key] = { text: formatSettingValue(value[key]), overridden: stored, invalid: false, clear: false }
251
+ } else {
252
+ fields[key] = { text: staged.text, overridden: !staged.clear, invalid: !staged.clear && parseSettingValue(spec, staged.text) === undefined, clear: staged.clear }
253
+ }
254
+ }
255
+ return {
256
+ status,
257
+ writable: snap ? snap.writable !== false : false,
258
+ fields,
259
+ dirty: this.draft.size > 0,
260
+ invalid: Object.keys(fields).some((k) => fields[k].invalid),
261
+ saving: this.saving,
262
+ failed: this.failed,
263
+ }
264
+ }
265
+ edit(key, text) { this.draft.set(key, { text, clear: false }); this.failed = false; this.notify() }
266
+ resetField(key) {
267
+ // 恢复默认 = 清除 user 层覆盖(草稿显示 base 值,保存时 unset)。
268
+ let baseText = formatSettingValue(SETTINGS_DEFAULTS[key])
269
+ try {
270
+ const snap = this.scope.getSnapshot()
271
+ if (snap && snap.base && snap.base[key] !== undefined) baseText = formatSettingValue(snap.base[key])
272
+ } catch (e) {}
273
+ this.draft.set(key, { text: baseText, clear: true })
274
+ this.failed = false
275
+ this.notify()
276
+ }
277
+ discard() { this.draft.clear(); this.failed = false; this.notify() }
278
+ async save() {
279
+ const snap = this.getSnapshot()
280
+ if (this.draft.size === 0 || this.saving || snap.invalid) return
281
+ const pending = new Map(this.draft)
282
+ this.saving = true
283
+ this.failed = false
284
+ this.notify()
285
+ const landed = new Set()
286
+ for (const [key, staged] of pending) {
287
+ try {
288
+ if (staged.clear) { await this.scope.unset(key); landed.add(key); continue }
289
+ const v = parseSettingValue(SETTING_FIELD_SPECS[key], staged.text)
290
+ if (v === undefined) continue
291
+ await this.scope.set(key, v)
292
+ landed.add(key)
293
+ } catch (e) {}
294
+ }
295
+ for (const [key, before] of pending) if (landed.has(key) && this.draft.get(key) === before) this.draft.delete(key)
296
+ this.saving = false
297
+ this.failed = landed.size !== pending.size
298
+ this.notify()
299
+ }
300
+ }
301
+
302
+ // 设置弹窗「会话流」分节:分组表单(左标签+说明,右数字输入),底部保存/放弃。
303
+ function SessionFlowSettingsSection(props) {
304
+ const controller = props.controller
305
+ const state = useSyncExternalStore((fn) => controller.subscribe(fn), () => controller.getSnapshot())
306
+ if (state.status !== 'ready') {
307
+ return h('div', { className: 'sfset-root' }, h('p', { className: 'sfset-unavailable' }, STR.setUnavailable))
308
+ }
309
+ const disabled = !state.writable || state.saving
310
+ const rows = []
311
+ for (const group of SETTING_GROUPS) {
312
+ rows.push(h('div', { key: group.titleKey, className: 'sfset-groupTitle' }, STR[group.titleKey]))
313
+ for (const key of group.fields) {
314
+ const spec = SETTING_FIELD_SPECS[key]
315
+ const field = state.fields[key]
316
+ rows.push(h('div', { key, className: 'sfset-row' + (field.invalid ? ' sfset-invalid' : '') },
317
+ h('div', { className: 'sfset-meta' },
318
+ h('div', { className: 'sfset-label' }, STR[spec.labelKey],
319
+ field.overridden ? h('span', { className: 'sfset-overridden' }, STR.setOverridden) : null),
320
+ h('div', { className: 'sfset-hint' }, STR[spec.hintKey])),
321
+ h('div', { className: 'sfset-controls' },
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
+ }),
334
+ h('button', { type: 'button', className: 'sfset-reset', disabled, onClick: () => controller.resetField(key) }, STR.setReset))))
335
+ }
336
+ }
337
+ return h('div', { className: 'sfset-root' },
338
+ h('p', { className: 'sfset-desc' }, STR.settingsDesc),
339
+ rows,
340
+ h('div', { className: 'sfset-footer' },
341
+ h('button', { type: 'button', className: 'sfset-save', disabled: disabled || !state.dirty || state.invalid, onClick: () => { controller.save() } }, STR.setSave),
342
+ h('button', { type: 'button', className: 'sfset-discard', disabled: disabled || !state.dirty, onClick: () => controller.discard() }, STR.setDiscard),
343
+ state.invalid ? h('span', { className: 'sfset-error' }, STR.setInvalid) : null,
344
+ state.failed ? h('span', { className: 'sfset-error' }, STR.setSaveFail) : null))
345
+ }
346
+
347
+ // 取色对齐宠物设置卡(同一弹窗内经真机明暗/皮肤验证):纯 dsw-alias 变量、
348
+ // 不写 hex 兜底——变量缺失时继承弹窗上下文色,硬编码兜底在皮肤主题下反而会撞色。
349
+ const SETTINGS_STYLE = [
350
+ `.sfset-root{padding:4px 2px;display:flex;flex-direction:column;max-width:560px}`,
351
+ `.sfset-desc{margin:0 0 10px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}`,
352
+ `.sfset-groupTitle{margin:14px 0 4px;font-size:13px;font-weight:600;line-height:1.5;color:var(--dsw-alias-label-primary);border-bottom:1px solid var(--dsw-alias-border-l2);padding-bottom:4px}`,
353
+ `.sfset-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}`,
354
+ `.sfset-meta{min-width:0;flex:1}`,
355
+ `.sfset-label{font-size:13px;font-weight:500;line-height:1.5;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:8px}`,
356
+ `.sfset-overridden{white-space:nowrap;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);border-radius:999px;padding:1px 8px;font-size:11px;font-weight:500;line-height:17px}`,
357
+ `.sfset-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);margin-top:2px}`,
358
+ `.sfset-controls{flex:none;display:flex;align-items:center;gap:8px}`,
359
+ `.sfset-input{width:110px;height:34px;box-sizing:border-box;padding:0 12px;font:inherit;font-size:13px;line-height:1.5;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary)}`,
360
+ `.sfset-row.sfset-invalid .sfset-input{border-color:var(--dsw-alias-label-error)}`,
361
+ `.sfset-reset{font:inherit;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-secondary);cursor:pointer;background:0 0;border:none;padding:0}`,
362
+ `.sfset-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}`,
363
+ `.sfset-footer{display:flex;align-items:center;gap:8px;margin-top:16px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:12px}`,
364
+ `.sfset-save{appearance:none;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;border:1px solid #0000;border-radius:8px;padding:5px 14px;background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3)}`,
365
+ `.sfset-save:disabled{opacity:.4;cursor:default}`,
366
+ `.sfset-discard{appearance:none;font:inherit;font-size:13px;line-height:1.5;cursor:pointer;border-radius:8px;padding:5px 14px;border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-secondary)}`,
367
+ `.sfset-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}`,
368
+ `.sfset-discard:disabled{opacity:.4;cursor:default}`,
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}`,
371
+ `.sfset-unavailable{font-size:13px;line-height:1.5;color:var(--dsw-alias-state-warn-primary)}`,
372
+ ].join('\n')
373
+
124
374
  // ── 右侧导航图标(组件级共享,定义在顶层避免作用域问题)───────────
125
375
  // feather 风格线条,currentColor 自适应明暗主题、与文字同色。
126
376
  const NAV_ICON_USER = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`
@@ -136,6 +386,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
136
386
  // 这样 top 栏(flex:none + sticky)天然固定,不会随内容滚出视口。
137
387
  const STYLE = [
138
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}`,
139
396
  `${VIEW_SELECTOR}{z-index:60;background:var(--dsw-alias-bg-base,#fff);display:none!important;position:absolute;inset:0;overflow:hidden}`,
140
397
  `html[${ACTIVE_ATTR}]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) ${VIEW_SELECTOR}{display:block!important}`,
141
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}`,
@@ -148,7 +405,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
148
405
  `[data-dsh-frame][data-sidebar-collapsed] .sf-entryLabel{display:none}`,
149
406
  // box-sizing:border-box 必须:height:100% + padding 在 content-box 下会溢出容器,
150
407
  // 产生多余的整页滚动条,导致 top 栏随滚动消失(用户实测反馈)。
151
- `.sf-view{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);min-width:0;height:100%;min-height:0;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex;overflow:hidden}`,
408
+ // T2 修复:flex:1 1 0(flex-basis 用绝对 0 长度,非 flex:1 0% 百分比)。
409
+ // 根因(诊断数据 d47b82bf 实测):官方 viewArea 是 min-height:auto(聊天视图靠它
410
+ // 撑开内容 + 粘性输入框,不可全局强改);flex:1 的 0% 基准在父级高度不定时被
411
+ // Chrome 按 content 处理 → .sf-view 假设尺寸=内容高 → viewArea 内容下限被顶高 →
412
+ // 溢出 scrollBody → 整块滚动。基准用绝对 0 → 假设尺寸=0 → viewArea 下限=0 →
413
+ // 回到 flex 分配空间,两列各自内部滚动。工作台模式父容器非 flex,flex 无效无害。
414
+ `.sf-view{box-sizing:border-box;background:var(--dsw-alias-bg-base,#fff);min-width:0;height:100%;flex:1 1 0;min-height:0;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit);flex-direction:column;gap:10px;padding:14px 16px 16px;display:flex;overflow:hidden}`,
152
415
  // top 栏悬浮固定:flex:none 在布局上已固定;再加 sticky + 背景双保险。
153
416
  `.sf-viewHeader{flex:none;position:sticky;top:0;z-index:1;background:var(--dsw-alias-bg-base,#fff);align-items:center;gap:10px;display:flex;flex-wrap:wrap}`,
154
417
  `.sf-viewTitle{color:var(--dsw-alias-label-primary,#222);white-space:nowrap;margin:0;font-size:16px;font-weight:700}`,
@@ -210,6 +473,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
210
473
  `.sf-healthChip.hc-wait .sf-hcLabel{color:#a87b00}`,
211
474
  `.sf-healthChip.hc-stall .sf-hcBar{background:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
212
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)}`,
213
479
  `.sf-statsRow{display:flex;gap:12px;flex-wrap:wrap;margin-top:3px}`,
214
480
  `.sf-hint{color:var(--dsw-alias-label-secondary,#888);font-size:12px;padding:2px 2px 0}`,
215
481
  // ── 工作区筛选 tabs(独立一行,醒目)──
@@ -338,17 +604,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
338
604
  `.sf-fulltextCount{flex:none;border-radius:999px;background:rgba(90,140,255,.15);color:#4a7dff;font-size:10px;line-height:15px;min-width:18px;text-align:center;padding:0 5px;font-weight:700}`,
339
605
  `.sf-fulltext .sf-muted{font-size:11px;color:var(--dsw-alias-label-secondary,#666)}`,
340
606
  // M12:详情并入右侧栏(details 槽位轻量视图)。
607
+ // 头部布局:标题+按钮**聚左**,spacer 吃掉右侧余量——彻底规避右上角
608
+ // dsh-better-sidebar toggleCluster(视口级常驻浮钮,压过官方 details 列右缘,
609
+ // 实测「退出并入」被压)。按钮不再进右上角浮动图标区,任何第三方浮钮都压不到。
341
610
  `.dk-root{box-sizing:border-box;height:100%;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px;overflow:hidden;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit)}`,
342
- `.dk-head{flex:none;display:flex;align-items:center;gap:8px;min-width:0}`,
343
- `.dk-title{flex:1;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
344
- `.dk-btn{flex:none;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:7px;padding:3px 9px;font-size:11px;cursor:pointer}`,
611
+ `.dk-head{flex:none;display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}`,
612
+ `.dk-title{flex:none;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}`,
613
+ `.dk-headSpacer{flex:1 1 auto;min-width:0;align-self:stretch;pointer-events:none}`,
614
+ `.dk-btn{flex:none;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:7px;padding:3px 9px;font-size:11px;cursor:pointer;white-space:nowrap}`,
345
615
  `.dk-btn:hover{border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
616
+ // T6:dock 实时开关激活态(绿色语义,与详情页 sf-liveBar 同色系)。
617
+ `.dk-btn.dk-btnLive{border-color:rgba(28,158,90,.5);background:rgba(28,158,90,.08);color:#1c9e5a;font-weight:600}`,
346
618
  `.dk-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px}`,
347
619
  `.dk-summary{border:1px solid var(--dsw-alias-border-l2,#e4e6eb);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#f8f9fa);flex:none;display:flex;flex-direction:column;gap:4px}`,
348
620
  `.dk-summaryRow{display:flex;gap:6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);min-width:0}`,
349
621
  `.dk-summaryTag{flex:none;border-radius:5px;padding:0 5px;font-size:10px;line-height:16px;font-weight:700;background:rgba(90,140,255,.12);color:#4a7dff}`,
350
622
  `.dk-summaryText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
351
623
  `.dk-summaryFull{font-size:12px;line-height:1.6;color:var(--dsw-alias-label-primary,#222);white-space:pre-wrap;word-break:break-word;max-height:140px;overflow:auto}`,
624
+ // T8-B 钉住提示条:琥珀色警示(直播会话 ≠ 当前会话)。
625
+ `.dk-pinBar{flex:none;display:flex;align-items:center;gap:8px;padding:5px 8px;border:1px solid rgba(224,150,63,.45);background:rgba(224,150,63,.08);border-radius:8px}`,
626
+ `.dk-pinText{flex:1;min-width:0;font-size:12px;color:#c07a2a}`,
352
627
  // M12:并入期间禁用 layout grid 列宽过渡——官方会话切换会 closeDetails(宽 360→0),
353
628
  // 守护重开(0→360)若带过渡动画会产生「关→开」闪烁;禁用后切换直接刷新内容(实测踩坑)。
354
629
  `html[data-dsh-dock-active] [class*=frame],html[data-dsh-dock-active] [data-side=details]{transition:none!important}`,
@@ -393,41 +668,80 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
393
668
  // 垂直居中悬浮于会话内容区中间(top 由组件算区中心 + translateY(-50%))。
394
669
  // 动画体系:一切组件的产生/消失都有「来源与目标」——display 硬切全部改为
395
670
  // transform/opacity/max-height 过渡(实测踩坑:display 切换无动画、视觉突兀)。
396
- `[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1) .22s}`,
397
- // 收拢时序:离开时宽度过渡延迟 .22s——信息面板先滑出(.24s),悬浮条主体再收拢
398
- // (用户要求:收拢第一步先收起详情信息框);进入(hover/focus)无延迟即时展开。
399
- `[data-dsh-piano-keys]:hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
671
+ `[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;padding:0 0 8px;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1)}`,
672
+ // 收拢时序(T4):宽度与条目高度(.pk-key .22s)**同时起跑**——用户要求纵向横向
673
+ // 同时收拢(此前宽度延迟 .22s 造成「先纵后横」两段式);详情信息框仍先行立即
674
+ // 滑出(.pk-detail.hidden 无延迟),不参与纵向节奏。进入(hover/focus)同态:
675
+ // 无延迟即时展开,收拢=展开的倒放。
676
+ // 收拢锁(collapse-lock):pointerleave 后 300ms 内抑制 :hover 展开(focus-within 不受影响)。
677
+ // 日志实锤的几何自反馈回路:展开态越界 → 整条收拢(几何瞬变)→ 收拢后的胶囊条
678
+ // 「弹到」静止鼠标下方 → 合成 mouseenter → :hover 重新展开 → 再越界……每 300-500ms
679
+ // 一个来回(「横条反复出现消失」)。锁窗内 :hover 不展开,回路断开;真实驻留 300ms
680
+ // 后正常展开(顺带构成驻留去抖:快速掠过不再闪开)。
681
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
400
682
  `html[${ACTIVE_ATTR}] [data-dsh-piano-keys]{display:none!important}`,
401
- `.pk-strip{flex:1;min-height:0;max-height:calc(60vh - 60px);overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:5px;padding:8px 4px;scrollbar-width:none;outline:none}`,
402
- `.pk-strip::-webkit-scrollbar{display:none}`,
683
+ // T5:strip 高度 JS 显式管理(setStripHeight:可见行数 × 实测行距 + 上下内边距
684
+ // 16 − 间隙 5;收拢/展开键高动画期间 RO 逐帧跟随)——clipper 是 absolute 脱流,
685
+ // strip 不能再靠内容撑高(实测塌缩整条消失)。**必须 flex:1 1 auto**(basis:auto
686
+ // 时 height 属性才是弹性基准;flex:1 = basis 0% 会架空 JS height——实测踩坑)。
687
+ // shrink 保留配合 60vh 硬上限压缩。裁剪面=pk-clip(absolute inset:0 = strip
688
+ // 【内容盒】):CSS overflow 裁剪在内边距盒——上下缓冲键各露 3px 进内边距带
689
+ // (「上面会话的下边框露出一点点」实测踩坑);clipper 把可见区收到恰好 12 行。
690
+ // 垂直呼吸全部移出 strip:标题行下边距 8 + 根容器下内边距 8(均静止件);
691
+ // strip 零 padding——绝对定位 clipper 的 inset:0 解析的是【padding box】而非
692
+ // 内容盒,padding 留在 strip 上不参与裁剪、键却从 y=0 起排画进 padding 带
693
+ // (顶键贴死标题行、底部框线被裁——实测踩坑)。strip 高度=纯内容 rows×pitch−5。
694
+ `.pk-strip{flex:1 1 auto;min-height:0;max-height:calc(60vh - 60px);position:relative;outline:none}`,
695
+ `.pk-clip{position:absolute;inset:0;overflow:hidden}`,
696
+ // 键挂 pk-list 上;transform 由 JS 逐帧驱动(rAF 追逐 scrollPos + 实测行距,
697
+ // renderStart 离散跳变时同步重设——CSS transition 会把内容重排的跳变也做成
698
+ // 动画,每滚一行顿一次,实测踩坑)。
699
+ // 垂直内边距在 strip(静止件)不在 list(移动件):padding 挂移动件会随
700
+ // transform 平移,键从 y=8 起排 → 缓冲键底边 +3px 露出裁剪线(实测踩坑)。
701
+ `.pk-list{display:flex;flex-direction:column;gap:5px;padding:0 4px;will-change:transform}`,
403
702
  // 收拢态:短横条(胶囊线);overflow:hidden 使 label 文字被高度裁剪(来源=胶囊);
404
703
  // ::before 延伸命中区(±4px,点击/悬浮无死区)。
405
- `.pk-key{position:relative;flex:none;height:4px;min-height:4px;border-radius:99px;border:none;cursor:pointer;padding:0;margin:0 4px;background:var(--dsw-alias-border-l2,#d7dae0);color:var(--dsw-alias-label-primary,#222);overflow:hidden;transition:height .22s ease,padding .22s ease,margin .22s ease,border-radius .22s ease,background .14s ease,border-color .14s ease,box-shadow .18s ease;outline:none;min-width:0}`,
704
+ // T4 二阶段:box-sizing:border-box + 确定高度(4px↔30px)——原展开态 height:auto
705
+ // 不可插值(日志实锤:全程无 height 过渡事件),高度在两态间瞬移 120↔408px,
706
+ // 收拢条边缘 hover 进出即整条几何反复跳变(用户实测「闪烁抽搐」)。
707
+ `.pk-key{position:relative;flex:none;box-sizing:border-box;height:4px;min-height:4px;border-radius:99px;border:none;cursor:pointer;padding:0;margin:0 4px;background:var(--dsw-alias-border-l2,#d7dae0);color:var(--dsw-alias-label-primary,#222);overflow:hidden;transition:height .22s ease,padding .22s ease,margin .22s ease,border-radius .22s ease,background .14s ease,border-color .14s ease,box-shadow .18s ease;outline:none;min-width:0}`,
406
708
  `.pk-key::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px}`,
709
+ // 热区填满死区(三日志轮实锤症状一):strip 上下内边距 8px 只有 ::before ±4px 覆盖,
710
+ // 标题行/首键之间残留 4px 死区——鼠标停进去 key[0] 高亮+详情面板熄灭,跨出又点亮,
711
+ // 窄带内反复横跳=「横条出现消失」。首/末键 ::before 精确补到 strip 边缘(-8px),
712
+ // 带内任意位置都算 hover 该键,高亮/面板稳定常驻;键间隙 5px 由相邻 ±4px 互叠已全覆盖。
713
+ `.pk-key:first-child::before{top:-8px}`,
714
+ `.pk-key:last-child::before{bottom:-8px}`,
407
715
  `.pk-key:hover{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
408
716
  `.pk-key:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);outline-offset:-1px}`,
409
717
  `.pk-keyActive{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
410
718
  `.pk-keyFocus{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
411
719
  // 展开态(悬浮/聚焦):条目变文字卡片;label 常驻(缩略态被条目高度裁剪),
412
720
  // 展开时随高度过渡露出文字(来源=胶囊内部,目标=卡片全文)。
413
- `[data-dsh-piano-keys]:hover .pk-key,[data-dsh-piano-keys]:focus-within .pk-key{display:flex;align-items:center;gap:6px;height:auto;min-height:28px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);padding:5px 8px;margin:0;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 80%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
414
- `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key:hover{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 96%,transparent);border-color:var(--dsw-alias-brand-primary,#4a7dff)}`,
415
- `[data-dsh-piano-keys]:hover .pk-keyActive,[data-dsh-piano-keys]:focus-within .pk-keyActive{border-color:var(--dsw-alias-brand-primary,#4a7dff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4a7dff) 10%,var(--dsw-alias-bg-base,#fff))}`,
721
+ // T4 二阶段:height:autoheight:30px(label 18 + padding 10 + border 2 = 原自然
722
+ // 高度,最终几何不变)——确定长度可插值,与收拢态 4px 平滑互转(倒放对称)。
723
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key,[data-dsh-piano-keys]:focus-within .pk-key{display:flex;align-items:center;gap:6px;height:30px;min-height:30px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);padding:5px 8px;margin:0;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 80%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
724
+ // 热区链(四日志轮定位症状一):head↔gap↔首键 必须连续同态,否则边界 1px 晃动
725
+ // 即反复点亮/熄灭首键高亮(横条出现消失)。gap 由首键 ::before 覆盖(上文),
726
+ // head 由下行选择器链到首键:hover 标题行 ≡ hover 第一个键。
727
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key:hover,[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head:hover ~ .pk-strip .pk-key:first-child{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 96%,transparent);border-color:var(--dsw-alias-brand-primary,#4a7dff)}`,
728
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-keyActive,[data-dsh-piano-keys]:focus-within .pk-keyActive{border-color:var(--dsw-alias-brand-primary,#4a7dff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4a7dff) 10%,var(--dsw-alias-bg-base,#fff))}`,
416
729
  `.pk-label{flex:1;min-width:0;font-size:12px;line-height:18px;max-height:18px;color:var(--dsw-alias-label-primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:none;opacity:0;transition:opacity .18s ease .06s}`,
417
- `[data-dsh-piano-keys]:hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
730
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
418
731
  // 轮次序号:缩略态折叠(宽度 0 + 透明),展开态从左侧展开露出(来源=左缘)。
419
732
  `.pk-turnNo{flex:none;width:0;min-width:0;overflow:hidden;opacity:0;text-align:left;font-size:9.5px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-tertiary,#999);font-family:ui-monospace,Consolas,monospace;user-select:none;white-space:nowrap;transition:width .22s ease,opacity .16s ease .04s}`,
420
- `[data-dsh-piano-keys]:hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
733
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
421
734
  // hover 条目:在【该条目右侧】展开信息面板(.pk-detail)——absolute 相对悬浮条容器,
422
735
  // 坐标按条目位置换算为容器内偏移(注意:容器有 transform:translateY(-50%),fixed 子元素
423
736
  // 会被 transform 祖先劫持成相对容器定位——用视口坐标会双重偏移,实测踩坑)。
424
- `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
737
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus,[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head:hover ~ .pk-strip .pk-key:first-child{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
425
738
  // 展开时序:详情框延迟 .28s 再滑入——等悬浮条展开动画完成、条目就位后,
426
739
  // 位置才准确(第一次展开时条目仍在变形,立即计算会错位,实测踩坑);
427
740
  // 隐藏则立即(无延迟)。
428
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}`,
429
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}`,
430
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}`,
431
745
  // 面板内容随条目切换:新内容从右轻滑入(key 变化重新挂载触发 animation)。
432
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}`,
433
747
  `@keyframes pkSlideIn{from{opacity:0;transform:translateX(10px)}to{opacity:1;transform:translateX(0)}}`,
@@ -436,14 +750,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
436
750
  `.pk-status{position:absolute;left:0;right:0;top:calc(100% + 4px);padding:7px 10px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 82%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--dsw-alias-border-l2,#e4e6eb);font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);box-shadow:0 4px 14px rgba(0,0,0,.12);z-index:6;text-align:center;white-space:normal;word-break:break-word;opacity:1;transform:translateY(0);transition:opacity .18s ease,transform .22s ease,visibility .22s}`,
437
751
  `.pk-status.hidden{opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none}`,
438
752
  `.pk-status.warn{color:#d97706;border-color:rgba(217,119,6,.4)}`,
439
- // 展开态显示悬浮条细滚动条(缩略态保持无滚动条)。
440
- `[data-dsh-piano-keys]:hover .pk-strip,[data-dsh-piano-keys]:focus-within .pk-strip{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,#d7dae0) transparent}`,
441
- `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar,[data-dsh-piano-keys]:focus-within .pk-strip::-webkit-scrollbar{width:6px;display:block}`,
442
- `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,#d7dae0);border-radius:3px}`,
443
753
  // 头部:从上方滑入(来源=悬浮条顶缘)——max-height/padding/opacity/transform 过渡,
444
754
  // 不占缩略态布局。内容「标题 + ▲▼ 按钮组(并排右上)」。
445
- `.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
446
- `[data-dsh-piano-keys]:hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
755
+ `.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;margin-bottom:8px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
756
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
447
757
  `.pk-headLabel{flex:1;min-width:0;font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
448
758
  `.pk-navGroup{flex:none;display:flex;gap:3px}`,
449
759
  `.pk-navBtn{flex:none;width:20px;height:20px;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:6px;cursor:pointer;font-size:9px;line-height:1;padding:0;transition:border-color .14s ease,color .14s ease}`,
@@ -604,6 +914,79 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
604
914
  }
605
915
  const canOfficialRename = (conn) =>
606
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
+ }
607
990
  /** 静默时长文本({N} 占位)。 */
608
991
  const fmtSilent = (idleMs) => {
609
992
  if (idleMs === null || idleMs === undefined) return ''
@@ -612,6 +995,16 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
612
995
  ? STR.healthSilentMin.replace('{N}', String(min))
613
996
  : STR.healthSilentSec.replace('{N}', String(Math.max(1, Math.round(idleMs / 1000))))
614
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
+ }
615
1008
  /** 健康徽标(详情实时条 / 总览卡片共用);active/ended/unknown 不渲染(已有进行中/已结束徽标)。 */
616
1009
  const healthBadge = (health) => {
617
1010
  if (!health) return null
@@ -622,24 +1015,41 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
622
1015
  return null
623
1016
  }
624
1017
 
625
- // ── 会话页头部健康芯片(官方 conversation.session.header.actions 槽位)──
1018
+ // ── 会话页头部健康胶囊(官方 conversation.session.header.actions 槽位)──
626
1019
  // 位置:官方模式标识(agent-preset label, order -10)右侧(order -9)。
627
- // 数据:sessions.list 快照跟踪 running(官方真实状态,随 host 流即时翻转)→
628
- // running 10s 轮询 tail history → host derive 健康分类(与总览探测同管线)。
1020
+ // 常驻四态(用户需求 2026-09-05:非运行也展示):
1021
+ // 空闲(灰条 +「N 分钟前」相对时间,零 RPC,来自 byId.updatedAt)
1022
+ // 运行·活跃(绿)/ 运行·工具执行中·静默中(黄)/ 运行·疑似卡死(红,脉冲)
1023
+ // —— 运行时 10s 轮询 tail history → host derive 健康分类;probe 未返回前
1024
+ // 乐观显示「活跃」(避免胶囊闪烁)。空白新会话不显示。
629
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 静默(与总览探测同口径)。
630
1035
  function SessionHealthChip(props) {
631
- const { sessionId, connection, sessions } = props
1036
+ const { connection, sessions } = props
1037
+ const sid = props.sessionId || currentSessionIdOf(sessions)
1038
+ const sessionId = sid
632
1039
  const [running, setRunning] = useState(false)
1040
+ const [meta, setMeta] = useState({ updatedAt: 0, blank: false })
633
1041
  const [health, setHealth] = useState(null)
634
1042
  useEffect(() => {
635
1043
  if (!sessionId || !sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
636
1044
  const compute = () => {
637
1045
  try {
638
- // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running)——
639
- // 不是 { items }(那是 workspace 浏览器用的另一个视图快照,曾误用导致芯片恒 null)。
1046
+ // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running/updatedAt/blank)。
640
1047
  const snap = sessions.list.getSnapshot()
641
1048
  const it = snap && snap.byId ? snap.byId[sessionId] : undefined
642
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 }))
643
1053
  } catch (e) { setRunning(false) }
644
1054
  }
645
1055
  compute()
@@ -647,13 +1057,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
647
1057
  return undefined
648
1058
  }, [sessions, sessionId])
649
1059
  useEffect(() => {
650
- if (!running || !sessionId || !canOfficialRename(connection)) { setHealth(null); return undefined }
1060
+ if (!running || !sessionId || !sfRemoteRef.current) { setHealth(null); return undefined }
651
1061
  let alive = true
652
1062
  const probe = async () => {
653
1063
  try {
654
- const hr = await connection.api.sessions.history({ sessionId, maxMessages: 3 })
655
- const val = hr && hr.result && hr.result.value
656
- const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
1064
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 3)
1065
+ const events = historyEventsOf(val)
657
1066
  if (events.length === 0) { if (alive) setHealth(null); return }
658
1067
  const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
659
1068
  if (alive && d && d.ok) setHealth(d.health || null)
@@ -663,22 +1072,61 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
663
1072
  const iv = setInterval(probe, 10000)
664
1073
  return () => { alive = false; clearInterval(iv) }
665
1074
  }, [running, sessionId, connection])
666
- if (!running || !health) return null
667
- const kind = health.kind
668
- const silent = fmtSilent(health.idleMs)
669
- // 收拢态胶囊条颜色语义:绿=活跃 / 黄=工具执行中·静默中 / 红=疑似卡死(脉冲)。
670
- let text = STR.healthActive, cls = 'hc-active'
671
- if (kind === 'tool-wait') { text = STR.healthToolWait; cls = 'hc-wait' }
672
- else if (kind === 'quiet') { text = STR.healthQuiet; cls = 'hc-wait' }
673
- else if (kind === 'stalled') { text = STR.healthStalled; cls = 'hc-stall' }
674
- else if (kind !== 'active') return null // ended/unknown 不显示
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
+ }
675
1123
  return h('button', {
676
1124
  className: 'sf-healthChip ' + cls,
677
1125
  title: STR.healthChipTitle,
678
1126
  onClick: (e) => { e.stopPropagation(); if (props.onOpen) props.onOpen() },
679
1127
  },
680
1128
  h('span', { className: 'sf-hcBar' }),
681
- h('span', { className: 'sf-hcLabel' }, text + (silent && kind !== 'active' ? ' · ' + silent : '')),
1129
+ h('span', { className: 'sf-hcLabel' }, text + (silent && cls !== 'hc-active' ? ' · ' + silent : '')),
682
1130
  )
683
1131
  }
684
1132
 
@@ -744,8 +1192,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
744
1192
  const target = all.find((x) => x.id === sid)
745
1193
  if (target && target.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
746
1194
  }
747
- if (trimmed !== '' && canOfficialRename(conn)) {
748
- 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 })
749
1198
  .then((accepted) => applyLocal(accepted))
750
1199
  .catch(() => legacy())
751
1200
  }
@@ -757,16 +1206,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
757
1206
  const [healthMap, setHealthMap] = useState({})
758
1207
  const [liveRunning, setLiveRunning] = useState(null) // null=未探测;Set<sessionId>
759
1208
  useEffect(() => {
760
- const conn = props.connection
761
- if (!canOfficialRename(conn) || state.phase !== 'ready') return undefined
1209
+ if (!sfRemoteRef.current || state.phase !== 'ready') return undefined
762
1210
  let alive = true
763
1211
  let probing = false
764
1212
  const probe = async () => {
765
1213
  if (probing) return
766
1214
  probing = true
767
1215
  try {
768
- const resp = await conn.api.sessions.list({})
769
- 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) || []
770
1218
  const runningIds = items.filter((i) => i.running === true).map((i) => i.sessionId)
771
1219
  if (!alive) return
772
1220
  setLiveRunning(new Set(runningIds))
@@ -774,9 +1222,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
774
1222
  for (let i = 0; i < Math.min(runningIds.length, 8); i += 3) {
775
1223
  await Promise.all(runningIds.slice(i, i + 3).map(async (sid) => {
776
1224
  try {
777
- const hr = await conn.api.sessions.history({ sessionId: sid, maxMessages: 3 })
778
- const val = hr && hr.result && hr.result.value
779
- const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
1225
+ const events = historyEventsOf(await sfRemoteRef.current.pageBySession(sid, 3))
780
1226
  if (events.length === 0) return
781
1227
  const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
782
1228
  if (d && d.ok && d.health) next[sid] = d.health
@@ -792,12 +1238,17 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
792
1238
  return () => { alive = false; clearInterval(iv) }
793
1239
  }, [props.connection, state.phase])
794
1240
  // 头部芯片直达:工作台打开意图 → 列表就绪后自动进入该会话详情(每个意图只消费一次)。
1241
+ // T3 修复:无论命中与否都标记消费 + 通知父级清意图——此前 Overview 卸载(进详情)后
1242
+ // intentConsumed ref 随之丢失,「← 返回」重挂 Overview 时旧意图被再次消费,
1243
+ // 表现为返回后又被拉回详情(用户实测);未命中时也消费,避免每次重挂都重试查找。
795
1244
  const intentConsumed = useRef(null)
796
1245
  useEffect(() => {
797
1246
  if (!props.intentId || intentConsumed.current === props.intentId || state.phase !== 'ready') return
1247
+ intentConsumed.current = props.intentId
1248
+ if (props.onIntentConsumed) props.onIntentConsumed()
798
1249
  const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
799
1250
  const target = all.find((x) => x.id === props.intentId)
800
- if (target) { intentConsumed.current = props.intentId; props.onOpen(target) }
1251
+ if (target) props.onOpen(target)
801
1252
  }, [props.intentId, state.phase])
802
1253
  useEffect(() => () => {
803
1254
  if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
@@ -1445,16 +1896,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1445
1896
  }
1446
1897
  } catch (e) {}
1447
1898
  // 2) 运行时 history 桥接(在线子代理)→ host derive 得 light 结构
1448
- if (connection !== undefined && node.parentId !== undefined) {
1899
+ if (node.parentId !== undefined && sfRemoteRef.current) {
1449
1900
  try {
1450
- const resp = await connection.api.subagents.history({
1451
- parentSessionId: node.parentId,
1452
- childSessionId: node.id,
1453
- mode: node.mode,
1454
- maxMessages: 120,
1455
- })
1456
- if (resp && resp.result && resp.result.ok && resp.result.value && Array.isArray(resp.result.value.events)) {
1457
- 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
1458
1905
  const derived = await api('derive', { events })
1459
1906
  if (derived && derived.ok) {
1460
1907
  // derive 返回完整 timeline——转成 light 结构
@@ -1572,8 +2019,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1572
2019
  })
1573
2020
  const trimmed = String(title || '').trim()
1574
2021
  if (trimmed === '' && session.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1575
- if (trimmed !== '' && canOfficialRename(conn)) {
1576
- 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 })
1577
2025
  .then((accepted) => { setTitleOverride(accepted); setRenaming(false); return true })
1578
2026
  .catch(() => legacy())
1579
2027
  }
@@ -1714,27 +2162,22 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1714
2162
  }
1715
2163
  setLiveState((s) => ({ ...s, phase: 'loading' }))
1716
2164
  try {
1717
- // 会话地址未知时走普通 sessions 通道(当前会话);若连接层要求显式地址则回退。
1718
- const resp = await conn.api.sessions.history({
1719
- sessionId: session.id,
1720
- mode: 'full',
1721
- maxMessages: 400,
1722
- })
1723
- const val = resp && resp.result && resp.result.value
1724
- 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) {
1725
2168
  setLiveState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
1726
2169
  return
1727
2170
  }
1728
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2171
+ const events = historyEventsOf(val)
1729
2172
  const derived = await api('derive', { events, now: Date.now() })
1730
2173
  if (!derived || !derived.ok) {
1731
2174
  setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
1732
2175
  return
1733
2176
  }
1734
2177
  const tl = (derived.timeline && derived.timeline.turns) || []
1735
- // 实时视图只保留「最近 3 个历史回合 + 当前运行回合」(若有),数量恒定,
1736
- // 不随 history 窗口大小变化——新回合出现时最旧的被挤出。
1737
- const LIVE_MAX_HISTORY_TURNS = 3
2178
+ // 实时视图只保留「最近 N 个历史回合 + 当前运行回合」(若有),数量恒定,
2179
+ // 不随 history 窗口大小变化——新回合出现时最旧的被挤出。N 可配置(设置页)。
2180
+ const LIVE_MAX_HISTORY_TURNS = sfSettings.current.liveHistoryTurns
1738
2181
  const liveRunning = derived.running === true
1739
2182
  const keepCount = tl.length > LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
1740
2183
  ? LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
@@ -1769,11 +2212,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1769
2212
  const enterLive = () => {
1770
2213
  setLiveOn(true)
1771
2214
  liveSeenTurns.current.clear() // 重进实时:全新视图,首轮全部折叠
2215
+ tlFollowRef.current = true // T7:开播默认吸底
1772
2216
  fetchLive.current()
1773
2217
  if (liveTimer.current === null) {
1774
- liveTimer.current = setInterval(() => { fetchLive.current() }, 3000)
2218
+ liveTimer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
1775
2219
  }
1776
2220
  }
2221
+ // T7 实时吸底跟随:用户在时间线底部(距底 ≤liveFollowPx)时,每次轮询刷新后平滑滚到
2222
+ // 新底部;上滑读历史不跟随(onScroll 实时追踪)。阈值默认 40px(可在设置页调整)。
2223
+ const tlScrollRef = useRef(null)
2224
+ const tlFollowRef = useRef(true)
2225
+ const onTlScroll = () => {
2226
+ const el = tlScrollRef.current
2227
+ if (el === null) return
2228
+ tlFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
2229
+ }
2230
+ useEffect(() => {
2231
+ if (!liveOn || liveState.timeline === null) return
2232
+ const el = tlScrollRef.current
2233
+ if (el !== null && tlFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
2234
+ }, [liveState.timeline, liveOn])
1777
2235
  const exitLive = () => {
1778
2236
  setLiveOn(false)
1779
2237
  liveSeenTurns.current.clear()
@@ -2009,10 +2467,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2009
2467
  running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
2010
2468
  session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
2011
2469
  h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
2012
- // M12:并入右栏(仅工作台详情页;嵌入标签页不显示——避免右栏与标签页重复)。
2013
- !props.embedded && h('button', {
2470
+ // T6:并入右栏(工作台详情页 + 嵌入标签页都显示)。嵌入模式点击后额外
2471
+ // 自动切回「对话」标签——右栏已接管会话流展示,中栏标签页继续停留会造成
2472
+ // 双视图冗余(用户指定交互:点击=打开 dock+跳回对话)。
2473
+ h('button', {
2014
2474
  className: 'sf-btn',
2015
- onClick: () => { if (dockBridge.controller) dockBridge.controller.open() },
2475
+ onClick: () => {
2476
+ if (dockBridge.controller) dockBridge.controller.open()
2477
+ if (props.embedded) activateChatTab()
2478
+ },
2016
2479
  title: STR.dockHint,
2017
2480
  }, STR.dock),
2018
2481
  // M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
@@ -2031,8 +2494,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2031
2494
  state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
2032
2495
  state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.emptyHint),
2033
2496
  state.phase === 'ready' && h('div', { className: 'sf-detailBody' },
2034
- // 左侧时间线
2035
- h('div', { className: 'sf-timeline' },
2497
+ // 左侧时间线(T7:ref+onScroll 供实时吸底跟随)
2498
+ h('div', { className: 'sf-timeline', ref: tlScrollRef, onScroll: onTlScroll },
2036
2499
  h('div', { className: 'sf-muted', style: { flex: 'none' }, title: data.workspaceCwd || data.workspace },
2037
2500
  (data.workspaceLabel || data.workspace || '') + ' · ' + fmtTime(sessionInfo.createdAt) +
2038
2501
  ' · ' + STR.duration + ' ' + fmtDuration((sessionInfo.lastEventTime || 0) - (sessionInfo.createdAt || 0)) +
@@ -2111,7 +2574,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2111
2574
  ),
2112
2575
  h('button', { className: 'sf-btn sf-liveExitBtn', onClick: exitLive }, STR.liveExit),
2113
2576
  ),
2114
- liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
2577
+ liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
2115
2578
  liveOn && liveState.phase === 'loading' && liveState.timeline === null && h('div', { className: 'sf-hint' }, STR.liveLoading),
2116
2579
  liveOn && liveState.timeline !== null && h(TimelineTurns, {
2117
2580
  // 保留回合头(默认折叠可点击展开):hideHead=true 时折叠回合既无内容
@@ -2342,19 +2805,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2342
2805
 
2343
2806
  const fetchLive = useRef(async () => {})
2344
2807
  fetchLive.current = async () => {
2345
- if (connection === undefined || connection.api === undefined || connection.api.sessions === undefined) {
2808
+ if (!sfRemoteRef.current) {
2346
2809
  setState((s) => ({ ...s, phase: 'error', error: STR.liveUnavailable }))
2347
2810
  return
2348
2811
  }
2349
2812
  setState((s) => ({ ...s, phase: s.timeline === null ? 'loading' : 'ready' }))
2350
2813
  try {
2351
- const resp = await connection.api.sessions.history({ sessionId, mode: 'full', maxMessages: 400 })
2352
- const val = resp && resp.result && resp.result.value
2353
- if (val === undefined || !Array.isArray(val.events)) {
2814
+ const val = await sfRemoteRef.current.pageBySession(sessionId, 400)
2815
+ if (val === undefined) {
2354
2816
  setState((s) => ({ ...s, phase: 'error', error: STR.liveFail }))
2355
2817
  return
2356
2818
  }
2357
- const events = val.events.map((entry) => (entry && entry.event) || entry)
2819
+ const events = historyEventsOf(val)
2358
2820
  const derived = await api('derive', { events, now: Date.now() })
2359
2821
  if (!derived || !derived.ok) {
2360
2822
  setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
@@ -2384,7 +2846,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2384
2846
  useEffect(() => {
2385
2847
  liveSeenTurns.current.clear()
2386
2848
  fetchLive.current()
2387
- if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, 3000)
2849
+ if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
2388
2850
  return () => { if (timer.current !== null) { clearInterval(timer.current); timer.current = null } }
2389
2851
  }, [sessionId])
2390
2852
 
@@ -2445,6 +2907,33 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2445
2907
  })()
2446
2908
  return () => ac.abort()
2447
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])
2448
2937
 
2449
2938
  return detail === null
2450
2939
  ? h(SessionFlowOverview, {
@@ -2455,6 +2944,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2455
2944
  connection: props.connection,
2456
2945
  liveTitles,
2457
2946
  intentId,
2947
+ // T3:意图消费后即清除(SessionFlowView 不随详情↔总览切换卸载,状态稳)——
2948
+ // 防止「← 返回」重挂 Overview 时旧意图再次消费把用户拉回详情。
2949
+ onIntentConsumed: () => setIntentId(null),
2458
2950
  })
2459
2951
  : h(SessionFlowDetail, {
2460
2952
  session: detail.session,
@@ -2485,6 +2977,34 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2485
2977
  : { open: false, listeners: new Set(), controller: null })
2486
2978
  const PIANO_WINDOW = 15
2487
2979
 
2980
+ // T6:从嵌入标签页切回「对话」标签。官方 conversation.view 槽位环的标签栏没有
2981
+ // 公开激活 API(标准 props 仅 sessionId/useSession)——DOM 兜底:先按文案定位我们
2982
+ // 自己的标签按钮(「会话流」),再取其父容器(标签栏)的【直接子元素】逐个按文本
2983
+ // 精确匹配「对话 / Chat」——不限定 button:实测部分内置标签(对话/轨迹)不是
2984
+ // <button> 元素,按 button 过滤会漏掉它们、只命中插件 tab(点了会跳 Plan 图,
2985
+ // 实测踩坑)。命中后点击该元素或其内部首个按钮;未命中则不动作(宁可不动也
2986
+ // 不跳错标签)。React 合成监听挂在本事件上,click() 有效。
2987
+ function activateChatTab() {
2988
+ try {
2989
+ const pane = document.querySelector(CONVERSATION_SELECTOR)
2990
+ if (pane === null) return
2991
+ const btns = Array.prototype.slice.call(pane.querySelectorAll('button'))
2992
+ const ours = btns.find((b) => (b.textContent || '').trim().indexOf(STR.entry) >= 0)
2993
+ if (ours === undefined) return
2994
+ const bar = ours.parentElement
2995
+ if (bar === null) return
2996
+ const kids = Array.prototype.slice.call(bar.children)
2997
+ let target = null
2998
+ for (const k of kids) {
2999
+ if (k === ours || (typeof k.contains === 'function' && k.contains(ours))) continue
3000
+ if (/^(对话|Chat)$/.test((k.textContent || '').trim())) { target = k; break }
3001
+ }
3002
+ if (target === null) return
3003
+ const clickable = target.tagName === 'BUTTON' ? target : (target.querySelector('button') || target)
3004
+ clickable.click()
3005
+ } catch (e) {}
3006
+ }
3007
+
2488
3008
  // 定位目标:会话内容区。注意必须选**滚动容器** scrollBody(data-conversation-scroll):
2489
3009
  // viewArea 在滚动场景下 rect 是「内容全尺寸」(如 y=-25687, h=26823),用它定位会把
2490
3010
  // 竖条甩到视口外(实测踩坑)。scrollBody 才有正确的视口内矩形。
@@ -2499,28 +3019,98 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2499
3019
  return undefined
2500
3020
  }
2501
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
+
2502
3077
  // ── M11:本会话轮次导航(会话页左侧常驻悬浮条目条,v3)──────────
2503
3078
  // 范围:只显示【当前会话】的对话轮次(用户消息),不跨会话/不跨工作区。
2504
- // 数据:官方消息行锚点 DOM 驱动(data-time-hover-rootdsh-navbar 同款机制,
2505
- // 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 实时)。
2506
3081
  // 轮次 → 结论预览:按顺序索引映射 host get 的 lightTurns(用户消息摊平)。
2507
3082
  // 交互:点击滚动定位该轮、active 跟随视口第一条、窗口裁剪 + 滚轮/▲▼、
2508
3083
  // 键盘 ↑↓/Enter、hover 预览卡(该轮用户消息 + 结论)、右键唤起会话流工作台。
2509
3084
  // 样式:DeepSeek 网页版右侧悬浮消息导航风格(圆角卡片 + 单行截断文字),移到左侧。
2510
3085
  function TurnKeys(props) {
2511
3086
  const { sessions, onOpenWorkbench } = props
2512
- 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 到位后触发详情框重渲染
2513
3090
  const [activeIdx, setActiveIdx] = useState(-1)
2514
- const [winOffset, setWinOffset] = useState(0)
3091
+ // T5 平滑滚动:连续滚动位置(浮点,单位=行)。替代原整数 winOffset 的「每格硬跳
3092
+ // 1 条」——滚轮增量累积(deltaMode 归一)+ 列表 transform 百分比位移(transition
3093
+ // 追逐),渲染窗口 = 可视 WINDOW 条 + 上下缓冲,位移按列表自身高度百分比换算,
3094
+ // 收拢(4px)/展开(30px)行高变化时比例自守恒,无需测量行距。
3095
+ const [scrollPos, setScrollPos] = useState(0)
2515
3096
  const [kbGlobal, setKbGlobal] = useState(null)
2516
3097
  const [detailIdx, setDetailIdx] = useState(null) // 右侧信息面板显示的条目索引
2517
3098
  const detailTimer = useRef(null)
2518
3099
  const rootRef = useRef(null) // 悬浮条根容器(面板定位换算基准)
2519
3100
  const [jumpFail, setJumpFail] = useState(false) // 未加载轮次提示
2520
3101
  const [, force] = useState(0)
3102
+ // detailIdx 镜像:过渡监听 effect(deps [])闭包里读取当前值,避免读到旧 state。
3103
+ const detailIdxRef = useRef(null)
2521
3104
 
2522
3105
  // 定位自管理:垂直居中悬浮于会话内容区(scrollBody 矩形中心 + translateY(-50%))。
2523
3106
  const [pos, setPos] = useState(null) // {left, top(区中心)}
3107
+ useEffect(() => {
3108
+ return () => {
3109
+ const lock = collapseLockRef.current
3110
+ if (lock.timer) clearTimeout(lock.timer)
3111
+ if (lock.retry) clearTimeout(lock.retry)
3112
+ }
3113
+ }, [])
2524
3114
  useEffect(() => {
2525
3115
  const compute = () => {
2526
3116
  const area = pianoViewArea()
@@ -2552,7 +3142,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2552
3142
  useEffect(() => {
2553
3143
  if (initTailDone.current || entries.length <= WINDOW) return
2554
3144
  initTailDone.current = true
2555
- setWinOffset(entries.length - WINDOW)
3145
+ setScrollPos(entries.length - WINDOW)
2556
3146
  }, [entries])
2557
3147
 
2558
3148
  // 当前会话 id(仅取 current,不展示跨会话列表)。
@@ -2578,7 +3168,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2578
3168
  const flat = []
2579
3169
  for (const lt of json.lightTurns || []) {
2580
3170
  for (const u of lt.userMessages || []) {
2581
- 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) })
2582
3172
  }
2583
3173
  }
2584
3174
  // 防御:同一 seq 只保留一条(避免同一条消息重复渲染 = 「最后一条显示两次」)。
@@ -2622,10 +3212,18 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2622
3212
  return () => { alive = false; if (iv) clearInterval(iv); if (flowObs) flowObs.disconnect() }
2623
3213
  }, [currentId])
2624
3214
 
2625
- // DOM 行工具:官方消息行锚点(dsh-navbar 同款)。
2626
- const domRows = () => [...document.querySelectorAll('[data-time-hover-root]')]
2627
- .filter((r) => !r.hasAttribute('data-pending-steering'))
2628
- .filter((r) => !r.hasAttribute('data-turn-tail') && r.querySelector('[class*=bubble]') !== null)
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
+ }
2629
3227
  const textOf = (row) => {
2630
3228
  const b = row.querySelector('[class*=bubble]')
2631
3229
  return b ? String(b.textContent || '').replace(/\s+/g, ' ').trim() : ''
@@ -2639,56 +3237,184 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2639
3237
  .replace(/[*_#>|~-]{1,3}/g, ' ')
2640
3238
  .replace(/\s+/g, ' ')
2641
3239
  .trim()
2642
- // 条目 → DOM 行匹配:preview 归一化截断前缀与行文本归一化后 includes 比较。
2643
- const findRow = (entry) => {
2644
- const core = norm(String(entry.preview || '').replace(/…+$/u, '')).slice(0, 50)
2645
- if (!core) return null // 无文字内容(纯图片/文件消息)无法文本定位
2646
- for (const row of domRows()) {
2647
- const t = norm(textOf(row))
2648
- if (t && t.includes(core.slice(0, 30))) return row
3240
+ // 行→条目映射(单调整体映射,active 定位与点击跳转共用):
3241
+ // 按文档序游标推进匹配用户行(「继续」类重复文本不会误配到前面条目);
3242
+ // 助手行未匹配时继承前一条用户行的条目(轮次归属)——视口首行为助手
3243
+ // 回复时 active 仍指向其所属用户轮次,不再丢失高亮(T1 修复点一)。
3244
+ const mapRows = () => {
3245
+ const rows = domRows()
3246
+ const idxs = new Array(rows.length).fill(-1)
3247
+ let cursor = 0
3248
+ let last = -1
3249
+ for (let i = 0; i < rows.length; i++) {
3250
+ const t = norm(textOf(rows[i]))
3251
+ if (t) {
3252
+ for (let j = cursor; j < entries.length; j++) {
3253
+ const core = norm(String(entries[j].preview || '').replace(/…+$/u, '')).slice(0, 50)
3254
+ if (core && t.includes(core.slice(0, 30))) { cursor = j + 1; last = j; break }
3255
+ }
3256
+ }
3257
+ idxs[i] = last
2649
3258
  }
3259
+ return { rows, idxs }
3260
+ }
3261
+ // 条目 → DOM 行:从映射中取首个归属该条目的行(用户行本身;映射按文档序
3262
+ // 单调推进,重复文本不会命中到前面的同文条目)。
3263
+ const findRow = (entry) => {
3264
+ const target = entries.indexOf(entry)
3265
+ if (target === -1) return null
3266
+ const { rows, idxs } = mapRows()
3267
+ for (let i = 0; i < rows.length; i++) if (idxs[i] === target) return rows[i]
2650
3268
  return null
2651
3269
  }
2652
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
+ }
2653
3293
  // 点击定位:先查该轮次是否已加载(DOM 中能否匹配到对应行)——
2654
- // 已加载 → 平滑滚动定位;未加载 → 只提示、不跳转(自动加载循环会让消息流
2655
- // 乱滚/跳错位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
3294
+ // 已加载 → 平滑滚动定位;未加载 → 委托官方 rail 引擎加载跳转(v0.1.2+);
3295
+ // rail 缺席(旧版 dsh)→ 只提示、不跳转(自动加载循环会让消息流乱滚/跳错
3296
+ // 位置,实测踩坑;用户需先在会话区向上滚动加载历史再点击)。
2656
3297
  const open = (entry) => {
2657
3298
  const row = findRow(entry)
2658
3299
  if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'start' }); return }
3300
+ if (delegateToRail(entry)) return
2659
3301
  setJumpFail(true)
2660
3302
  setTimeout(() => setJumpFail(false), 2600)
2661
3303
  }
2662
3304
 
2663
- // active:视口内第一条 DOM 用户行 文本匹配条目索引高亮(阅读位置跟随)。
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
+
3339
+ // active:视口内最靠上的可见行 → 映射条目索引高亮(阅读位置跟随)。
3340
+ // 可见判定用 bottom 进入视口(含被顶部裁切一半的行)——此前 top>=0 会
3341
+ // 跳过被裁切行导致高亮偏晚一轮(T1 修复点二)。
2664
3342
  useEffect(() => {
2665
3343
  const compute = () => {
2666
3344
  if (entries.length === 0) { setActiveIdx(-1); return }
2667
- const rows = domRows()
2668
- let target = null
3345
+ const { rows, idxs } = mapRows()
3346
+ let target = -1
2669
3347
  let bestTop = Number.POSITIVE_INFINITY
2670
- for (const row of rows) {
2671
- const top = row.getBoundingClientRect().top
2672
- if (top >= 0 && top < bestTop) { bestTop = top; target = row }
3348
+ for (let i = 0; i < rows.length; i++) {
3349
+ const r = rows[i].getBoundingClientRect()
3350
+ if (r.bottom > 1 && r.top < bestTop) { bestTop = r.top; target = i }
2673
3351
  }
2674
- if (!target) { setActiveIdx(entries.length - 1); return }
2675
- const t = norm(textOf(target))
2676
- const idx = entries.findIndex((e) => {
2677
- const core = norm(String(e.preview || '').replace(/…+$/u, '')).slice(0, 50)
2678
- return core && t.includes(core.slice(0, 30))
2679
- })
2680
- setActiveIdx(idx)
3352
+ if (target === -1) { setActiveIdx(entries.length - 1); return }
3353
+ const idx = idxs[target]
3354
+ // idx<0(视口首行在第一条用户消息之前):保持现状,不清高亮不跳变。
3355
+ if (idx >= 0) setActiveIdx(idx)
2681
3356
  }
2682
3357
  compute()
2683
3358
  const iv = setInterval(compute, 800)
2684
3359
  return () => clearInterval(iv)
2685
3360
  }, [entries])
2686
3361
 
2687
- const WINDOW = 12
3362
+ const WINDOW = sfSettings.current.pianoWindow
2688
3363
  const hidden = entries.length < 2
2689
- const windowed = entries.slice(winOffset, winOffset + WINDOW)
2690
- const canUp = winOffset > 0
2691
- const canDown = winOffset + WINDOW < entries.length
3364
+ // 虚拟窗口:renderStart 起渲染 WINDOW+3 条(上方缓冲 1 + 下方缓冲 2)。
3365
+ const maxOff = Math.max(0, entries.length - WINDOW)
3366
+ const clampedPos = Math.max(0, Math.min(maxOff, scrollPos))
3367
+ const renderStart = Math.max(0, Math.floor(clampedPos) - 1)
3368
+ const renderCount = Math.min(WINDOW + 3, entries.length - renderStart)
3369
+ const windowed = entries.slice(renderStart, renderStart + renderCount)
3370
+ const winOffset = Math.round(clampedPos) // 逻辑口径(高亮/面板/▲▼基准)四舍五入
3371
+ const canUp = clampedPos > 0.01
3372
+ const canDown = clampedPos < maxOff - 0.01
3373
+ // T5 显示位(ref,非 state——逐帧 DOM 更新不过 React):renderStartRef 镜像供
3374
+ // 逐帧换算;displayPos 由 rAF 追逐 clampedPos(指数趋近 .22/帧 ≈ .16s 到位)。
3375
+ const displayPosRef = useRef(0)
3376
+ const renderStartRef = useRef(0)
3377
+ renderStartRef.current = renderStart
3378
+ const applyShift = () => {
3379
+ const list = listRef.current
3380
+ if (list === null || list.children.length < 2) return
3381
+ const pitch = list.children[1].offsetTop - list.children[0].offsetTop
3382
+ if (!(pitch > 0)) return
3383
+ list.style.transform = 'translate3d(0,' + (-(displayPosRef.current - renderStartRef.current) * pitch).toFixed(2) + 'px,0)'
3384
+ // strip 高度显式管理:clipper 脱流后 strip 不能靠内容撑高(塌缩=整条消失)。
3385
+ // 纯内容高 = 可见行数 × 实测行距 − 间隙(12×35−5=415 / 12×9−5=103);呼吸
3386
+ // 在 strip 外(head 下边距 8 + 根下内边距 8);键高动画期间 RO 逐帧跟随。
3387
+ const rows = Math.min(WINDOW, entries.length)
3388
+ const strip = stripRef.current
3389
+ if (strip !== null) strip.style.height = (rows * pitch - 5).toFixed(1) + 'px'
3390
+ }
3391
+ useEffect(() => {
3392
+ let raf = 0
3393
+ const step = () => {
3394
+ const target = Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), scrollPos))
3395
+ const cur = displayPosRef.current
3396
+ if (Math.abs(target - cur) < 0.002) {
3397
+ displayPosRef.current = target
3398
+ applyShift()
3399
+ return
3400
+ }
3401
+ displayPosRef.current = cur + (target - cur) * 0.22
3402
+ applyShift()
3403
+ raf = requestAnimationFrame(step)
3404
+ }
3405
+ raf = requestAnimationFrame(step)
3406
+ return () => cancelAnimationFrame(raf)
3407
+ }, [scrollPos, entries.length])
3408
+ // renderStart 跳变(内容重排)后同步重设 transform(渲染后、绘制前),显示位
3409
+ // 不变 → 无视觉跳变;列表高度随键高变化(收拢/展开)时重算行距保持对齐。
3410
+ useEffect(() => { applyShift() })
3411
+ useEffect(() => {
3412
+ const list = listRef.current
3413
+ if (list === null || typeof ResizeObserver !== 'function') return undefined
3414
+ const ro = new ResizeObserver(() => applyShift())
3415
+ ro.observe(list)
3416
+ return () => ro.disconnect()
3417
+ }, [renderStart, entries.length])
2692
3418
 
2693
3419
  // 悬浮条滚轮/▲▼:只翻悬浮条自身窗口,绝不影响会话区滚动(用户明确要求)。
2694
3420
  const onKeyDown = (e) => {
@@ -2707,35 +3433,118 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2707
3433
  }
2708
3434
  useEffect(() => {
2709
3435
  if (kbGlobal === null) return
2710
- if (kbGlobal < winOffset) setWinOffset(kbGlobal)
2711
- else if (kbGlobal >= winOffset + WINDOW) setWinOffset(kbGlobal - WINDOW + 1)
3436
+ setScrollPos((o) => {
3437
+ const p = Math.max(0, Math.min(maxOff, o))
3438
+ if (kbGlobal < p) return kbGlobal
3439
+ if (kbGlobal >= p + WINDOW) return Math.max(0, Math.min(maxOff, kbGlobal - WINDOW + 1))
3440
+ return o
3441
+ })
2712
3442
  }, [kbGlobal])
2713
3443
 
2714
- // 滚轮翻页:React onWheel 是被动监听器(passive: true),内部 preventDefault
3444
+ // 滚轮平滑滚动:React onWheel 是被动监听器(passive: true),内部 preventDefault
2715
3445
  // 无效并刷屏告警(实测踩坑)——改原生 addEventListener('wheel', {passive:false})。
3446
+ // 增量累积(deltaMode 归一为像素;~0.012 行/px:一整格滚轮 ≈1.2 行,触控板小
3447
+ // 增量连续累积),rAF 追逐成连续位移。**静止吸附**:滚轮停 ~170ms 后取整到最近
3448
+ // 整行(chase 平滑滑过去)——小数位置会让顶/底条露半截(「展示了 T23 的一部
3449
+ // 分」实测踩坑),用户要求静止时恰好完整 12 条。
2716
3450
  const stripRef = useRef(null)
3451
+ const listRef = useRef(null) // T5 虚拟滚动列表容器(键的父级,transform 平移)
3452
+ const snapTimerRef = useRef(0)
2717
3453
  useEffect(() => {
2718
3454
  const el = stripRef.current
2719
3455
  if (el === null) return undefined
2720
3456
  const onNativeWheel = (e) => {
2721
3457
  e.preventDefault()
2722
3458
  e.stopPropagation()
2723
- if (e.deltaY > 0) setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1))
2724
- else setWinOffset((o) => Math.max(0, o - 1))
3459
+ let dy = e.deltaY
3460
+ if (e.deltaMode === 1) dy *= 33
3461
+ else if (e.deltaMode === 2) dy *= 300
3462
+ setScrollPos((o) => Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), o + dy * sfSettings.current.pianoWheelSpeed)))
3463
+ // 静止吸附:连续滚动不断顺延;停止后吸附到最近整行。
3464
+ if (snapTimerRef.current !== 0) clearTimeout(snapTimerRef.current)
3465
+ snapTimerRef.current = setTimeout(() => {
3466
+ snapTimerRef.current = 0
3467
+ setScrollPos((o) => Math.round(o))
3468
+ }, sfSettings.current.pianoSnapMs)
2725
3469
  }
2726
3470
  el.addEventListener('wheel', onNativeWheel, { passive: false })
2727
- return () => el.removeEventListener('wheel', onNativeWheel)
3471
+ return () => {
3472
+ el.removeEventListener('wheel', onNativeWheel)
3473
+ if (snapTimerRef.current !== 0) { clearTimeout(snapTimerRef.current); snapTimerRef.current = 0 }
3474
+ }
2728
3475
  }, [entries.length])
2729
3476
 
3477
+ // 面板锚定刷新:detailPos 在渲染时刻从键的实时矩形计算——mouseenter 常发生在
3478
+ // 收拢/半展开几何(root 宽 32),面板被锚在收拢条右缘=鼠标旁,且展开完成后无
3479
+ // 重渲染而停留在旧坐标。监听过渡结束(React 合成事件不含 transitionend 的完整
3480
+ // 语义),仅几何属性(width/height/max-height)且面板显示中时强制重渲染,用最终
3481
+ // 几何重算(面板可见延迟 .28s > 展开 .26s,用户看到的就是正确位置)。
3482
+ useEffect(() => {
3483
+ const el = rootRef.current
3484
+ if (el === null) return undefined
3485
+ const onEnd = (e) => {
3486
+ if (detailIdxRef.current !== null &&
3487
+ (e.propertyName === 'width' || e.propertyName === 'height' || e.propertyName === 'max-height')) {
3488
+ force((t) => t + 1)
3489
+ }
3490
+ }
3491
+ el.addEventListener('transitionend', onEnd)
3492
+ return () => el.removeEventListener('transitionend', onEnd)
3493
+ }, [])
3494
+
2730
3495
  // 右侧信息面板:hover 条目/键盘聚焦时弹出该轮信息(保留弹出面板、移除原生
2731
- // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目与面板间移动 150ms 宽限。
3496
+ // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目间移动 250ms 宽限。
2732
3497
  const showDetail = (idx) => {
3498
+ const lock = collapseLockRef.current
3499
+ if (Date.now() < lock.until) {
3500
+ // 锁检查必须先于 clearTimeout:收拢条上的合成 mouseenter(skipped)不得
3501
+ // 清掉待执行的隐藏定时器(实测:连续 skipped 把面板拖了 ~1s 不走)。
3502
+ if (lock.kind === 'enter') {
3503
+ // 进入护栏期(hover-guard):推迟到锁过期后重试——驻留则面板照常出现,
3504
+ // 快速路过则随离场作废(重试前检查 root 仍 :hover)。
3505
+ if (lock.retry) clearTimeout(lock.retry)
3506
+ lock.retry = setTimeout(() => {
3507
+ lock.retry = null
3508
+ if (rootRef.current !== null && rootRef.current.matches(':hover')) showDetail(idx)
3509
+ }, Math.max(20, lock.until - Date.now() + 20))
3510
+ return
3511
+ }
3512
+ return
3513
+ }
3514
+ if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
2733
3515
  if (detailTimer.current) clearTimeout(detailTimer.current)
2734
3516
  setDetailIdx(idx)
2735
3517
  }
2736
3518
  const hideDetail = () => {
3519
+ const lock = collapseLockRef.current
3520
+ if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
2737
3521
  if (detailTimer.current) clearTimeout(detailTimer.current)
2738
- detailTimer.current = setTimeout(() => setDetailIdx(null), 150)
3522
+ // 宽限 250ms:标题行/首键边界窄带来回移动时,面板滑出动画(.24s)频繁被
3523
+ // 打断重入,加长宽限减少闪动。
3524
+ detailTimer.current = setTimeout(() => setDetailIdx(null), 250)
3525
+ }
3526
+ const collapseLockRef = useRef({ until: 0, timer: null, retry: null, kind: '' })
3527
+ // hover-guard / collapse-lock 二合一:进入锁 120ms(「快速路过闪动」——锁窗内
3528
+ // :hover 不展开,路过即零动画;驻留 120ms 后照常展开)+离开锁 300ms(防几何
3529
+ // 自反馈回路:展开态越界→收拢→胶囊条弹到静止鼠标下→合成 mouseenter→再展开)。
3530
+ // kind 区分 showDetail 策略:enter 锁→推迟重试(驻留后面板照常),leave 锁→跳过。
3531
+ const armCollapseLock = (ms, kind) => {
3532
+ const lock = collapseLockRef.current
3533
+ const el = rootRef.current
3534
+ const w = typeof ms === 'number' ? ms : 300
3535
+ if (el !== null && el.getAttribute('data-collapse-lock') === null) {
3536
+ el.setAttribute('data-collapse-lock', '')
3537
+ }
3538
+ lock.kind = kind === 'enter' ? 'enter' : 'leave'
3539
+ lock.until = Date.now() + w
3540
+ if (lock.timer) clearTimeout(lock.timer)
3541
+ lock.timer = setTimeout(() => {
3542
+ lock.until = 0
3543
+ lock.kind = ''
3544
+ if (lock.timer) { clearTimeout(lock.timer); lock.timer = null }
3545
+ const el2 = rootRef.current
3546
+ if (el2 !== null) el2.removeAttribute('data-collapse-lock')
3547
+ }, w)
2739
3548
  }
2740
3549
  useEffect(() => () => { if (detailTimer.current) clearTimeout(detailTimer.current) }, [])
2741
3550
  // 键盘聚焦跟随显示面板。
@@ -2743,6 +3552,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2743
3552
  if (kbGlobal !== null && entries[kbGlobal]) showDetail(kbGlobal)
2744
3553
  }, [kbGlobal])
2745
3554
 
3555
+ // detailIdx 镜像同步(供 deps [] 的过渡监听闭包读取当前值)。
3556
+ useEffect(() => { detailIdxRef.current = detailIdx }, [detailIdx])
3557
+
2746
3558
  const rootStyle = pos === null ? { display: 'none' } : {
2747
3559
  left: pos.left + 'px',
2748
3560
  top: pos.top + 'px',
@@ -2752,12 +3564,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2752
3564
 
2753
3565
  const detailEntry = detailIdx !== null ? entries[detailIdx] : null
2754
3566
  // 面板位置:absolute 相对悬浮条容器(容器有 translateY(-50%) transform,fixed
2755
- // 会退化为相对容器——必须按容器内偏移计算)。条目 = strip 直接子元素,
2756
- // 索引 = detailIdx - winOffset;越界修正按视口坐标换算回容器内。
3567
+ // 会退化为相对容器——必须按容器内偏移计算)。条目 = 列表(pk-list)直接子元素,
3568
+ // 索引 = detailIdx - renderStart(虚拟窗口含缓冲);越界修正按视口坐标换算回容器内。
2757
3569
  let detailPos = null
2758
3570
  const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : null
2759
- if (detailEntry && rootRect) {
2760
- const el = stripRef.current ? stripRef.current.children[detailIdx - winOffset] : null
3571
+ if (detailEntry && rootRect && listRef.current) {
3572
+ const el = listRef.current.children[detailIdx - renderStart]
2761
3573
  if (el instanceof Element) {
2762
3574
  const r = el.getBoundingClientRect()
2763
3575
  const topV = Math.min(r.top, Math.max(4, window.innerHeight - 320))
@@ -2765,42 +3577,74 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2765
3577
  }
2766
3578
  }
2767
3579
 
2768
- return h('div', { 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle },
2769
- h('div', { className: 'pk-head' },
3580
+ return h('div', {
3581
+ 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle,
3582
+ onPointerEnter: () => {
3583
+ // 进入锁(hover-guard 120ms):快速路过收拢条零动画零闪动;驻留后照常展开。
3584
+ armCollapseLock(120, 'enter')
3585
+ },
3586
+ onPointerLeave: () => {
3587
+ // 面板随收拢同步消失:root 级离开=真实收拢开始,立即隐藏(淡出 .18s 与收拢
3588
+ // .22-.26s 同步收完)——不走 250ms 宽限+滑出拖尾 ~0.5s。
3589
+ if (detailIdx !== null) {
3590
+ if (detailTimer.current) clearTimeout(detailTimer.current)
3591
+ setDetailIdx(null)
3592
+ }
3593
+ // 锁只在「当前不在锁窗内」时武装:锁窗内的离开(条本就被抑制未展开)不续期,
3594
+ // 否则收拢条边缘快速进出让锁自续(实测 1.4s 不展开),300ms 后照常展开。
3595
+ if (Date.now() >= collapseLockRef.current.until) armCollapseLock()
3596
+ },
3597
+ },
3598
+ h('div', {
3599
+ className: 'pk-head',
3600
+ // 热区链:hover 标题行 ≡ hover 第一个键(CSS 侧高亮已链;JS 侧面板跟随)。
3601
+ // head↔gap↔首键连续同态,消灭「横条出现消失」的状态边界。
3602
+ onMouseEnter: () => showDetail(winOffset),
3603
+ onMouseLeave: hideDetail,
3604
+ },
2770
3605
  h('span', { className: 'pk-headLabel' }, STR.pianoTitle),
2771
3606
  h('div', { className: 'pk-navGroup' },
2772
- h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => setWinOffset((o) => Math.max(0, o - 1)), title: STR.expanded }, '▲'),
2773
- h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1)), title: STR.collapsed }, '▼'),
3607
+ h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => { const n = Math.max(0, winOffset - 1); setScrollPos(n); showDetail(n) }, title: STR.expanded }, '▲'),
3608
+ h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => { const n = Math.min(Math.max(0, entries.length - WINDOW), winOffset + 1); setScrollPos(n); showDetail(n) }, title: STR.collapsed }, '▼'),
2774
3609
  ),
2775
3610
  ),
2776
3611
  h('div', { className: 'pk-strip', ref: stripRef, tabIndex: 0, onKeyDown, onMouseDown: (e) => { if (e.target === e.currentTarget) e.preventDefault() }, 'aria-label': STR.pianoTitle },
2777
- windowed.map((entry, idx) => {
2778
- const gi = winOffset + idx
2779
- const act = activeIdx === gi
2780
- return h('button', {
2781
- key: 'turn-' + gi + '-' + entry.seq,
2782
- type: 'button',
2783
- className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
2784
- // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
2785
- // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
2786
- onClick: (e) => { open(entry); e.currentTarget.blur() },
2787
- onMouseEnter: () => showDetail(gi),
2788
- onMouseLeave: hideDetail,
2789
- onContextMenu: (e) => {
2790
- e.preventDefault()
2791
- if (onOpenWorkbench) onOpenWorkbench()
3612
+ // T5 裁剪框(strip 内容盒):缓冲键全裁,可见区恰好 12 行;strip 本体保留
3613
+ // 内边距(视觉呼吸)+ 定位(relative)。
3614
+ h('div', { className: 'pk-clip' },
3615
+ // T5 虚拟滚动列表:transform 由 JS 逐帧驱动(applyShift),不设 style
3616
+ // 以免 React 重渲染覆盖 JS 值。
3617
+ h('div', { className: 'pk-list', ref: listRef },
3618
+ windowed.map((entry, idx) => {
3619
+ const gi = renderStart + idx
3620
+ const act = activeIdx === gi
3621
+ return h('button', {
3622
+ key: 'turn-' + gi + '-' + entry.seq,
3623
+ type: 'button',
3624
+ className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
3625
+ // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
3626
+ // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
3627
+ onClick: (e) => { open(entry); e.currentTarget.blur() },
3628
+ onMouseEnter: () => showDetail(gi),
3629
+ onMouseLeave: hideDetail,
3630
+ onContextMenu: (e) => {
3631
+ e.preventDefault()
3632
+ if (onOpenWorkbench) onOpenWorkbench()
3633
+ },
2792
3634
  },
2793
- },
2794
- // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
2795
- h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
2796
- h('span', { className: 'pk-label' }, entry.preview || '(空)'),
2797
- )
2798
- }),
3635
+ // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
3636
+ h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
3637
+ h('span', { className: 'pk-label' }, entry.preview || '(空)'),
3638
+ )
3639
+ }),
3640
+ ),
3641
+ ),
2799
3642
  ),
2800
3643
  // 信息面板:跟随 hover 条目右侧展开(fixed,位置由 detailPos 控制);
2801
3644
  // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
2802
3645
  h('div', { className: 'pk-detail' + (detailEntry ? '' : ' hidden'), style: detailPos, onMouseEnter: () => showDetail(detailIdx), onMouseLeave: hideDetail },
2803
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) }),
2804
3648
  detailEntry && h('div', { key: 'b' + detailEntry.turn, className: 'pk-detailBody' }, detailEntry.preview || '(空)'),
2805
3649
  ),
2806
3650
  // 跳转状态提示:常驻渲染 + hidden 类(从下方淡入/淡出);未加载轮次提示。
@@ -2816,27 +3660,170 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2816
3660
  function DetailsDockView(props) {
2817
3661
  const { sessionId, connection, onExit } = props
2818
3662
  const [state, setState] = useState({ phase: 'loading', error: null, data: null })
2819
- const [collapsed, setCollapsed] = useState(() => new Set())
3663
+ // T8-B:重挂载续播时折叠集合按钉住时间线播种(新回合照常并入折叠)。
3664
+ const [collapsed, setCollapsed] = useState(() => {
3665
+ const s = new Set()
3666
+ if (dockBridge.live && dockBridge.live.on && dockBridge.live.lastState) {
3667
+ for (const t of dockBridge.live.lastState.timeline || []) s.add('turn-' + t.turn)
3668
+ }
3669
+ return s
3670
+ })
2820
3671
  const collapsedInit = useRef(false)
2821
3672
  const [turnItems, setTurnItems] = useState(() => new Map())
2822
3673
  const loadingTurns = useRef(new Set())
2823
3674
 
2824
- useEffect(() => {
2825
- let alive = true
3675
+ // ── T6 dock 实时模式:复用实时通道(sessions.history → derive,与详情页 M6
3676
+ // 同构),但只显示「开播后」的对话——首轮记录基准回合集合,此后仅显示新出现
3677
+ // 的回合(含开播时正在运行的回合);不加载历史回合(档案 get 在实时期间跳过)。
3678
+ // ── T8-B 钉住原会话:dock 视图随会话切换整体重挂载(槽位 session 作用域
3679
+ // key),组件级实时状态必丢——实时状态提升到 dockBridge.live 模块级存储
3680
+ // (钉住 sid/基准/已见/定时器/最近状态跨挂载存活):开播即钉住当时的会话,
3681
+ // GUI 切走后右栏继续直播原会话并显示提示条(告知用户),可一键转播当前会话。
3682
+ const liveStore = dockBridge.live || (dockBridge.live = {
3683
+ on: false, sid: undefined, timer: null, base: null, seen: new Set(),
3684
+ lastState: null, conn: undefined, emit: null, tick: null,
3685
+ })
3686
+ const [liveOn, setLiveOn] = useState(() => liveStore.on)
3687
+ const [liveState, setLiveState] = useState(() => liveStore.lastState
3688
+ || { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
3689
+ const fetchLive = useRef(async () => {})
3690
+ fetchLive.current = async () => {
3691
+ const sid = liveStore.sid
3692
+ const conn = liveStore.conn
3693
+ if (sid === undefined || conn === undefined || conn.api === undefined || conn.api.sessions === undefined) {
3694
+ liveStore.lastState = { phase: 'error', error: STR.liveUnavailable, timeline: [], running: false, eventCount: 0, health: null }
3695
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3696
+ return
3697
+ }
3698
+ if (liveStore.emit !== null) liveStore.emit({ ...liveStateKeep(), phase: 'loading' })
3699
+ try {
3700
+ const val = sfRemoteRef.current ? await sfRemoteRef.current.pageBySession(sid, 400) : undefined
3701
+ if (val === undefined) {
3702
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail }
3703
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3704
+ return
3705
+ }
3706
+ const events = historyEventsOf(val)
3707
+ const derived = await api('derive', { events, now: Date.now() })
3708
+ if (!derived || !derived.ok) {
3709
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: (derived && derived.error) || STR.liveFail }
3710
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3711
+ return
3712
+ }
3713
+ const tl = (derived.timeline && derived.timeline.turns) || []
3714
+ const running = derived.running === true
3715
+ if (liveStore.base === null) {
3716
+ // 开播基准:当时已存在的全部回合;运行中的那个除外(它是「正在发生的
3717
+ // 实时对话」,从开播起就要展示其后续流式变化)。
3718
+ liveStore.base = new Set(tl.map((t) => t.turn))
3719
+ if (running && tl.length > 0) liveStore.base.delete(tl[tl.length - 1].turn)
3720
+ }
3721
+ // 只保留:开播基准之后新出现的回合 + 开播时正在运行的那个回合。
3722
+ const display = tl.filter((t) => !liveStore.base.has(t.turn))
3723
+ const newTurns = display.filter((t) => !liveStore.seen.has(t.turn))
3724
+ for (const t of display) liveStore.seen.add(t.turn)
3725
+ if (newTurns.length > 0 && liveStore.emit !== null) {
3726
+ setCollapsed((prev) => {
3727
+ const next = new Set(prev)
3728
+ for (const t of newTurns) next.add('turn-' + t.turn)
3729
+ return next
3730
+ })
3731
+ }
3732
+ liveStore.lastState = { phase: 'ready', error: null, timeline: display, running, eventCount: events.length, health: derived.health || null }
3733
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3734
+ } catch (e) {
3735
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }
3736
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3737
+ }
3738
+ }
3739
+ // 保留当前显示数据、只换 phase 的辅助(emit 前构造)。
3740
+ function liveStateKeep() {
3741
+ return liveStore.lastState || { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null }
3742
+ }
3743
+ const enterLive = () => {
3744
+ liveStore.on = true
3745
+ liveStore.sid = sessionId // T8-B:开播即钉住当前会话
3746
+ liveStore.seen.clear()
3747
+ liveStore.base = null
3748
+ liveStore.lastState = null
3749
+ setLiveOn(true)
3750
+ liveFollowRef.current = true // T7:开播默认吸底
3751
+ fetchLive.current()
3752
+ if (liveStore.timer === null) {
3753
+ liveStore.timer = setInterval(() => { if (liveStore.tick !== null) liveStore.tick() }, sfSettings.current.livePollMs)
3754
+ }
3755
+ }
3756
+ const exitLive = () => {
3757
+ liveStore.on = false
3758
+ liveStore.sid = undefined
3759
+ liveStore.seen.clear()
3760
+ liveStore.base = null
3761
+ liveStore.lastState = null
3762
+ if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
3763
+ setLiveOn(false)
3764
+ setLiveState({ phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
3765
+ // 回到档案视图:重新加载(实时期间可能已产生新回合)。
2826
3766
  setState({ phase: 'loading', error: null, data: null })
2827
3767
  setTurnItems(new Map())
2828
3768
  collapsedInit.current = false
2829
- if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
2830
- api('get', { sessionId }).then((json) => {
2831
- if (!alive) return
2832
- if (json && json.ok) {
2833
- setState({ phase: 'ready', error: null, data: json })
2834
- } else {
2835
- setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
3769
+ }
3770
+ // T8-B:转播当前会话(提示条按钮)——重钉到当前 sessionId、基准重算。
3771
+ const rebindLive = () => {
3772
+ liveStore.sid = sessionId
3773
+ liveStore.seen.clear()
3774
+ liveStore.base = null
3775
+ fetchLive.current()
3776
+ }
3777
+ // 挂载注册:连接对象/状态发射器/定时器分发指向当前实例(跨重挂载续播);
3778
+ // 卸载时若 3s 内无新实例接管(dock 真正关闭走 onExit→exitLive),停表清理。
3779
+ useEffect(() => {
3780
+ liveStore.conn = props.connection
3781
+ liveStore.emit = setLiveState
3782
+ liveStore.tick = () => { fetchLive.current() }
3783
+ return () => {
3784
+ if (liveStore.emit === setLiveState) liveStore.emit = null
3785
+ if (liveStore.tick !== null) {
3786
+ const t = liveStore.tick
3787
+ setTimeout(() => { if (liveStore.tick === t && liveStore.emit === null) {
3788
+ if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
3789
+ liveStore.on = false; liveStore.tick = null
3790
+ } }, 500)
2836
3791
  }
2837
- }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
3792
+ }
3793
+ }, [])
3794
+ // T7 实时吸底跟随(dock):dk-body 距底 ≤40px 时每次刷新平滑滚到新底部。
3795
+ const dkBodyRef = useRef(null)
3796
+ const liveFollowRef = useRef(true)
3797
+ const onDkBodyScroll = () => {
3798
+ const el = dkBodyRef.current
3799
+ if (el === null) return
3800
+ liveFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
3801
+ }
3802
+ useEffect(() => {
3803
+ if (!liveOn || liveState.timeline === null || liveState.timeline.length === 0) return
3804
+ const el = dkBodyRef.current
3805
+ if (el !== null && liveFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
3806
+ }, [liveState.timeline, liveOn])
3807
+
3808
+ useEffect(() => {
3809
+ let alive = true
3810
+ // T8-B:实时钉住在 liveStore.sid(会话切换不重绑);档案加载仅非实时时进行。
3811
+ if (!liveOn) {
3812
+ setState({ phase: 'loading', error: null, data: null })
3813
+ setTurnItems(new Map())
3814
+ collapsedInit.current = false
3815
+ if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
3816
+ api('get', { sessionId }).then((json) => {
3817
+ if (!alive) return
3818
+ if (json && json.ok) {
3819
+ setState({ phase: 'ready', error: null, data: json })
3820
+ } else {
3821
+ setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
3822
+ }
3823
+ }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
3824
+ }
2838
3825
  return () => { alive = false }
2839
- }, [sessionId])
3826
+ }, [sessionId, liveOn])
2840
3827
 
2841
3828
  useEffect(() => {
2842
3829
  if (state.phase !== 'ready' || collapsedInit.current) return
@@ -2890,14 +3877,40 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2890
3877
 
2891
3878
  return h('div', { className: 'dk-root' },
2892
3879
  h('div', { className: 'dk-head' },
2893
- h('span', { className: 'dk-title' }, STR.dockHint),
3880
+ // T6:标题「会话流」+ 按钮**紧跟标题**(左侧区);spacer 吃掉右侧余量——
3881
+ // 彻底避开右上角 dsh-better-sidebar toggleCluster(视口级常驻浮钮区)。
3882
+ h('span', { className: 'dk-title', title: STR.dockHint }, STR.entry),
3883
+ props.connection !== undefined && h('button', {
3884
+ className: 'dk-btn' + (liveOn ? ' dk-btnLive' : ''),
3885
+ onClick: liveOn ? exitLive : enterLive,
3886
+ title: STR.dockLiveHint,
3887
+ }, liveOn ? STR.liveActive + ' ✕' : STR.live),
2894
3888
  h('button', { className: 'dk-btn', onClick: onExit }, STR.dockExit),
3889
+ h('span', { className: 'dk-headSpacer' }),
2895
3890
  ),
2896
- h('div', { className: 'dk-body' },
2897
- state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
2898
- state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
2899
- state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
2900
- state.phase === 'ready' && h('div', { className: 'dk-summary' },
3891
+ h('div', { className: 'dk-body', ref: dkBodyRef, onScroll: onDkBodyScroll },
3892
+ liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
3893
+ h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
3894
+ healthBadge(liveState.health),
3895
+ h('span', { className: 'sf-liveText' },
3896
+ h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
3897
+ liveState.phase === 'loading' && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveRefreshing),
3898
+ ),
3899
+ ),
3900
+ liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
3901
+ liveOn && liveStore.sid !== undefined && liveStore.sid !== sessionId && h('div', { className: 'dk-pinBar', title: liveStore.sid },
3902
+ h('span', { className: 'dk-pinText' }, STR.dockPinNotice),
3903
+ h('button', { className: 'dk-btn', onClick: rebindLive }, STR.dockPinRebind),
3904
+ ),
3905
+ liveOn && liveState.phase !== 'error' && liveState.timeline.length === 0 && h('div', { className: 'sf-hint' }, STR.dockLiveEmpty),
3906
+ liveOn && liveState.timeline.length > 0 && h(TimelineTurns, {
3907
+ turns: liveState.timeline, collapsed, toggle, liveActive: liveState.running === true,
3908
+ activeTurn: null, setActiveTurn: () => {},
3909
+ }),
3910
+ !liveOn && state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
3911
+ !liveOn && state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
3912
+ !liveOn && state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
3913
+ !liveOn && state.phase === 'ready' && h('div', { className: 'dk-summary' },
2901
3914
  ruleTaskCount > 0 && h('div', { className: 'dk-summaryRow' },
2902
3915
  h('span', { className: 'dk-summaryTag' }, STR.summaryTaskCount),
2903
3916
  h('span', { className: 'dk-summaryText' }, ruleTaskCount),
@@ -2916,8 +3929,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2916
3929
  ),
2917
3930
  data.summary && h('div', { className: 'dk-summaryFull' }, data.summary),
2918
3931
  ),
2919
- state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2920
- state.phase === 'ready' && h(TurnList, {
3932
+ !liveOn && state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
3933
+ !liveOn && state.phase === 'ready' && h(TurnList, {
2921
3934
  sessionId,
2922
3935
  lightTurns,
2923
3936
  collapsed,
@@ -2929,11 +3942,233 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2929
3942
  )
2930
3943
  }
2931
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
+
2932
4167
  // ── M11:钢琴键竖条挂载(DOM 注入 + 自愈)────────────────────────
2933
4168
  // 容器挂 document.body(dsh-navbar 同款模式):完全脱离会话区布局树,
2934
4169
  // 不干扰 conversation 的 flex/grid 布局;fixed 定位由组件自管理。
2935
4170
  // MutationObserver 自愈:容器丢失(布局重建)时自动恢复。
2936
- function mountPianoKeys(controller, sessions, connection) {
4171
+ function mountPianoKeys(controller, sessions, connection, settingsCtrl) {
2937
4172
  let root = undefined
2938
4173
  let container = undefined
2939
4174
  let waitObserver = null
@@ -2957,9 +4192,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2957
4192
  document.body.appendChild(container)
2958
4193
  try {
2959
4194
  root = createRoot(container)
2960
- root.render(h(TurnKeys, {
4195
+ root.render(h(NavRoot, {
2961
4196
  sessions,
2962
4197
  connection,
4198
+ settingsCtrl: settingsCtrl || null,
2963
4199
  onOpenWorkbench: () => controller.open(),
2964
4200
  }))
2965
4201
  syncChatVisibility()
@@ -3127,7 +4363,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3127
4363
  }
3128
4364
 
3129
4365
  // ── 插件体 ─────────────────────────────────────────────────────────
3130
- const inject = ['sessions', 'connection', 'slots', 'layout']
4366
+ const inject = ['sessions', 'connection', 'slots', 'layout', 'remote', 'remote.session']
3131
4367
 
3132
4368
  function apply(ctx) {
3133
4369
  try {
@@ -3135,12 +4371,17 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3135
4371
  if (!document.getElementById('dsh-session-flow-style')) {
3136
4372
  const style = document.createElement('style')
3137
4373
  style.id = 'dsh-session-flow-style'
3138
- style.textContent = STYLE
4374
+ style.textContent = STYLE + '\n' + SETTINGS_STYLE
3139
4375
  document.head.appendChild(style)
3140
4376
  }
3141
4377
 
3142
4378
  const sessions = ctx.get('sessions')
3143
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
3144
4385
 
3145
4386
  const state = { open: false, listeners: new Set() }
3146
4387
  const controller = {
@@ -3215,6 +4456,27 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3215
4456
  } catch (error) {
3216
4457
  console.error('[dsh-session-flow] header health chip registration failed:', error)
3217
4458
  }
4459
+ // 插件设置页:settings.section 顶级 tab(order 140,排在皮肤中心 120/宠物 130 之后)。
4460
+ // 读写经 settingsScope(pet 同款:优先 webUiSettings 桥,回退 settingsScope);
4461
+ // 两者都不可用(旧宿主/未暴露)时不注册 tab。controller 闭包持有,
4462
+ // 卸载经 slots.inject 返回的 disposer 释放 scope 订阅。
4463
+ try {
4464
+ const settingsBinder = ctx.get('webUiSettings') ?? ctx.get('settingsScope')
4465
+ if (settingsBinder !== undefined && settingsBinder !== null && typeof settingsBinder.bind === 'function') {
4466
+ sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
4467
+ slots.inject('settings.section', () => {
4468
+ const unregister = slots.register({
4469
+ name: 'settings.section',
4470
+ id: 'session-flow',
4471
+ order: 140,
4472
+ label: () => STR.settingsTitle,
4473
+ }, () => h(SessionFlowSettingsSection, { controller: sfSettingsCtrl }))
4474
+ return () => { sfSettingsCtrl.dispose(); unregister() }
4475
+ })
4476
+ }
4477
+ } catch (error) {
4478
+ console.error('[dsh-session-flow] settings.section registration failed:', error)
4479
+ }
3218
4480
  }
3219
4481
 
3220
4482
  // M12:详情并入右侧栏 —— details 槽位动态占用(priority -1 低于官方 tool-details)。
@@ -3233,66 +4495,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3233
4495
  } catch (e) {}
3234
4496
  }
3235
4497
 
3236
- // M12×aionui 兼容层:官方 details 把手按 3 轨模型定位(left = frameWidth - details,
3237
- // 假设 details 是最后一轨);aionui-panel frame grid 扩为 5 轨
3238
- // (sidebar|center|details|preview|explorer)后,真实边界左移 preview+explorer px,
3239
- // 把手却停在旧位置(悬浮于预览列上方,见 docs/02-design/LAYOUT-COEXIST.md)。
3240
- // dock 激活期间按实际轨宽重算把手位置:三方写 frame style → MutationObserver;
3241
- // 纯缩放(grid 字符串不变)→ ResizeObserver。3 轨时公式退化为官方原值,无副作用。
3242
- const parseGridTracksCompat = (input) => {
3243
- const tracks = []
3244
- let depth = 0
3245
- let current = ''
3246
- for (const ch of String(input)) {
3247
- if (ch === '(') depth += 1
3248
- if (ch === ')') depth = Math.max(0, depth - 1)
3249
- if (ch === ' ' && depth === 0) {
3250
- if (current !== '') { tracks.push(current); current = '' }
3251
- continue
3252
- }
3253
- current += ch
3254
- }
3255
- if (current !== '') tracks.push(current)
3256
- return tracks
3257
- }
3258
- const detailsHandleCompat = {
3259
- frame: null,
3260
- observer: null,
3261
- sizeObserver: null,
3262
- start() {
3263
- if (this.observer !== null) return
3264
- const frame = document.querySelector('[data-dsh-frame]') ||
3265
- (document.querySelector('[class*="sidebarCol"]') || {}).parentElement || null
3266
- if (frame === null) return
3267
- this.frame = frame
3268
- this.observer = new MutationObserver(() => this.apply())
3269
- this.observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
3270
- this.sizeObserver = new ResizeObserver(() => this.apply())
3271
- this.sizeObserver.observe(frame)
3272
- this.apply()
3273
- },
3274
- stop() {
3275
- if (this.observer !== null) { this.observer.disconnect(); this.observer = null }
3276
- if (this.sizeObserver !== null) { this.sizeObserver.disconnect(); this.sizeObserver = null }
3277
- this.frame = null
3278
- },
3279
- apply() {
3280
- const frame = this.frame
3281
- if (frame === null) return
3282
- const tracks = parseGridTracksCompat(frame.style.gridTemplateColumns)
3283
- if (tracks.length < 3) return
3284
- const trackPx = (t) => {
3285
- const m = /^(-?[\d.]+)px$/.exec(String(t).trim())
3286
- return m === null ? 0 : Number(m[1])
3287
- }
3288
- const details = trackPx(tracks[2])
3289
- const preview = tracks.length >= 4 ? trackPx(tracks[3]) : 0
3290
- const explorer = tracks.length >= 5 ? trackPx(tracks[4]) : 0
3291
- const handle = frame.querySelector('[data-side="details"]')
3292
- if (handle === null) return
3293
- handle.style.left = `${Math.round(frame.getBoundingClientRect().width - details - preview - explorer)}px`
3294
- },
3295
- }
4498
+ // M12×aionui:官方 details 把手在 5 grid 下的位置补偿已由上游
4499
+ // aionui-panel ≥0.2.0 接管(dsh-web-ui PR #311,applyGrid 每帧重算;
4500
+ // 3 轨时退化为官方原值)。本插件过渡兼容层 detailsHandleCompat 已移除
4501
+ // (2026-08-18,本机 aionui 0.2.0 实证含修复;旧版 aionui 请升级)。
3296
4502
  const ensureColumn = () => {
3297
4503
  if (dockBridge.open && layout !== undefined && typeof layout.openDetails === 'function') {
3298
4504
  try { layout.openDetails() } catch (e) {}
@@ -3334,7 +4540,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3334
4540
  if (!dockBridge.open) {
3335
4541
  dockBridge.open = true
3336
4542
  syncDetails()
3337
- detailsHandleCompat.start()
3338
4543
  // 会话切换时官方会自动 closeDetails —— 高频守护快速重开(无动画,内容直接刷新)。
3339
4544
  if (detailsGuard === null) {
3340
4545
  detailsGuard = setInterval(() => { ensureColumn() }, 150)
@@ -3346,7 +4551,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3346
4551
  if (dockBridge.open) {
3347
4552
  dockBridge.open = false
3348
4553
  syncDetails()
3349
- detailsHandleCompat.stop()
3350
4554
  if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3351
4555
  for (const fn of dockBridge.listeners) fn()
3352
4556
  }
@@ -3387,7 +4591,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3387
4591
  disposers.push(mountSidebarEntry(controller))
3388
4592
  disposers.push(mountBoard(controller, sessions, connection))
3389
4593
  // M11:钢琴键会话快切(会话页左侧常驻竖条);挂载失败不影响其他功能。
3390
- disposers.push(mountPianoKeys(controller, sessions, connection))
4594
+ disposers.push(mountPianoKeys(controller, sessions, connection, sfSettingsCtrl))
3391
4595
  } catch (error) {
3392
4596
  console.error('[dsh-session-flow] mount failed:', error)
3393
4597
  }
@@ -3400,7 +4604,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3400
4604
  }
3401
4605
  if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3402
4606
  setDockActive(false)
3403
- detailsHandleCompat.stop()
3404
4607
  if (layout !== undefined && typeof layout.closeDetails === 'function') {
3405
4608
  try { layout.closeDetails() } catch (e) {}
3406
4609
  }