dsh-session-flow 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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
 
@@ -40,6 +40,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
40
40
  pianoJumpFail: '该轮次尚未加载:请在会话区向上滚动加载历史后再点击',
41
41
  fullText: '全文检索', fullTextSearching: '搜索中…', fullTextPartial: '已扫描 {N}/{M} 个会话(结果未完整)', fullTextNone: '全文无命中', fullTextFail: '全文检索失败',
42
42
  dock: '并入右栏', dockExit: '退出并入', dockHint: '会话流已并入右侧栏 · 随当前会话切换', dockEmpty: '打开会话后可在此查看会话流',
43
+ dockLiveHint: '实时:只显示开播后的对话,不加载历史回合', dockLiveEmpty: '实时中 · 等待新对话…', dockLiveEnterFail: '当前会话已结束或不可实时',
44
+ dockPinNotice: '📌 直播已钉住原会话(当前已切换会话)', dockPinRebind: '转播当前会话',
43
45
  rename: '重命名', renamePlaceholder: '输入新标题…', renameFail: '重命名失败', origTitle: '原名',
44
46
  search: '搜索标题/工具/文件… 支持 tool: pwsh · file: src · err:', allWorkspaces: '全部工作区',
45
47
  sortRecent: '最近运行', sortNewest: '最近创建', sortOldest: '最早创建', sortTools: '工具最多', sortLongest: '耗时最长',
@@ -72,12 +74,30 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
72
74
  summaryStale: '⚠ 摘要生成后有新对话,内容可能已不准确,建议重新生成',
73
75
  llmSummary: '生成 LLM 摘要', llmGenerating: '生成中…', llmFail: '生成失败', summaryRuleTag: '规则', summaryLlmTag: 'LLM',
74
76
  cacheHint: '仅清理本插件的缓存(可自动重建),不影响 DSH 会话存档与任何其他数据', cacheLimit: '时间线缓存上限',
77
+ healthActive: '活跃', healthToolWait: '工具执行中', healthQuiet: '静默中', healthStalled: '疑似卡死',
78
+ healthSilentMin: '静默 {N} 分钟', healthSilentSec: '静默 {N} 秒',
79
+ healthChipTitle: '会话健康状态(会话流)· 点击打开详情',
80
+ renameClearBlocked: '该会话标题由官方会话管理:请输入新标题(不支持清空)',
81
+ settingsTitle: '会话流', settingsDesc: '调整轮次悬浮条与实时跟踪的行为参数。保存后立即生效(轮次悬浮条行数在重进会话页后完全生效)。',
82
+ setGroupPiano: '轮次悬浮条', setGroupLive: '实时跟踪', setGroupHealth: '健康监控',
83
+ setPianoWindow: '可见行数', setPianoWindowHint: '悬浮条同时显示的轮次条数(6–18)',
84
+ setPianoWheelSpeed: '滚轮灵敏度', setPianoWheelSpeedHint: '每像素滚动的行数(0.005–0.03),越大滚得越快',
85
+ setPianoSnapMs: '静止吸附延迟(ms)', setPianoSnapMsHint: '滚轮停止多少毫秒后吸附到整行(100–400)',
86
+ setLivePollMs: '轮询间隔(ms)', setLivePollMsHint: '实时模式的刷新间隔(1500–10000),下次开播生效',
87
+ setLiveFollowPx: '吸底阈值(px)', setLiveFollowPxHint: '距底部多少像素内视为「在底部」并跟随滚动(20–120)',
88
+ setLiveHistoryTurns: '保留历史回合', setLiveHistoryTurnsHint: '实时模式下保留的历史回合数(0–10),下次开播生效',
89
+ setStallThresholdMin: '卡死阈值(分钟)', setStallThresholdMinHint: '运行中但静默超过该时长判定为疑似卡死(1–10)',
90
+ setSave: '保存', setDiscard: '放弃更改', setSaveFail: '部分设置未保存成功,请检查后重试',
91
+ setReset: '恢复默认', setOverridden: '已修改', setInvalid: '超出范围或格式无效',
92
+ setUnavailable: '当前环境未暴露设置服务,无法编辑(请升级 dsh 后重试)',
75
93
  } : {
76
94
  entry: 'Session Flow', title: 'Session Flow', rescan: 'Rescan', scanning: 'Scanning…',
77
95
  pianoTitle: 'Turns', pianoNew: 'New session', pianoFav: 'Favorite', pianoUnfav: 'Unfavorite', pianoFavs: 'Favorites', pianoOpenInFlow: 'Open in Session Flow',
78
96
  pianoJumpFail: 'Turn not loaded — scroll up in the conversation to load history, then retry',
79
97
  fullText: 'Full-text', fullTextSearching: 'Searching…', fullTextPartial: 'Scanned {N}/{M} sessions (partial)', fullTextNone: 'No full-text matches', fullTextFail: 'Full-text search failed',
80
98
  dock: 'Dock right', dockExit: 'Undock', dockHint: 'Session Flow docked · follows current session', dockEmpty: 'Open a session to view its flow here',
99
+ dockLiveHint: 'Live: only the conversation since enabled, no history', dockLiveEmpty: 'Live · waiting for new turns…', dockLiveEnterFail: 'Session ended or not live-capable',
100
+ dockPinNotice: '📌 Live pinned to previous session (session switched)', dockPinRebind: 'Follow current session',
81
101
  rename: 'Rename', renamePlaceholder: 'New title…', renameFail: 'Rename failed', origTitle: 'Original',
82
102
  search: 'Search title / tools / files… use tool: · file: · err:', allWorkspaces: 'All workspaces',
83
103
  sortRecent: 'Recently run', sortNewest: 'Recently created', sortOldest: 'Oldest first', sortTools: 'Most tools', sortLongest: 'Longest',
@@ -111,8 +131,218 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
111
131
  summaryStale: '⚠ New conversation since summary was generated; content may be outdated — regenerate',
112
132
  llmSummary: 'Generate LLM summary', llmGenerating: 'Generating…', llmFail: 'Failed', summaryRuleTag: 'rule', summaryLlmTag: 'LLM',
113
133
  cacheHint: "Only this plugin's cache (auto-rebuilds); never touches DSH session archives or other data", cacheLimit: 'Timeline cache limit',
134
+ healthActive: 'Active', healthToolWait: 'Tool running', healthQuiet: 'Quiet', healthStalled: 'Likely stalled',
135
+ healthSilentMin: 'silent {N} min', healthSilentSec: 'silent {N} s',
136
+ healthChipTitle: 'Session health (Session Flow) · click to open detail',
137
+ renameClearBlocked: 'Title is managed by the official session: enter a new title (clearing unsupported)',
138
+ settingsTitle: 'Session Flow', settingsDesc: 'Tune the piano-keys strip and live-tracking behavior. Applies on save (strip row count fully applies after re-entering a session page).',
139
+ setGroupPiano: 'Turn strip', setGroupLive: 'Live tracking', setGroupHealth: 'Health monitor',
140
+ setPianoWindow: 'Visible rows', setPianoWindowHint: 'How many turn keys the strip shows at once (6–18)',
141
+ setPianoWheelSpeed: 'Wheel speed', setPianoWheelSpeedHint: 'Rows scrolled per pixel (0.005–0.03); larger scrolls faster',
142
+ setPianoSnapMs: 'Snap delay (ms)', setPianoSnapMsHint: 'Delay after the wheel stops before snapping to a whole row (100–400)',
143
+ setLivePollMs: 'Poll interval (ms)', setLivePollMsHint: 'Live refresh interval (1500–10000); applies to the next live session',
144
+ setLiveFollowPx: 'Follow threshold (px)', setLiveFollowPxHint: 'Distance from the bottom that still counts as "at bottom" and follows (20–120)',
145
+ setLiveHistoryTurns: 'History turns kept', setLiveHistoryTurnsHint: 'History turns kept in live mode (0–10); applies to the next live session',
146
+ setStallThresholdMin: 'Stall threshold (min)', setStallThresholdMinHint: 'Running but silent beyond this counts as likely stalled (1–10)',
147
+ setSave: 'Save', setDiscard: 'Discard', setSaveFail: 'Some settings were not saved — check and retry',
148
+ setReset: 'Reset', setOverridden: 'Modified', setInvalid: 'Out of range or invalid format',
149
+ setUnavailable: 'The settings service is not exposed in this environment (upgrade dsh and retry)',
114
150
  }
115
151
 
152
+ // ── 插件设置(settings.section 独立 tab)──────────────────────────
153
+ // Host 注册 settings 命名空间 'session-flow'(lib/host.js SETTINGS_DEFAULTS 同源);
154
+ // client 经 settingsScope 读写(pet 同款:优先 webUiSettings 桥,回退 ctx.settingsScope)。
155
+ // 全部消费点读 sfSettings.current.xxx,缺省 = 原硬编码值——无设置时行为零变化。
156
+ const SETTINGS_DEFAULTS = { pianoWindow: 12, pianoWheelSpeed: 0.012, pianoSnapMs: 170, livePollMs: 3000, liveFollowPx: 40, liveHistoryTurns: 3, stallThresholdMin: 3 }
157
+ const sfSettings = { current: { ...SETTINGS_DEFAULTS } }
158
+ const SETTING_GROUPS = [
159
+ { titleKey: 'setGroupPiano', fields: ['pianoWindow', 'pianoWheelSpeed', 'pianoSnapMs'] },
160
+ { titleKey: 'setGroupLive', fields: ['livePollMs', 'liveFollowPx', 'liveHistoryTurns'] },
161
+ { titleKey: 'setGroupHealth', fields: ['stallThresholdMin'] },
162
+ ]
163
+ const SETTING_FIELD_SPECS = {
164
+ pianoWindow: { min: 6, max: 18, integer: true, labelKey: 'setPianoWindow', hintKey: 'setPianoWindowHint' },
165
+ pianoWheelSpeed: { min: 0.005, max: 0.03, integer: false, labelKey: 'setPianoWheelSpeed', hintKey: 'setPianoWheelSpeedHint' },
166
+ pianoSnapMs: { min: 100, max: 400, integer: true, labelKey: 'setPianoSnapMs', hintKey: 'setPianoSnapMsHint' },
167
+ livePollMs: { min: 1500, max: 10000, integer: true, labelKey: 'setLivePollMs', hintKey: 'setLivePollMsHint' },
168
+ liveFollowPx: { min: 20, max: 120, integer: true, labelKey: 'setLiveFollowPx', hintKey: 'setLiveFollowPxHint' },
169
+ liveHistoryTurns: { min: 0, max: 10, integer: true, labelKey: 'setLiveHistoryTurns', hintKey: 'setLiveHistoryTurnsHint' },
170
+ stallThresholdMin: { min: 1, max: 10, integer: true, labelKey: 'setStallThresholdMin', hintKey: 'setStallThresholdMinHint' },
171
+ }
172
+ function formatSettingValue(v) { return v === undefined || v === null ? '' : String(v) }
173
+ function parseSettingValue(spec, text) {
174
+ const n = Number(String(text).trim())
175
+ if (!Number.isFinite(n) || n < spec.min || n > spec.max) return undefined
176
+ if (spec.integer && !Number.isInteger(n)) return undefined
177
+ return n
178
+ }
179
+
180
+ // 设置表单控制器:草稿(staged edits)+ scope 读写(pet CardForm 简化版——
181
+ // 同样是「保存才落盘」语义;getSnapshot 缓存快照供 useSyncExternalStore)。
182
+ class SfSettingsController {
183
+ constructor(scope) {
184
+ this.scope = scope
185
+ this.listeners = new Set()
186
+ this.draft = new Map() // key -> { text, clear }
187
+ this.saving = false
188
+ this.failed = false
189
+ this.cached = null
190
+ this.unsubscribe = scope.subscribe(() => this.notify())
191
+ this.notify()
192
+ }
193
+ dispose() {
194
+ try { if (this.unsubscribe) this.unsubscribe() } catch (e) {}
195
+ this.unsubscribe = null
196
+ this.listeners.clear()
197
+ }
198
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) }
199
+ // 快照值合并进模块级桥(所有功能代码的消费点;settings 变化即时下发)。
200
+ applySnapshotToBridge() {
201
+ try {
202
+ const snap = this.scope.getSnapshot()
203
+ if (snap && snap.status === 'ready' && snap.value && typeof snap.value === 'object') {
204
+ sfSettings.current = { ...SETTINGS_DEFAULTS, ...snap.value }
205
+ }
206
+ } catch (e) {}
207
+ }
208
+ notify() {
209
+ this.applySnapshotToBridge()
210
+ this.cached = this.buildSnapshot()
211
+ for (const fn of this.listeners) fn()
212
+ }
213
+ getSnapshot() {
214
+ if (this.cached === null) this.cached = this.buildSnapshot()
215
+ return this.cached
216
+ }
217
+ buildSnapshot() {
218
+ let snap = null
219
+ try { snap = this.scope.getSnapshot() } catch (e) {}
220
+ const status = snap ? String(snap.status || 'unavailable') : 'unavailable'
221
+ const value = { ...SETTINGS_DEFAULTS, ...(snap && snap.value ? snap.value : {}) }
222
+ const user = (snap && snap.user && typeof snap.user === 'object') ? snap.user : {}
223
+ const fields = {}
224
+ for (const key of Object.keys(SETTING_FIELD_SPECS)) {
225
+ const spec = SETTING_FIELD_SPECS[key]
226
+ const staged = this.draft.get(key)
227
+ const stored = Object.prototype.hasOwnProperty.call(user, key)
228
+ if (staged === undefined) {
229
+ fields[key] = { text: formatSettingValue(value[key]), overridden: stored, invalid: false, clear: false }
230
+ } else {
231
+ fields[key] = { text: staged.text, overridden: !staged.clear, invalid: !staged.clear && parseSettingValue(spec, staged.text) === undefined, clear: staged.clear }
232
+ }
233
+ }
234
+ return {
235
+ status,
236
+ writable: snap ? snap.writable !== false : false,
237
+ fields,
238
+ dirty: this.draft.size > 0,
239
+ invalid: Object.keys(fields).some((k) => fields[k].invalid),
240
+ saving: this.saving,
241
+ failed: this.failed,
242
+ }
243
+ }
244
+ edit(key, text) { this.draft.set(key, { text, clear: false }); this.failed = false; this.notify() }
245
+ resetField(key) {
246
+ // 恢复默认 = 清除 user 层覆盖(草稿显示 base 值,保存时 unset)。
247
+ let baseText = formatSettingValue(SETTINGS_DEFAULTS[key])
248
+ try {
249
+ const snap = this.scope.getSnapshot()
250
+ if (snap && snap.base && snap.base[key] !== undefined) baseText = formatSettingValue(snap.base[key])
251
+ } catch (e) {}
252
+ this.draft.set(key, { text: baseText, clear: true })
253
+ this.failed = false
254
+ this.notify()
255
+ }
256
+ discard() { this.draft.clear(); this.failed = false; this.notify() }
257
+ async save() {
258
+ const snap = this.getSnapshot()
259
+ if (this.draft.size === 0 || this.saving || snap.invalid) return
260
+ const pending = new Map(this.draft)
261
+ this.saving = true
262
+ this.failed = false
263
+ this.notify()
264
+ const landed = new Set()
265
+ for (const [key, staged] of pending) {
266
+ try {
267
+ if (staged.clear) { await this.scope.unset(key); landed.add(key); continue }
268
+ const v = parseSettingValue(SETTING_FIELD_SPECS[key], staged.text)
269
+ if (v === undefined) continue
270
+ await this.scope.set(key, v)
271
+ landed.add(key)
272
+ } catch (e) {}
273
+ }
274
+ for (const [key, before] of pending) if (landed.has(key) && this.draft.get(key) === before) this.draft.delete(key)
275
+ this.saving = false
276
+ this.failed = landed.size !== pending.size
277
+ this.notify()
278
+ }
279
+ }
280
+
281
+ // 设置弹窗「会话流」分节:分组表单(左标签+说明,右数字输入),底部保存/放弃。
282
+ function SessionFlowSettingsSection(props) {
283
+ const controller = props.controller
284
+ const state = useSyncExternalStore((fn) => controller.subscribe(fn), () => controller.getSnapshot())
285
+ if (state.status !== 'ready') {
286
+ return h('div', { className: 'sfset-root' }, h('p', { className: 'sfset-unavailable' }, STR.setUnavailable))
287
+ }
288
+ const disabled = !state.writable || state.saving
289
+ const rows = []
290
+ for (const group of SETTING_GROUPS) {
291
+ rows.push(h('div', { key: group.titleKey, className: 'sfset-groupTitle' }, STR[group.titleKey]))
292
+ for (const key of group.fields) {
293
+ const spec = SETTING_FIELD_SPECS[key]
294
+ const field = state.fields[key]
295
+ rows.push(h('div', { key, className: 'sfset-row' + (field.invalid ? ' sfset-invalid' : '') },
296
+ h('div', { className: 'sfset-meta' },
297
+ h('div', { className: 'sfset-label' }, STR[spec.labelKey],
298
+ field.overridden ? h('span', { className: 'sfset-overridden' }, STR.setOverridden) : null),
299
+ h('div', { className: 'sfset-hint' }, STR[spec.hintKey])),
300
+ h('div', { className: 'sfset-controls' },
301
+ h('input', {
302
+ className: 'sfset-input', type: 'number',
303
+ min: spec.min, max: spec.max, step: spec.integer ? 1 : 0.001,
304
+ value: field.text, disabled,
305
+ onChange: (e) => controller.edit(key, e.target.value),
306
+ }),
307
+ h('button', { type: 'button', className: 'sfset-reset', disabled, onClick: () => controller.resetField(key) }, STR.setReset))))
308
+ }
309
+ }
310
+ return h('div', { className: 'sfset-root' },
311
+ h('p', { className: 'sfset-desc' }, STR.settingsDesc),
312
+ rows,
313
+ h('div', { className: 'sfset-footer' },
314
+ h('button', { type: 'button', className: 'sfset-save', disabled: disabled || !state.dirty || state.invalid, onClick: () => { controller.save() } }, STR.setSave),
315
+ h('button', { type: 'button', className: 'sfset-discard', disabled: disabled || !state.dirty, onClick: () => controller.discard() }, STR.setDiscard),
316
+ state.invalid ? h('span', { className: 'sfset-error' }, STR.setInvalid) : null,
317
+ state.failed ? h('span', { className: 'sfset-error' }, STR.setSaveFail) : null))
318
+ }
319
+
320
+ // 取色对齐宠物设置卡(同一弹窗内经真机明暗/皮肤验证):纯 dsw-alias 变量、
321
+ // 不写 hex 兜底——变量缺失时继承弹窗上下文色,硬编码兜底在皮肤主题下反而会撞色。
322
+ const SETTINGS_STYLE = [
323
+ `.sfset-root{padding:4px 2px;display:flex;flex-direction:column;max-width:560px}`,
324
+ `.sfset-desc{margin:0 0 10px;font-size:13px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}`,
325
+ `.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}`,
326
+ `.sfset-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 0}`,
327
+ `.sfset-meta{min-width:0;flex:1}`,
328
+ `.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}`,
329
+ `.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}`,
330
+ `.sfset-hint{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary);margin-top:2px}`,
331
+ `.sfset-controls{flex:none;display:flex;align-items:center;gap:8px}`,
332
+ `.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)}`,
333
+ `.sfset-row.sfset-invalid .sfset-input{border-color:var(--dsw-alias-label-error)}`,
334
+ `.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}`,
335
+ `.sfset-reset:hover:not(:disabled){color:var(--dsw-alias-label-primary)}`,
336
+ `.sfset-footer{display:flex;align-items:center;gap:8px;margin-top:16px;border-top:1px solid var(--dsw-alias-border-l2);padding-top:12px}`,
337
+ `.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)}`,
338
+ `.sfset-save:disabled{opacity:.4;cursor:default}`,
339
+ `.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)}`,
340
+ `.sfset-discard:hover:not(:disabled){color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-dimmed)}`,
341
+ `.sfset-discard:disabled{opacity:.4;cursor:default}`,
342
+ `.sfset-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}`,
343
+ `.sfset-unavailable{font-size:13px;line-height:1.5;color:var(--dsw-alias-state-warn-primary)}`,
344
+ ].join('\n')
345
+
116
346
  // ── 右侧导航图标(组件级共享,定义在顶层避免作用域问题)───────────
117
347
  // feather 风格线条,currentColor 自适应明暗主题、与文字同色。
118
348
  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>`
@@ -140,7 +370,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
140
370
  `[data-dsh-frame][data-sidebar-collapsed] .sf-entryLabel{display:none}`,
141
371
  // box-sizing:border-box 必须:height:100% + padding 在 content-box 下会溢出容器,
142
372
  // 产生多余的整页滚动条,导致 top 栏随滚动消失(用户实测反馈)。
143
- `.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}`,
373
+ // T2 修复:flex:1 1 0(flex-basis 用绝对 0 长度,非 flex:1 0% 百分比)。
374
+ // 根因(诊断数据 d47b82bf 实测):官方 viewArea 是 min-height:auto(聊天视图靠它
375
+ // 撑开内容 + 粘性输入框,不可全局强改);flex:1 的 0% 基准在父级高度不定时被
376
+ // Chrome 按 content 处理 → .sf-view 假设尺寸=内容高 → viewArea 内容下限被顶高 →
377
+ // 溢出 scrollBody → 整块滚动。基准用绝对 0 → 假设尺寸=0 → viewArea 下限=0 →
378
+ // 回到 flex 分配空间,两列各自内部滚动。工作台模式父容器非 flex,flex 无效无害。
379
+ `.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}`,
144
380
  // top 栏悬浮固定:flex:none 在布局上已固定;再加 sticky + 背景双保险。
145
381
  `.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}`,
146
382
  `.sf-viewTitle{color:var(--dsw-alias-label-primary,#222);white-space:nowrap;margin:0;font-size:16px;font-weight:700}`,
@@ -179,6 +415,29 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
179
415
  `.sf-badgeEnd{background:rgba(120,120,120,.12);color:#888}`,
180
416
  `.sf-badgeSub{background:rgba(240,150,60,.15);color:#d97706}`,
181
417
  `.sf-badgeErr{background:rgba(230,80,80,.13);color:#d43b3b}`,
418
+ // 卡死监控徽标:疑似卡死(红,呼吸动画引起注意)/ 工具执行中·静默中(黄)。
419
+ `.sf-badgeStall{background:rgba(230,80,80,.16);color:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
420
+ `.sf-badgeWait{background:rgba(220,170,40,.15);color:#a87b00}`,
421
+ // 会话页头部健康芯片(官方 header.actions 槽位,模式标识右侧)。
422
+ // 钢琴键同款语言:收拢态 = 彩色短胶囊条(语义色保留:绿/黄/红,卡死带呼吸脉冲);
423
+ // 悬浮/聚焦展开为毛玻璃文字标签(backdrop-filter + color-mix,与 pk-key 展开态同构)。
424
+ `.sf-healthChip{display:inline-flex;align-items:center;gap:6px;height:20px;padding:0 3px;margin-left:2px;border:none;border-radius:99px;background:transparent;cursor:pointer;font-family:inherit;overflow:hidden;outline:none;transition:padding .22s ease,background .18s ease,border-color .18s ease,box-shadow .18s ease}`,
425
+ `.sf-healthChip .sf-hcBar{flex:none;width:4px;height:16px;border-radius:99px;transition:width .22s ease,height .22s ease}`,
426
+ `.sf-healthChip .sf-hcLabel{font-size:11px;line-height:16px;white-space:nowrap;max-width:0;opacity:0;overflow:hidden;user-select:none;transition:max-width .24s ease,opacity .16s ease .05s}`,
427
+ // 展开态:毛玻璃底(blur 10px)+ 同色系淡 tint 背景(状态色 14% 透明混入 72% 基底色),
428
+ // 边框带 40% 状态色呼应;tint 淡雅、毛玻璃质感保留。
429
+ `.sf-healthChip:hover,.sf-healthChip:focus-visible{padding:3px 9px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
430
+ `.sf-healthChip.hc-active:hover,.sf-healthChip.hc-active:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(28,158,90,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#1c9e5a)}`,
431
+ `.sf-healthChip.hc-wait:hover,.sf-healthChip.hc-wait:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(217,165,20,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#d9a514)}`,
432
+ `.sf-healthChip.hc-stall:hover,.sf-healthChip.hc-stall:focus-visible{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 72%,rgba(212,59,59,.14));border-color:color-mix(in srgb,var(--dsw-alias-border-l2,#e4e6eb) 60%,#d43b3b)}`,
433
+ `.sf-healthChip:hover .sf-hcBar,.sf-healthChip:focus-visible .sf-hcBar{width:6px;height:6px;border-radius:99px}`,
434
+ `.sf-healthChip:hover .sf-hcLabel,.sf-healthChip:focus-visible .sf-hcLabel{max-width:200px;opacity:1}`,
435
+ `.sf-healthChip.hc-active .sf-hcBar{background:#1c9e5a}`,
436
+ `.sf-healthChip.hc-active .sf-hcLabel{color:#1c9e5a}`,
437
+ `.sf-healthChip.hc-wait .sf-hcBar{background:#d9a514}`,
438
+ `.sf-healthChip.hc-wait .sf-hcLabel{color:#a87b00}`,
439
+ `.sf-healthChip.hc-stall .sf-hcBar{background:#d43b3b;animation:sfBadgePulse 2.8s ease-in-out infinite}`,
440
+ `.sf-healthChip.hc-stall .sf-hcLabel{color:#d43b3b}`,
182
441
  `.sf-statsRow{display:flex;gap:12px;flex-wrap:wrap;margin-top:3px}`,
183
442
  `.sf-hint{color:var(--dsw-alias-label-secondary,#888);font-size:12px;padding:2px 2px 0}`,
184
443
  // ── 工作区筛选 tabs(独立一行,醒目)──
@@ -307,17 +566,26 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
307
566
  `.sf-fulltextCount{flex:none;border-radius:999px;background:rgba(90,140,255,.15);color:#4a7dff;font-size:10px;line-height:15px;min-width:18px;text-align:center;padding:0 5px;font-weight:700}`,
308
567
  `.sf-fulltext .sf-muted{font-size:11px;color:var(--dsw-alias-label-secondary,#666)}`,
309
568
  // M12:详情并入右侧栏(details 槽位轻量视图)。
569
+ // 头部布局:标题+按钮**聚左**,spacer 吃掉右侧余量——彻底规避右上角
570
+ // dsh-better-sidebar toggleCluster(视口级常驻浮钮,压过官方 details 列右缘,
571
+ // 实测「退出并入」被压)。按钮不再进右上角浮动图标区,任何第三方浮钮都压不到。
310
572
  `.dk-root{box-sizing:border-box;height:100%;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 12px;overflow:hidden;color:var(--dsw-alias-label-primary,#222);font-family:var(--dsw-font-family,inherit)}`,
311
- `.dk-head{flex:none;display:flex;align-items:center;gap:8px;min-width:0}`,
312
- `.dk-title{flex:1;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
313
- `.dk-btn{flex:none;border:1px solid var(--dsw-alias-border-l2,#ddd);background:var(--dsw-alias-bg-layer-1,#f5f5f5);color:var(--dsw-alias-label-primary,#222);border-radius:7px;padding:3px 9px;font-size:11px;cursor:pointer}`,
573
+ `.dk-head{flex:none;display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}`,
574
+ `.dk-title{flex:none;min-width:0;font-size:13px;font-weight:700;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}`,
575
+ `.dk-headSpacer{flex:1 1 auto;min-width:0;align-self:stretch;pointer-events:none}`,
576
+ `.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}`,
314
577
  `.dk-btn:hover{border-color:var(--dsw-alias-brand-primary,#4a7dff);color:var(--dsw-alias-brand-primary,#4a7dff)}`,
578
+ // T6:dock 实时开关激活态(绿色语义,与详情页 sf-liveBar 同色系)。
579
+ `.dk-btn.dk-btnLive{border-color:rgba(28,158,90,.5);background:rgba(28,158,90,.08);color:#1c9e5a;font-weight:600}`,
315
580
  `.dk-body{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:6px}`,
316
581
  `.dk-summary{border:1px solid var(--dsw-alias-border-l2,#e4e6eb);border-radius:10px;padding:8px 10px;background:var(--dsw-alias-bg-layer-1,#f8f9fa);flex:none;display:flex;flex-direction:column;gap:4px}`,
317
582
  `.dk-summaryRow{display:flex;gap:6px;font-size:11.5px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);min-width:0}`,
318
583
  `.dk-summaryTag{flex:none;border-radius:5px;padding:0 5px;font-size:10px;line-height:16px;font-weight:700;background:rgba(90,140,255,.12);color:#4a7dff}`,
319
584
  `.dk-summaryText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
320
585
  `.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}`,
586
+ // T8-B 钉住提示条:琥珀色警示(直播会话 ≠ 当前会话)。
587
+ `.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}`,
588
+ `.dk-pinText{flex:1;min-width:0;font-size:12px;color:#c07a2a}`,
321
589
  // M12:并入期间禁用 layout grid 列宽过渡——官方会话切换会 closeDetails(宽 360→0),
322
590
  // 守护重开(0→360)若带过渡动画会产生「关→开」闪烁;禁用后切换直接刷新内容(实测踩坑)。
323
591
  `html[data-dsh-dock-active] [class*=frame],html[data-dsh-dock-active] [data-side=details]{transition:none!important}`,
@@ -362,35 +630,73 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
362
630
  // 垂直居中悬浮于会话内容区中间(top 由组件算区中心 + translateY(-50%))。
363
631
  // 动画体系:一切组件的产生/消失都有「来源与目标」——display 硬切全部改为
364
632
  // transform/opacity/max-height 过渡(实测踩坑:display 切换无动画、视觉突兀)。
365
- `[data-dsh-piano-keys]{position:fixed;left:0;top:50%;width:32px;max-height:60vh;z-index:15;display:flex;flex-direction:column;font-family:var(--dsw-font-family,inherit);transition:width .26s cubic-bezier(.3,1.15,.4,1) .22s}`,
366
- // 收拢时序:离开时宽度过渡延迟 .22s——信息面板先滑出(.24s),悬浮条主体再收拢
367
- // (用户要求:收拢第一步先收起详情信息框);进入(hover/focus)无延迟即时展开。
368
- `[data-dsh-piano-keys]:hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
633
+ `[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)}`,
634
+ // 收拢时序(T4):宽度与条目高度(.pk-key .22s)**同时起跑**——用户要求纵向横向
635
+ // 同时收拢(此前宽度延迟 .22s 造成「先纵后横」两段式);详情信息框仍先行立即
636
+ // 滑出(.pk-detail.hidden 无延迟),不参与纵向节奏。进入(hover/focus)同态:
637
+ // 无延迟即时展开,收拢=展开的倒放。
638
+ // 收拢锁(collapse-lock):pointerleave 后 300ms 内抑制 :hover 展开(focus-within 不受影响)。
639
+ // 日志实锤的几何自反馈回路:展开态越界 → 整条收拢(几何瞬变)→ 收拢后的胶囊条
640
+ // 「弹到」静止鼠标下方 → 合成 mouseenter → :hover 重新展开 → 再越界……每 300-500ms
641
+ // 一个来回(「横条反复出现消失」)。锁窗内 :hover 不展开,回路断开;真实驻留 300ms
642
+ // 后正常展开(顺带构成驻留去抖:快速掠过不再闪开)。
643
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover,[data-dsh-piano-keys]:focus-within{width:200px;transition-delay:0s}`,
369
644
  `html[${ACTIVE_ATTR}] [data-dsh-piano-keys]{display:none!important}`,
370
- `.pk-strip{flex:1;min-height:0;max-height:calc(60vh - 60px);overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;gap:5px;padding:8px 4px;scrollbar-width:none;outline:none}`,
371
- `.pk-strip::-webkit-scrollbar{display:none}`,
645
+ // T5:strip 高度 JS 显式管理(setStripHeight:可见行数 × 实测行距 + 上下内边距
646
+ // 16 − 间隙 5;收拢/展开键高动画期间 RO 逐帧跟随)——clipper 是 absolute 脱流,
647
+ // strip 不能再靠内容撑高(实测塌缩整条消失)。**必须 flex:1 1 auto**(basis:auto
648
+ // 时 height 属性才是弹性基准;flex:1 = basis 0% 会架空 JS height——实测踩坑)。
649
+ // shrink 保留配合 60vh 硬上限压缩。裁剪面=pk-clip(absolute inset:0 = strip
650
+ // 【内容盒】):CSS overflow 裁剪在内边距盒——上下缓冲键各露 3px 进内边距带
651
+ // (「上面会话的下边框露出一点点」实测踩坑);clipper 把可见区收到恰好 12 行。
652
+ // 垂直呼吸全部移出 strip:标题行下边距 8 + 根容器下内边距 8(均静止件);
653
+ // strip 零 padding——绝对定位 clipper 的 inset:0 解析的是【padding box】而非
654
+ // 内容盒,padding 留在 strip 上不参与裁剪、键却从 y=0 起排画进 padding 带
655
+ // (顶键贴死标题行、底部框线被裁——实测踩坑)。strip 高度=纯内容 rows×pitch−5。
656
+ `.pk-strip{flex:1 1 auto;min-height:0;max-height:calc(60vh - 60px);position:relative;outline:none}`,
657
+ `.pk-clip{position:absolute;inset:0;overflow:hidden}`,
658
+ // 键挂 pk-list 上;transform 由 JS 逐帧驱动(rAF 追逐 scrollPos + 实测行距,
659
+ // renderStart 离散跳变时同步重设——CSS transition 会把内容重排的跳变也做成
660
+ // 动画,每滚一行顿一次,实测踩坑)。
661
+ // 垂直内边距在 strip(静止件)不在 list(移动件):padding 挂移动件会随
662
+ // transform 平移,键从 y=8 起排 → 缓冲键底边 +3px 露出裁剪线(实测踩坑)。
663
+ `.pk-list{display:flex;flex-direction:column;gap:5px;padding:0 4px;will-change:transform}`,
372
664
  // 收拢态:短横条(胶囊线);overflow:hidden 使 label 文字被高度裁剪(来源=胶囊);
373
665
  // ::before 延伸命中区(±4px,点击/悬浮无死区)。
374
- `.pk-key{position:relative;flex:none;height:4px;min-height:4px;border-radius:99px;border:none;cursor:pointer;padding:0;margin:0 4px;background:var(--dsw-alias-border-l2,#d7dae0);color:var(--dsw-alias-label-primary,#222);overflow:hidden;transition:height .22s ease,padding .22s ease,margin .22s ease,border-radius .22s ease,background .14s ease,border-color .14s ease,box-shadow .18s ease;outline:none;min-width:0}`,
666
+ // T4 二阶段:box-sizing:border-box + 确定高度(4px↔30px)——原展开态 height:auto
667
+ // 不可插值(日志实锤:全程无 height 过渡事件),高度在两态间瞬移 120↔408px,
668
+ // 收拢条边缘 hover 进出即整条几何反复跳变(用户实测「闪烁抽搐」)。
669
+ `.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}`,
375
670
  `.pk-key::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px}`,
671
+ // 热区填满死区(三日志轮实锤症状一):strip 上下内边距 8px 只有 ::before ±4px 覆盖,
672
+ // 标题行/首键之间残留 4px 死区——鼠标停进去 key[0] 高亮+详情面板熄灭,跨出又点亮,
673
+ // 窄带内反复横跳=「横条出现消失」。首/末键 ::before 精确补到 strip 边缘(-8px),
674
+ // 带内任意位置都算 hover 该键,高亮/面板稳定常驻;键间隙 5px 由相邻 ±4px 互叠已全覆盖。
675
+ `.pk-key:first-child::before{top:-8px}`,
676
+ `.pk-key:last-child::before{bottom:-8px}`,
376
677
  `.pk-key:hover{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
377
678
  `.pk-key:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,#4a7dff);outline-offset:-1px}`,
378
679
  `.pk-keyActive{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
379
680
  `.pk-keyFocus{background:var(--dsw-alias-brand-primary,#4a7dff)}`,
380
681
  // 展开态(悬浮/聚焦):条目变文字卡片;label 常驻(缩略态被条目高度裁剪),
381
682
  // 展开时随高度过渡露出文字(来源=胶囊内部,目标=卡片全文)。
382
- `[data-dsh-piano-keys]:hover .pk-key,[data-dsh-piano-keys]:focus-within .pk-key{display:flex;align-items:center;gap:6px;height:auto;min-height:28px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2,#e4e6eb);padding:5px 8px;margin:0;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 80%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);box-shadow:0 1px 3px rgba(0,0,0,.07)}`,
383
- `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key:hover{background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 96%,transparent);border-color:var(--dsw-alias-brand-primary,#4a7dff)}`,
384
- `[data-dsh-piano-keys]:hover .pk-keyActive,[data-dsh-piano-keys]:focus-within .pk-keyActive{border-color:var(--dsw-alias-brand-primary,#4a7dff);background:color-mix(in srgb,var(--dsw-alias-brand-primary,#4a7dff) 10%,var(--dsw-alias-bg-base,#fff))}`,
683
+ // T4 二阶段:height:autoheight:30px(label 18 + padding 10 + border 2 = 原自然
684
+ // 高度,最终几何不变)——确定长度可插值,与收拢态 4px 平滑互转(倒放对称)。
685
+ `[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)}`,
686
+ // 热区链(四日志轮定位症状一):head↔gap↔首键 必须连续同态,否则边界 1px 晃动
687
+ // 即反复点亮/熄灭首键高亮(横条出现消失)。gap 由首键 ::before 覆盖(上文),
688
+ // head 由下行选择器链到首键:hover 标题行 ≡ hover 第一个键。
689
+ `[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)}`,
690
+ `[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))}`,
385
691
  `.pk-label{flex:1;min-width:0;font-size:12px;line-height:18px;max-height:18px;color:var(--dsw-alias-label-primary,#222);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:none;opacity:0;transition:opacity .18s ease .06s}`,
386
- `[data-dsh-piano-keys]:hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
692
+ `[data-dsh-piano-keys]:not([data-collapse-lock]):hover .pk-label,[data-dsh-piano-keys]:focus-within .pk-label{opacity:1}`,
387
693
  // 轮次序号:缩略态折叠(宽度 0 + 透明),展开态从左侧展开露出(来源=左缘)。
388
694
  `.pk-turnNo{flex:none;width:0;min-width:0;overflow:hidden;opacity:0;text-align:left;font-size:9.5px;line-height:16px;font-weight:600;color:var(--dsw-alias-label-tertiary,#999);font-family:ui-monospace,Consolas,monospace;user-select:none;white-space:nowrap;transition:width .22s ease,opacity .16s ease .04s}`,
389
- `[data-dsh-piano-keys]:hover .pk-turnNo,[data-dsh-piano-keys]:focus-within .pk-turnNo{width:28px;min-width:28px;opacity:1}`,
695
+ `[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}`,
390
696
  // hover 条目:在【该条目右侧】展开信息面板(.pk-detail)——absolute 相对悬浮条容器,
391
697
  // 坐标按条目位置换算为容器内偏移(注意:容器有 transform:translateY(-50%),fixed 子元素
392
698
  // 会被 transform 祖先劫持成相对容器定位——用视口坐标会双重偏移,实测踩坑)。
393
- `[data-dsh-piano-keys]:hover .pk-key:hover,[data-dsh-piano-keys]:focus-within .pk-key.pk-keyFocus{position:relative;z-index:2;box-shadow:0 4px 16px rgba(0,0,0,.18)}`,
699
+ `[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)}`,
394
700
  // 展开时序:详情框延迟 .28s 再滑入——等悬浮条展开动画完成、条目就位后,
395
701
  // 位置才准确(第一次展开时条目仍在变形,立即计算会错位,实测踩坑);
396
702
  // 隐藏则立即(无延迟)。
@@ -405,14 +711,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
405
711
  `.pk-status{position:absolute;left:0;right:0;top:calc(100% + 4px);padding:7px 10px;border-radius:8px;background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 82%,transparent);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);border:1px solid var(--dsw-alias-border-l2,#e4e6eb);font-size:11px;line-height:1.5;color:var(--dsw-alias-label-secondary,#666);box-shadow:0 4px 14px rgba(0,0,0,.12);z-index:6;text-align:center;white-space:normal;word-break:break-word;opacity:1;transform:translateY(0);transition:opacity .18s ease,transform .22s ease,visibility .22s}`,
406
712
  `.pk-status.hidden{opacity:0;transform:translateY(8px);visibility:hidden;pointer-events:none}`,
407
713
  `.pk-status.warn{color:#d97706;border-color:rgba(217,119,6,.4)}`,
408
- // 展开态显示悬浮条细滚动条(缩略态保持无滚动条)。
409
- `[data-dsh-piano-keys]:hover .pk-strip,[data-dsh-piano-keys]:focus-within .pk-strip{scrollbar-width:thin;scrollbar-color:var(--dsw-alias-border-l2,#d7dae0) transparent}`,
410
- `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar,[data-dsh-piano-keys]:focus-within .pk-strip::-webkit-scrollbar{width:6px;display:block}`,
411
- `[data-dsh-piano-keys]:hover .pk-strip::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,#d7dae0);border-radius:3px}`,
412
714
  // 头部:从上方滑入(来源=悬浮条顶缘)——max-height/padding/opacity/transform 过渡,
413
715
  // 不占缩略态布局。内容「标题 + ▲▼ 按钮组(并排右上)」。
414
- `.pk-head{display:flex;flex:none;align-items:center;gap:4px;padding:0 6px;color:var(--dsw-alias-label-secondary,#666);font-size:10px;line-height:1.2;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e5e5);max-height:0;overflow:hidden;opacity:0;transform:translateY(-6px);transition:max-height .24s ease,padding .24s ease,opacity .18s ease,transform .24s ease}`,
415
- `[data-dsh-piano-keys]:hover .pk-head,[data-dsh-piano-keys]:focus-within .pk-head{max-height:30px;padding-top:5px;padding-bottom:5px;opacity:1;transform:translateY(0)}`,
716
+ `.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}`,
717
+ `[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)}`,
416
718
  `.pk-headLabel{flex:1;min-width:0;font-size:10.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}`,
417
719
  `.pk-navGroup{flex:none;display:flex;gap:3px}`,
418
720
  `.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}`,
@@ -562,6 +864,104 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
562
864
  )
563
865
  }
564
866
 
867
+ // ── 重命名对齐官方 + 卡死监控 共享辅助 ─────────────────────────────
868
+ /** 官方 rename RPC(log-backed session/title user 事件):本插件与官方侧栏同一数据源。 */
869
+ const renameViaOfficial = (conn, sessionId, title) => {
870
+ return conn.api.sessions.rename({ sessionId, title }).then((resp) => {
871
+ const res = resp && resp.result
872
+ if (res && res.ok === true && res.value && typeof res.value.title === 'string') return res.value.title
873
+ throw new Error((res && res.error && res.error.message) || STR.renameFail)
874
+ })
875
+ }
876
+ const canOfficialRename = (conn) =>
877
+ conn !== undefined && conn.api !== undefined && conn.api.sessions !== undefined && typeof conn.api.sessions.rename === 'function'
878
+ /** 静默时长文本({N} 占位)。 */
879
+ const fmtSilent = (idleMs) => {
880
+ if (idleMs === null || idleMs === undefined) return ''
881
+ const min = Math.floor(idleMs / 60000)
882
+ return min >= 1
883
+ ? STR.healthSilentMin.replace('{N}', String(min))
884
+ : STR.healthSilentSec.replace('{N}', String(Math.max(1, Math.round(idleMs / 1000))))
885
+ }
886
+ /** 健康徽标(详情实时条 / 总览卡片共用);active/ended/unknown 不渲染(已有进行中/已结束徽标)。 */
887
+ const healthBadge = (health) => {
888
+ if (!health) return null
889
+ const silent = fmtSilent(health.idleMs)
890
+ if (health.kind === 'stalled') return h('span', { className: 'sf-badge sf-badgeStall' }, '🔴 ' + STR.healthStalled + (silent ? ' · ' + silent : ''))
891
+ if (health.kind === 'tool-wait') return h('span', { className: 'sf-badge sf-badgeWait' }, '🟡 ' + STR.healthToolWait + (silent ? ' · ' + silent : ''))
892
+ if (health.kind === 'quiet') return h('span', { className: 'sf-badge sf-badgeWait' }, '🟡 ' + STR.healthQuiet + (silent ? ' · ' + silent : ''))
893
+ return null
894
+ }
895
+
896
+ // ── 会话页头部健康芯片(官方 conversation.session.header.actions 槽位)──
897
+ // 位置:官方模式标识(agent-preset label, order -10)右侧(order -9)。
898
+ // 数据:sessions.list 快照跟踪 running(官方真实状态,随 host 流即时翻转)→
899
+ // running 时 10s 轮询 tail history → host derive 健康分类(与总览探测同管线)。
900
+ // 点击:唤起会话流工作台并直达该会话详情(workbenchBridge 意图桥)。
901
+ function SessionHealthChip(props) {
902
+ const { sessionId, connection, sessions } = props
903
+ const [running, setRunning] = useState(false)
904
+ const [health, setHealth] = useState(null)
905
+ useEffect(() => {
906
+ if (!sessionId || !sessions || !sessions.list || typeof sessions.list.getSnapshot !== 'function') return undefined
907
+ const compute = () => {
908
+ try {
909
+ // sessions.list 快照 = { ids, current, byId }(byId[id] 含 running)——
910
+ // 不是 { items }(那是 workspace 浏览器用的另一个视图快照,曾误用导致芯片恒 null)。
911
+ const snap = sessions.list.getSnapshot()
912
+ const it = snap && snap.byId ? snap.byId[sessionId] : undefined
913
+ setRunning(it ? it.running === true : false)
914
+ } catch (e) { setRunning(false) }
915
+ }
916
+ compute()
917
+ if (typeof sessions.list.subscribe === 'function') return sessions.list.subscribe(compute)
918
+ return undefined
919
+ }, [sessions, sessionId])
920
+ useEffect(() => {
921
+ if (!running || !sessionId || !canOfficialRename(connection)) { setHealth(null); return undefined }
922
+ let alive = true
923
+ const probe = async () => {
924
+ try {
925
+ const hr = await connection.api.sessions.history({ sessionId, maxMessages: 3 })
926
+ const val = hr && hr.result && hr.result.value
927
+ const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
928
+ if (events.length === 0) { if (alive) setHealth(null); return }
929
+ const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
930
+ if (alive && d && d.ok) setHealth(d.health || null)
931
+ } catch (e) {}
932
+ }
933
+ probe()
934
+ const iv = setInterval(probe, 10000)
935
+ return () => { alive = false; clearInterval(iv) }
936
+ }, [running, sessionId, connection])
937
+ if (!running || !health) return null
938
+ const kind = health.kind
939
+ const silent = fmtSilent(health.idleMs)
940
+ // 收拢态胶囊条颜色语义:绿=活跃 / 黄=工具执行中·静默中 / 红=疑似卡死(脉冲)。
941
+ let text = STR.healthActive, cls = 'hc-active'
942
+ if (kind === 'tool-wait') { text = STR.healthToolWait; cls = 'hc-wait' }
943
+ else if (kind === 'quiet') { text = STR.healthQuiet; cls = 'hc-wait' }
944
+ else if (kind === 'stalled') { text = STR.healthStalled; cls = 'hc-stall' }
945
+ else if (kind !== 'active') return null // ended/unknown 不显示
946
+ return h('button', {
947
+ className: 'sf-healthChip ' + cls,
948
+ title: STR.healthChipTitle,
949
+ onClick: (e) => { e.stopPropagation(); if (props.onOpen) props.onOpen() },
950
+ },
951
+ h('span', { className: 'sf-hcBar' }),
952
+ h('span', { className: 'sf-hcLabel' }, text + (silent && kind !== 'active' ? ' · ' + silent : '')),
953
+ )
954
+ }
955
+
956
+ // 工作台打开意图桥:头部芯片点击 → 唤起工作台并直达该会话详情。
957
+ // (工厂作用域:芯片渲染于官方槽位,与本插件视图分属不同 React 子树。)
958
+ const workbenchBridge = {
959
+ intent: null,
960
+ listeners: new Set(),
961
+ open(sid) { this.intent = sid; for (const fn of [...this.listeners]) fn(sid) },
962
+ subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn) },
963
+ }
964
+
565
965
  // ── 总览页 ──────────────────────────────────────────────────────────
566
966
  // props: { onOpen: (session) => void, onClose: () => void }
567
967
  function SessionFlowOverview(props) {
@@ -583,28 +983,98 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
583
983
  // M8a:当前编辑重命名的会话 id(null = 无编辑态)。
584
984
  const [renamingId, setRenamingId] = useState(null)
585
985
  // M8a:保存自定义标题 → 更新本地列表数据(userTitle 覆盖显示,不整页重刷)。
986
+ // 对齐官方(2026-08 起):非空标题且连接可用时优先走官方 session.rename RPC
987
+ // (log-backed,官方侧栏同步);空标题(清除)/官方拒绝(子代理 agent-busy 等)回退 legacy renames.json。
586
988
  const saveRename = (sid, title) => {
587
- return api('rename', { sessionId: sid, title }).then((json) => {
588
- if (json && json.ok) {
589
- setState((prev) => {
590
- if (!prev || !prev.data) return prev
591
- return {
592
- ...prev,
593
- data: {
594
- ...prev.data,
595
- workspaces: (prev.data.workspaces || []).map((g) => ({
596
- ...g,
597
- sessions: (g.sessions || []).map((s) => (s.id === sid ? { ...s, userTitle: json.userTitle } : s)),
598
- })),
599
- },
600
- }
601
- })
602
- setRenamingId(null)
603
- return true
604
- }
989
+ const conn = props.connection
990
+ const applyLocal = (userTitle) => {
991
+ setState((prev) => {
992
+ if (!prev || !prev.data) return prev
993
+ return {
994
+ ...prev,
995
+ data: {
996
+ ...prev.data,
997
+ workspaces: (prev.data.workspaces || []).map((g) => ({
998
+ ...g,
999
+ sessions: (g.sessions || []).map((s) => (s.id === sid ? { ...s, userTitle } : s)),
1000
+ })),
1001
+ },
1002
+ }
1003
+ })
1004
+ setRenamingId(null)
1005
+ return true
1006
+ }
1007
+ const legacy = () => api('rename', { sessionId: sid, title }).then((json) => {
1008
+ if (json && json.ok) return applyLocal(json.userTitle)
605
1009
  throw new Error((json && json.error) || STR.renameFail)
606
1010
  })
1011
+ const trimmed = String(title || '').trim()
1012
+ // 官方 user 标题不可经本插件清空(官方服务无删除语义):阻止并提示。
1013
+ if (trimmed === '') {
1014
+ const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
1015
+ const target = all.find((x) => x.id === sid)
1016
+ if (target && target.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1017
+ }
1018
+ if (trimmed !== '' && canOfficialRename(conn)) {
1019
+ return renameViaOfficial(conn, sid, trimmed)
1020
+ .then((accepted) => applyLocal(accepted))
1021
+ .catch(() => legacy())
1022
+ }
1023
+ return legacy()
607
1024
  }
1025
+ // ── 卡死监控:总览探测 ─────────────────────────────────────────────
1026
+ // 官方 sessions.list 的 running(attached agent 真实状态,比 15 分钟启发式准)+
1027
+ // tail history → host derive 健康分类。30s 轮询,上限 8 个、并发 3。
1028
+ const [healthMap, setHealthMap] = useState({})
1029
+ const [liveRunning, setLiveRunning] = useState(null) // null=未探测;Set<sessionId>
1030
+ useEffect(() => {
1031
+ const conn = props.connection
1032
+ if (!canOfficialRename(conn) || state.phase !== 'ready') return undefined
1033
+ let alive = true
1034
+ let probing = false
1035
+ const probe = async () => {
1036
+ if (probing) return
1037
+ probing = true
1038
+ try {
1039
+ const resp = await conn.api.sessions.list({})
1040
+ const items = (resp && resp.result && resp.result.value && resp.result.value.items) || []
1041
+ const runningIds = items.filter((i) => i.running === true).map((i) => i.sessionId)
1042
+ if (!alive) return
1043
+ setLiveRunning(new Set(runningIds))
1044
+ const next = {}
1045
+ for (let i = 0; i < Math.min(runningIds.length, 8); i += 3) {
1046
+ await Promise.all(runningIds.slice(i, i + 3).map(async (sid) => {
1047
+ try {
1048
+ const hr = await conn.api.sessions.history({ sessionId: sid, maxMessages: 3 })
1049
+ const val = hr && hr.result && hr.result.value
1050
+ const events = val && Array.isArray(val.events) ? val.events.map((e) => (e && e.event) || e) : []
1051
+ if (events.length === 0) return
1052
+ const d = await api('derive', { events, now: Date.now(), assumeRunning: true })
1053
+ if (d && d.ok && d.health) next[sid] = d.health
1054
+ } catch {}
1055
+ }))
1056
+ if (!alive) return
1057
+ }
1058
+ if (alive) setHealthMap(next)
1059
+ } catch {} finally { probing = false }
1060
+ }
1061
+ probe()
1062
+ const iv = setInterval(probe, 30000)
1063
+ return () => { alive = false; clearInterval(iv) }
1064
+ }, [props.connection, state.phase])
1065
+ // 头部芯片直达:工作台打开意图 → 列表就绪后自动进入该会话详情(每个意图只消费一次)。
1066
+ // T3 修复:无论命中与否都标记消费 + 通知父级清意图——此前 Overview 卸载(进详情)后
1067
+ // intentConsumed ref 随之丢失,「← 返回」重挂 Overview 时旧意图被再次消费,
1068
+ // 表现为返回后又被拉回详情(用户实测);未命中时也消费,避免每次重挂都重试查找。
1069
+ const intentConsumed = useRef(null)
1070
+ useEffect(() => {
1071
+ if (!props.intentId || intentConsumed.current === props.intentId || state.phase !== 'ready') return
1072
+ intentConsumed.current = props.intentId
1073
+ if (props.onIntentConsumed) props.onIntentConsumed()
1074
+ const all = ((state.data && state.data.workspaces) || []).flatMap((g) => g.sessions || [])
1075
+ const target = all.find((x) => x.id === props.intentId)
1076
+ if (target) props.onOpen(target)
1077
+ }, [props.intentId, state.phase])
608
1078
  useEffect(() => () => {
609
1079
  if (fullTextTimer.current) clearTimeout(fullTextTimer.current)
610
1080
  if (fullTextAbort.current) fullTextAbort.current.abort()
@@ -738,7 +1208,11 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
738
1208
  }
739
1209
  }
740
1210
  const now = Date.now()
741
- const running = s.lastEventTime !== null && s.lastEventTime !== undefined && (now - s.lastEventTime) < ACTIVE_WINDOW_MS
1211
+ // 卡死监控:官方探测过(liveRunning null)则以 attached agent 真实状态为准,
1212
+ // 否则回退 15 分钟活动窗口启发式。
1213
+ const running = liveRunning === null
1214
+ ? s.lastEventTime !== null && s.lastEventTime !== undefined && (now - s.lastEventTime) < ACTIVE_WINDOW_MS
1215
+ : liveRunning.has(s.id)
742
1216
  out.push({ ...s, workspace: group.name, workspaceLabel: wsLabelOf(group), workspaceCwd: wsCwdOf(group), running, isEmpty: isSessionEmpty(s) })
743
1217
  }
744
1218
  }
@@ -754,7 +1228,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
754
1228
  const ascending = sort === 'oldest'
755
1229
  out.sort((a, b) => (ascending ? key(a) - key(b) : key(b) - key(a)))
756
1230
  return out
757
- }, [state.data, query, wsFilter, sort, showEmpty, showIssues])
1231
+ }, [state.data, query, wsFilter, sort, showEmpty, showIssues, liveRunning])
758
1232
 
759
1233
  // M5b 档案统计:聚合工具出现热度与问题会话数,跟随选中的工作区(wsFilter)变化。
760
1234
  const stats = useMemo(() => {
@@ -904,13 +1378,14 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
904
1378
  h('div', { className: 'sf-cardTitle' },
905
1379
  renamingId === s.id
906
1380
  ? h(RenameInline, {
907
- initial: s.userTitle || s.title || '',
1381
+ initial: (props.liveTitles && props.liveTitles[s.id]) || s.userTitle || s.title || '',
908
1382
  onSave: (t) => saveRename(s.id, t),
909
1383
  onCancel: () => setRenamingId(null),
910
1384
  })
911
1385
  : h('span', { className: 'sf-cardText', style: { overflow: 'hidden', textOverflow: 'ellipsis' } },
912
- s.userTitle || s.title || STR.unknownTitle,
913
- s.userTitle && h('span', { className: 'sf-origTitle', title: s.title || STR.unknownTitle },
1386
+ // 显示优先级:mux 近实时官方标题 > userTitle(档案 user/遗留 overlay)> 自动标题。
1387
+ (props.liveTitles && props.liveTitles[s.id]) || s.userTitle || s.title || STR.unknownTitle,
1388
+ (s.userTitle || (props.liveTitles && props.liveTitles[s.id])) && h('span', { className: 'sf-origTitle', title: s.title || STR.unknownTitle },
914
1389
  STR.origTitle + ': ' + (s.title || STR.unknownTitle)),
915
1390
  h('button', {
916
1391
  className: 'sf-renameBtn', title: STR.rename,
@@ -919,6 +1394,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
919
1394
  ),
920
1395
  s.isEmpty && h('span', { className: 'sf-badge sf-badgeEnd' }, STR.emptySession),
921
1396
  s.running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1397
+ healthBadge(healthMap[s.id]),
922
1398
  s.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent + ' · d' + s.delegationDepth),
923
1399
  s.toolErrors > 0 && h('span', { className: 'sf-badge sf-badgeErr' }, STR.errors + ': ' + s.toolErrors),
924
1400
  ),
@@ -1357,8 +1833,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1357
1833
  // M8a:标题重命名(编辑态 + 保存后的显示覆盖,优先于 session.userTitle)。
1358
1834
  const [renaming, setRenaming] = useState(false)
1359
1835
  const [titleOverride, setTitleOverride] = useState(null)
1836
+ // mux 近实时官方标题(SessionFlowView 订阅 session/projection(title) 帧传入)。
1837
+ const liveTitleOf = (id) => (props.liveTitles && props.liveTitles[id]) || null
1838
+ // 对齐官方:优先官方 session.rename RPC(log-backed,双向一致);空标题/官方拒绝回退 legacy。
1360
1839
  const saveRename = (title) => {
1361
- return api('rename', { sessionId: session.id, title }).then((json) => {
1840
+ const conn = props.connection
1841
+ const legacy = () => api('rename', { sessionId: session.id, title }).then((json) => {
1362
1842
  if (json && json.ok) {
1363
1843
  setTitleOverride(json.userTitle) // null = 清除恢复原名
1364
1844
  setRenaming(false)
@@ -1366,6 +1846,14 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1366
1846
  }
1367
1847
  throw new Error((json && json.error) || STR.renameFail)
1368
1848
  })
1849
+ const trimmed = String(title || '').trim()
1850
+ if (trimmed === '' && session.titleSource === 'user') return Promise.reject(new Error(STR.renameClearBlocked))
1851
+ if (trimmed !== '' && canOfficialRename(conn)) {
1852
+ return renameViaOfficial(conn, session.id, trimmed)
1853
+ .then((accepted) => { setTitleOverride(accepted); setRenaming(false); return true })
1854
+ .catch(() => legacy())
1855
+ }
1856
+ return legacy()
1369
1857
  }
1370
1858
 
1371
1859
  useEffect(() => {
@@ -1514,15 +2002,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1514
2002
  return
1515
2003
  }
1516
2004
  const events = val.events.map((entry) => (entry && entry.event) || entry)
1517
- const derived = await api('derive', { events })
2005
+ const derived = await api('derive', { events, now: Date.now() })
1518
2006
  if (!derived || !derived.ok) {
1519
2007
  setLiveState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
1520
2008
  return
1521
2009
  }
1522
2010
  const tl = (derived.timeline && derived.timeline.turns) || []
1523
- // 实时视图只保留「最近 3 个历史回合 + 当前运行回合」(若有),数量恒定,
1524
- // 不随 history 窗口大小变化——新回合出现时最旧的被挤出。
1525
- const LIVE_MAX_HISTORY_TURNS = 3
2011
+ // 实时视图只保留「最近 N 个历史回合 + 当前运行回合」(若有),数量恒定,
2012
+ // 不随 history 窗口大小变化——新回合出现时最旧的被挤出。N 可配置(设置页)。
2013
+ const LIVE_MAX_HISTORY_TURNS = sfSettings.current.liveHistoryTurns
1526
2014
  const liveRunning = derived.running === true
1527
2015
  const keepCount = tl.length > LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
1528
2016
  ? LIVE_MAX_HISTORY_TURNS + (liveRunning ? 1 : 0)
@@ -1537,6 +2025,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1537
2025
  // 运行中 = 事件结构信号(未闭合回合/步骤/工具调用或流式中间态),
1538
2026
  // 不依赖时间戳:输出间隔长(模型思考/长工具执行)时不会误判停止。
1539
2027
  running: liveRunning,
2028
+ // 卡死监控:host 分类(active/tool-wait/quiet/stalled/ended)。
2029
+ health: derived.health || null,
1540
2030
  })
1541
2031
  // 只把「新出现的回合」并入折叠集合(默认折叠);已展开的回合不被收拢。
1542
2032
  const newTurns = displayTl.filter((t) => !liveSeenTurns.current.has(t.turn))
@@ -1555,15 +2045,30 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1555
2045
  const enterLive = () => {
1556
2046
  setLiveOn(true)
1557
2047
  liveSeenTurns.current.clear() // 重进实时:全新视图,首轮全部折叠
2048
+ tlFollowRef.current = true // T7:开播默认吸底
1558
2049
  fetchLive.current()
1559
2050
  if (liveTimer.current === null) {
1560
- liveTimer.current = setInterval(() => { fetchLive.current() }, 3000)
2051
+ liveTimer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
1561
2052
  }
1562
2053
  }
2054
+ // T7 实时吸底跟随:用户在时间线底部(距底 ≤liveFollowPx)时,每次轮询刷新后平滑滚到
2055
+ // 新底部;上滑读历史不跟随(onScroll 实时追踪)。阈值默认 40px(可在设置页调整)。
2056
+ const tlScrollRef = useRef(null)
2057
+ const tlFollowRef = useRef(true)
2058
+ const onTlScroll = () => {
2059
+ const el = tlScrollRef.current
2060
+ if (el === null) return
2061
+ tlFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
2062
+ }
2063
+ useEffect(() => {
2064
+ if (!liveOn || liveState.timeline === null) return
2065
+ const el = tlScrollRef.current
2066
+ if (el !== null && tlFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
2067
+ }, [liveState.timeline, liveOn])
1563
2068
  const exitLive = () => {
1564
2069
  setLiveOn(false)
1565
2070
  liveSeenTurns.current.clear()
1566
- setLiveState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null, running: false })
2071
+ setLiveState({ phase: 'idle', error: null, timeline: null, eventCount: 0, lastActive: null, running: false, health: null })
1567
2072
  if (liveTimer.current !== null) { clearInterval(liveTimer.current); liveTimer.current = null }
1568
2073
  }
1569
2074
  useEffect(() => () => { if (liveTimer.current !== null) clearInterval(liveTimer.current) }, [])
@@ -1779,13 +2284,13 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1779
2284
  h('h2', { className: 'sf-viewTitle', style: { flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis' } },
1780
2285
  renaming
1781
2286
  ? h(RenameInline, {
1782
- initial: titleOverride || session.userTitle || session.title || '',
2287
+ initial: titleOverride || liveTitleOf(session.id) || session.userTitle || session.title || '',
1783
2288
  onSave: saveRename,
1784
2289
  onCancel: () => setRenaming(false),
1785
2290
  })
1786
2291
  : h('span', { style: { display: 'inline-flex', alignItems: 'center', minWidth: 0 } },
1787
- h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: (titleOverride || session.userTitle) ? session.title || STR.unknownTitle : undefined },
1788
- titleOverride || session.userTitle || session.title || STR.unknownTitle),
2292
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, title: (titleOverride || liveTitleOf(session.id) || session.userTitle) ? session.title || STR.unknownTitle : undefined },
2293
+ titleOverride || liveTitleOf(session.id) || session.userTitle || session.title || STR.unknownTitle),
1789
2294
  h('button', {
1790
2295
  className: 'sf-renameBtn', title: STR.rename,
1791
2296
  onClick: () => setRenaming(true),
@@ -1795,10 +2300,15 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1795
2300
  running ? h('span', { className: 'sf-badge sf-badgeRun' }, STR.running) : h('span', { className: 'sf-badge sf-badgeEnd' }, STR.ended),
1796
2301
  session.delegationDepth > 0 && h('span', { className: 'sf-badge sf-badgeSub' }, STR.subagent),
1797
2302
  h('button', { className: 'sf-btn', onClick: () => { setPendingSelect(null); setView('lineage') } }, STR.lineage),
1798
- // M12:并入右栏(仅工作台详情页;嵌入标签页不显示——避免右栏与标签页重复)。
1799
- !props.embedded && h('button', {
2303
+ // T6:并入右栏(工作台详情页 + 嵌入标签页都显示)。嵌入模式点击后额外
2304
+ // 自动切回「对话」标签——右栏已接管会话流展示,中栏标签页继续停留会造成
2305
+ // 双视图冗余(用户指定交互:点击=打开 dock+跳回对话)。
2306
+ h('button', {
1800
2307
  className: 'sf-btn',
1801
- onClick: () => { if (dockBridge.controller) dockBridge.controller.open() },
2308
+ onClick: () => {
2309
+ if (dockBridge.controller) dockBridge.controller.open()
2310
+ if (props.embedded) activateChatTab()
2311
+ },
1802
2312
  title: STR.dockHint,
1803
2313
  }, STR.dock),
1804
2314
  // M6 实时通道:进行中会话可实时查看(sessions.history → derive → 折叠视图)。
@@ -1817,8 +2327,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1817
2327
  state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
1818
2328
  state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.emptyHint),
1819
2329
  state.phase === 'ready' && h('div', { className: 'sf-detailBody' },
1820
- // 左侧时间线
1821
- h('div', { className: 'sf-timeline' },
2330
+ // 左侧时间线(T7:ref+onScroll 供实时吸底跟随)
2331
+ h('div', { className: 'sf-timeline', ref: tlScrollRef, onScroll: onTlScroll },
1822
2332
  h('div', { className: 'sf-muted', style: { flex: 'none' }, title: data.workspaceCwd || data.workspace },
1823
2333
  (data.workspaceLabel || data.workspace || '') + ' · ' + fmtTime(sessionInfo.createdAt) +
1824
2334
  ' · ' + STR.duration + ' ' + fmtDuration((sessionInfo.lastEventTime || 0) - (sessionInfo.createdAt || 0)) +
@@ -1889,6 +2399,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1889
2399
  // M6 实时模式:sessions.history → derive 的完整折叠时间线(轮询刷新)。
1890
2400
  liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
1891
2401
  h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
2402
+ healthBadge(liveState.health),
1892
2403
  h('span', { className: 'sf-liveText' },
1893
2404
  h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
1894
2405
  liveState.lastActive && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveLastActive + ' ' + fmtTime(liveState.lastActive)),
@@ -1896,7 +2407,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
1896
2407
  ),
1897
2408
  h('button', { className: 'sf-btn sf-liveExitBtn', onClick: exitLive }, STR.liveExit),
1898
2409
  ),
1899
- liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
2410
+ liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
1900
2411
  liveOn && liveState.phase === 'loading' && liveState.timeline === null && h('div', { className: 'sf-hint' }, STR.liveLoading),
1901
2412
  liveOn && liveState.timeline !== null && h(TimelineTurns, {
1902
2413
  // 保留回合头(默认折叠可点击展开):hideHead=true 时折叠回合既无内容
@@ -2119,7 +2630,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2119
2630
  // 内实时通道同构(结构信号运行判定 / 只折叠新回合 / 不传 hideHead),自包含实现。
2120
2631
  function SessionFlowLiveFallback(props) {
2121
2632
  const { sessionId, connection } = props
2122
- const [state, setState] = useState({ phase: 'loading', error: null, timeline: null, eventCount: 0, running: false })
2633
+ const [state, setState] = useState({ phase: 'loading', error: null, timeline: null, eventCount: 0, running: false, health: null })
2123
2634
  const [collapsed, setCollapsed] = useState(() => new Set())
2124
2635
  const [activeTurn, setActiveTurn] = useState(null)
2125
2636
  const liveSeenTurns = useRef(new Set())
@@ -2140,7 +2651,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2140
2651
  return
2141
2652
  }
2142
2653
  const events = val.events.map((entry) => (entry && entry.event) || entry)
2143
- const derived = await api('derive', { events })
2654
+ const derived = await api('derive', { events, now: Date.now() })
2144
2655
  if (!derived || !derived.ok) {
2145
2656
  setState((s) => ({ ...s, phase: 'error', error: (derived && derived.error) || STR.liveFail }))
2146
2657
  return
@@ -2149,6 +2660,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2149
2660
  setState({
2150
2661
  phase: 'ready', error: null, timeline: tl, eventCount: events.length,
2151
2662
  running: derived.running === true,
2663
+ health: derived.health || null,
2152
2664
  })
2153
2665
  // 只折叠新出现的回合(默认折叠),已展开的保持用户状态(M6 经验)。
2154
2666
  const newTurns = tl.filter((t) => !liveSeenTurns.current.has(t.turn))
@@ -2168,7 +2680,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2168
2680
  useEffect(() => {
2169
2681
  liveSeenTurns.current.clear()
2170
2682
  fetchLive.current()
2171
- if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, 3000)
2683
+ if (timer.current === null) timer.current = setInterval(() => { fetchLive.current() }, sfSettings.current.livePollMs)
2172
2684
  return () => { if (timer.current !== null) { clearInterval(timer.current); timer.current = null } }
2173
2685
  }, [sessionId])
2174
2686
 
@@ -2184,6 +2696,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2184
2696
  return h('div', { className: 'sf-view' },
2185
2697
  h('div', { className: 'sf-viewHeader' },
2186
2698
  h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
2699
+ healthBadge(state.health),
2187
2700
  h('span', { className: 'sf-liveText' }, STR.liveFallbackHint),
2188
2701
  h('button', { className: 'sf-btn sf-liveExitBtn', onClick: props.onRetry }, STR.retryArchive),
2189
2702
  ),
@@ -2202,6 +2715,32 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2202
2715
  // ── 主视图(总览 ⇄ 详情)───────────────────────────────────────────
2203
2716
  function SessionFlowView(props) {
2204
2717
  const [detail, setDetail] = useState(null) // { session, searchQuery }
2718
+ // 头部芯片直达意图(workbenchBridge):收到会话 id → 总览数据就绪后自动进详情。
2719
+ const [intentId, setIntentId] = useState(workbenchBridge.intent)
2720
+ useEffect(() => workbenchBridge.subscribe((sid) => { setDetail(null); setIntentId(sid) }), [])
2721
+ // ── 官方标题近实时同步(rename 对齐增强)──────────────────────────
2722
+ // 自建 mux 下行流监听 session/projection(title) 帧:官方 rename / 自动标题生成后
2723
+ // host 向每个 mux 消费者即时广播(value 为标题字符串),秒级同步、免等重扫。
2724
+ const [liveTitles, setLiveTitles] = useState({}) // sessionId -> 最新官方标题
2725
+ useEffect(() => {
2726
+ const conn = props.connection
2727
+ if (conn === undefined || conn.api === undefined || conn.api.events === undefined || typeof conn.api.events.mux !== 'function') return undefined
2728
+ const ac = new AbortController()
2729
+ ;(async () => {
2730
+ try {
2731
+ for await (const envelope of conn.api.events.mux({}, ac.signal)) {
2732
+ const f = (envelope && envelope.payload) || envelope
2733
+ if (!f || f.type !== 'session/projection' || f.key !== 'title') continue
2734
+ const title = typeof f.value === 'string' ? f.value : (f.value && typeof f.value.title === 'string' ? f.value.title : '')
2735
+ if (f.sessionId && title !== '') {
2736
+ const sid = f.sessionId
2737
+ setLiveTitles((prev) => (prev[sid] === title ? prev : { ...prev, [sid]: title }))
2738
+ }
2739
+ }
2740
+ } catch (e) { /* 流断开/中止:静默(组件卸载或下次挂载时重建) */ }
2741
+ })()
2742
+ return () => ac.abort()
2743
+ }, [props.connection])
2205
2744
 
2206
2745
  return detail === null
2207
2746
  ? h(SessionFlowOverview, {
@@ -2209,12 +2748,19 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2209
2748
  onOpen: (session, searchQuery) => setDetail({ session, searchQuery }),
2210
2749
  onClose: props.onClose,
2211
2750
  sessions: props.sessions,
2751
+ connection: props.connection,
2752
+ liveTitles,
2753
+ intentId,
2754
+ // T3:意图消费后即清除(SessionFlowView 不随详情↔总览切换卸载,状态稳)——
2755
+ // 防止「← 返回」重挂 Overview 时旧意图再次消费把用户拉回详情。
2756
+ onIntentConsumed: () => setIntentId(null),
2212
2757
  })
2213
2758
  : h(SessionFlowDetail, {
2214
2759
  session: detail.session,
2215
2760
  initialSearchQuery: detail.searchQuery || '',
2216
2761
  sessions: props.sessions,
2217
2762
  connection: props.connection,
2763
+ liveTitles,
2218
2764
  onBack: () => setDetail(null),
2219
2765
  onClose: props.onClose,
2220
2766
  })
@@ -2238,6 +2784,34 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2238
2784
  : { open: false, listeners: new Set(), controller: null })
2239
2785
  const PIANO_WINDOW = 15
2240
2786
 
2787
+ // T6:从嵌入标签页切回「对话」标签。官方 conversation.view 槽位环的标签栏没有
2788
+ // 公开激活 API(标准 props 仅 sessionId/useSession)——DOM 兜底:先按文案定位我们
2789
+ // 自己的标签按钮(「会话流」),再取其父容器(标签栏)的【直接子元素】逐个按文本
2790
+ // 精确匹配「对话 / Chat」——不限定 button:实测部分内置标签(对话/轨迹)不是
2791
+ // <button> 元素,按 button 过滤会漏掉它们、只命中插件 tab(点了会跳 Plan 图,
2792
+ // 实测踩坑)。命中后点击该元素或其内部首个按钮;未命中则不动作(宁可不动也
2793
+ // 不跳错标签)。React 合成监听挂在本事件上,click() 有效。
2794
+ function activateChatTab() {
2795
+ try {
2796
+ const pane = document.querySelector(CONVERSATION_SELECTOR)
2797
+ if (pane === null) return
2798
+ const btns = Array.prototype.slice.call(pane.querySelectorAll('button'))
2799
+ const ours = btns.find((b) => (b.textContent || '').trim().indexOf(STR.entry) >= 0)
2800
+ if (ours === undefined) return
2801
+ const bar = ours.parentElement
2802
+ if (bar === null) return
2803
+ const kids = Array.prototype.slice.call(bar.children)
2804
+ let target = null
2805
+ for (const k of kids) {
2806
+ if (k === ours || (typeof k.contains === 'function' && k.contains(ours))) continue
2807
+ if (/^(对话|Chat)$/.test((k.textContent || '').trim())) { target = k; break }
2808
+ }
2809
+ if (target === null) return
2810
+ const clickable = target.tagName === 'BUTTON' ? target : (target.querySelector('button') || target)
2811
+ clickable.click()
2812
+ } catch (e) {}
2813
+ }
2814
+
2241
2815
  // 定位目标:会话内容区。注意必须选**滚动容器** scrollBody(data-conversation-scroll):
2242
2816
  // viewArea 在滚动场景下 rect 是「内容全尺寸」(如 y=-25687, h=26823),用它定位会把
2243
2817
  // 竖条甩到视口外(实测踩坑)。scrollBody 才有正确的视口内矩形。
@@ -2264,16 +2838,29 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2264
2838
  const { sessions, onOpenWorkbench } = props
2265
2839
  const [entries, setEntries] = useState([]) // [{turn, seq, preview}] host 全量用户轮次
2266
2840
  const [activeIdx, setActiveIdx] = useState(-1)
2267
- const [winOffset, setWinOffset] = useState(0)
2841
+ // T5 平滑滚动:连续滚动位置(浮点,单位=行)。替代原整数 winOffset 的「每格硬跳
2842
+ // 1 条」——滚轮增量累积(deltaMode 归一)+ 列表 transform 百分比位移(transition
2843
+ // 追逐),渲染窗口 = 可视 WINDOW 条 + 上下缓冲,位移按列表自身高度百分比换算,
2844
+ // 收拢(4px)/展开(30px)行高变化时比例自守恒,无需测量行距。
2845
+ const [scrollPos, setScrollPos] = useState(0)
2268
2846
  const [kbGlobal, setKbGlobal] = useState(null)
2269
2847
  const [detailIdx, setDetailIdx] = useState(null) // 右侧信息面板显示的条目索引
2270
2848
  const detailTimer = useRef(null)
2271
2849
  const rootRef = useRef(null) // 悬浮条根容器(面板定位换算基准)
2272
2850
  const [jumpFail, setJumpFail] = useState(false) // 未加载轮次提示
2273
2851
  const [, force] = useState(0)
2852
+ // detailIdx 镜像:过渡监听 effect(deps [])闭包里读取当前值,避免读到旧 state。
2853
+ const detailIdxRef = useRef(null)
2274
2854
 
2275
2855
  // 定位自管理:垂直居中悬浮于会话内容区(scrollBody 矩形中心 + translateY(-50%))。
2276
2856
  const [pos, setPos] = useState(null) // {left, top(区中心)}
2857
+ useEffect(() => {
2858
+ return () => {
2859
+ const lock = collapseLockRef.current
2860
+ if (lock.timer) clearTimeout(lock.timer)
2861
+ if (lock.retry) clearTimeout(lock.retry)
2862
+ }
2863
+ }, [])
2277
2864
  useEffect(() => {
2278
2865
  const compute = () => {
2279
2866
  const area = pianoViewArea()
@@ -2305,7 +2892,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2305
2892
  useEffect(() => {
2306
2893
  if (initTailDone.current || entries.length <= WINDOW) return
2307
2894
  initTailDone.current = true
2308
- setWinOffset(entries.length - WINDOW)
2895
+ setScrollPos(entries.length - WINDOW)
2309
2896
  }, [entries])
2310
2897
 
2311
2898
  // 当前会话 id(仅取 current,不展示跨会话列表)。
@@ -2392,14 +2979,34 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2392
2979
  .replace(/[*_#>|~-]{1,3}/g, ' ')
2393
2980
  .replace(/\s+/g, ' ')
2394
2981
  .trim()
2395
- // 条目 → DOM 行匹配:preview 归一化截断前缀与行文本归一化后 includes 比较。
2396
- const findRow = (entry) => {
2397
- const core = norm(String(entry.preview || '').replace(/…+$/u, '')).slice(0, 50)
2398
- if (!core) return null // 无文字内容(纯图片/文件消息)无法文本定位
2399
- for (const row of domRows()) {
2400
- const t = norm(textOf(row))
2401
- if (t && t.includes(core.slice(0, 30))) return row
2982
+ // 行→条目映射(单调整体映射,active 定位与点击跳转共用):
2983
+ // 按文档序游标推进匹配用户行(「继续」类重复文本不会误配到前面条目);
2984
+ // 助手行未匹配时继承前一条用户行的条目(轮次归属)——视口首行为助手
2985
+ // 回复时 active 仍指向其所属用户轮次,不再丢失高亮(T1 修复点一)。
2986
+ const mapRows = () => {
2987
+ const rows = domRows()
2988
+ const idxs = new Array(rows.length).fill(-1)
2989
+ let cursor = 0
2990
+ let last = -1
2991
+ for (let i = 0; i < rows.length; i++) {
2992
+ const t = norm(textOf(rows[i]))
2993
+ if (t) {
2994
+ for (let j = cursor; j < entries.length; j++) {
2995
+ const core = norm(String(entries[j].preview || '').replace(/…+$/u, '')).slice(0, 50)
2996
+ if (core && t.includes(core.slice(0, 30))) { cursor = j + 1; last = j; break }
2997
+ }
2998
+ }
2999
+ idxs[i] = last
2402
3000
  }
3001
+ return { rows, idxs }
3002
+ }
3003
+ // 条目 → DOM 行:从映射中取首个归属该条目的行(用户行本身;映射按文档序
3004
+ // 单调推进,重复文本不会命中到前面的同文条目)。
3005
+ const findRow = (entry) => {
3006
+ const target = entries.indexOf(entry)
3007
+ if (target === -1) return null
3008
+ const { rows, idxs } = mapRows()
3009
+ for (let i = 0; i < rows.length; i++) if (idxs[i] === target) return rows[i]
2403
3010
  return null
2404
3011
  }
2405
3012
 
@@ -2413,35 +3020,85 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2413
3020
  setTimeout(() => setJumpFail(false), 2600)
2414
3021
  }
2415
3022
 
2416
- // active:视口内第一条 DOM 用户行 文本匹配条目索引高亮(阅读位置跟随)。
3023
+ // active:视口内最靠上的可见行映射条目索引高亮(阅读位置跟随)。
3024
+ // 可见判定用 bottom 进入视口(含被顶部裁切一半的行)——此前 top>=0 会
3025
+ // 跳过被裁切行导致高亮偏晚一轮(T1 修复点二)。
2417
3026
  useEffect(() => {
2418
3027
  const compute = () => {
2419
3028
  if (entries.length === 0) { setActiveIdx(-1); return }
2420
- const rows = domRows()
2421
- let target = null
3029
+ const { rows, idxs } = mapRows()
3030
+ let target = -1
2422
3031
  let bestTop = Number.POSITIVE_INFINITY
2423
- for (const row of rows) {
2424
- const top = row.getBoundingClientRect().top
2425
- if (top >= 0 && top < bestTop) { bestTop = top; target = row }
3032
+ for (let i = 0; i < rows.length; i++) {
3033
+ const r = rows[i].getBoundingClientRect()
3034
+ if (r.bottom > 1 && r.top < bestTop) { bestTop = r.top; target = i }
2426
3035
  }
2427
- if (!target) { setActiveIdx(entries.length - 1); return }
2428
- const t = norm(textOf(target))
2429
- const idx = entries.findIndex((e) => {
2430
- const core = norm(String(e.preview || '').replace(/…+$/u, '')).slice(0, 50)
2431
- return core && t.includes(core.slice(0, 30))
2432
- })
2433
- setActiveIdx(idx)
3036
+ if (target === -1) { setActiveIdx(entries.length - 1); return }
3037
+ const idx = idxs[target]
3038
+ // idx<0(视口首行在第一条用户消息之前):保持现状,不清高亮不跳变。
3039
+ if (idx >= 0) setActiveIdx(idx)
2434
3040
  }
2435
3041
  compute()
2436
3042
  const iv = setInterval(compute, 800)
2437
3043
  return () => clearInterval(iv)
2438
3044
  }, [entries])
2439
3045
 
2440
- const WINDOW = 12
3046
+ const WINDOW = sfSettings.current.pianoWindow
2441
3047
  const hidden = entries.length < 2
2442
- const windowed = entries.slice(winOffset, winOffset + WINDOW)
2443
- const canUp = winOffset > 0
2444
- const canDown = winOffset + WINDOW < entries.length
3048
+ // 虚拟窗口:renderStart 起渲染 WINDOW+3 条(上方缓冲 1 + 下方缓冲 2)。
3049
+ const maxOff = Math.max(0, entries.length - WINDOW)
3050
+ const clampedPos = Math.max(0, Math.min(maxOff, scrollPos))
3051
+ const renderStart = Math.max(0, Math.floor(clampedPos) - 1)
3052
+ const renderCount = Math.min(WINDOW + 3, entries.length - renderStart)
3053
+ const windowed = entries.slice(renderStart, renderStart + renderCount)
3054
+ const winOffset = Math.round(clampedPos) // 逻辑口径(高亮/面板/▲▼基准)四舍五入
3055
+ const canUp = clampedPos > 0.01
3056
+ const canDown = clampedPos < maxOff - 0.01
3057
+ // T5 显示位(ref,非 state——逐帧 DOM 更新不过 React):renderStartRef 镜像供
3058
+ // 逐帧换算;displayPos 由 rAF 追逐 clampedPos(指数趋近 .22/帧 ≈ .16s 到位)。
3059
+ const displayPosRef = useRef(0)
3060
+ const renderStartRef = useRef(0)
3061
+ renderStartRef.current = renderStart
3062
+ const applyShift = () => {
3063
+ const list = listRef.current
3064
+ if (list === null || list.children.length < 2) return
3065
+ const pitch = list.children[1].offsetTop - list.children[0].offsetTop
3066
+ if (!(pitch > 0)) return
3067
+ list.style.transform = 'translate3d(0,' + (-(displayPosRef.current - renderStartRef.current) * pitch).toFixed(2) + 'px,0)'
3068
+ // strip 高度显式管理:clipper 脱流后 strip 不能靠内容撑高(塌缩=整条消失)。
3069
+ // 纯内容高 = 可见行数 × 实测行距 − 间隙(12×35−5=415 / 12×9−5=103);呼吸
3070
+ // 在 strip 外(head 下边距 8 + 根下内边距 8);键高动画期间 RO 逐帧跟随。
3071
+ const rows = Math.min(WINDOW, entries.length)
3072
+ const strip = stripRef.current
3073
+ if (strip !== null) strip.style.height = (rows * pitch - 5).toFixed(1) + 'px'
3074
+ }
3075
+ useEffect(() => {
3076
+ let raf = 0
3077
+ const step = () => {
3078
+ const target = Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), scrollPos))
3079
+ const cur = displayPosRef.current
3080
+ if (Math.abs(target - cur) < 0.002) {
3081
+ displayPosRef.current = target
3082
+ applyShift()
3083
+ return
3084
+ }
3085
+ displayPosRef.current = cur + (target - cur) * 0.22
3086
+ applyShift()
3087
+ raf = requestAnimationFrame(step)
3088
+ }
3089
+ raf = requestAnimationFrame(step)
3090
+ return () => cancelAnimationFrame(raf)
3091
+ }, [scrollPos, entries.length])
3092
+ // renderStart 跳变(内容重排)后同步重设 transform(渲染后、绘制前),显示位
3093
+ // 不变 → 无视觉跳变;列表高度随键高变化(收拢/展开)时重算行距保持对齐。
3094
+ useEffect(() => { applyShift() })
3095
+ useEffect(() => {
3096
+ const list = listRef.current
3097
+ if (list === null || typeof ResizeObserver !== 'function') return undefined
3098
+ const ro = new ResizeObserver(() => applyShift())
3099
+ ro.observe(list)
3100
+ return () => ro.disconnect()
3101
+ }, [renderStart, entries.length])
2445
3102
 
2446
3103
  // 悬浮条滚轮/▲▼:只翻悬浮条自身窗口,绝不影响会话区滚动(用户明确要求)。
2447
3104
  const onKeyDown = (e) => {
@@ -2460,35 +3117,118 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2460
3117
  }
2461
3118
  useEffect(() => {
2462
3119
  if (kbGlobal === null) return
2463
- if (kbGlobal < winOffset) setWinOffset(kbGlobal)
2464
- else if (kbGlobal >= winOffset + WINDOW) setWinOffset(kbGlobal - WINDOW + 1)
3120
+ setScrollPos((o) => {
3121
+ const p = Math.max(0, Math.min(maxOff, o))
3122
+ if (kbGlobal < p) return kbGlobal
3123
+ if (kbGlobal >= p + WINDOW) return Math.max(0, Math.min(maxOff, kbGlobal - WINDOW + 1))
3124
+ return o
3125
+ })
2465
3126
  }, [kbGlobal])
2466
3127
 
2467
- // 滚轮翻页:React onWheel 是被动监听器(passive: true),内部 preventDefault
3128
+ // 滚轮平滑滚动:React onWheel 是被动监听器(passive: true),内部 preventDefault
2468
3129
  // 无效并刷屏告警(实测踩坑)——改原生 addEventListener('wheel', {passive:false})。
3130
+ // 增量累积(deltaMode 归一为像素;~0.012 行/px:一整格滚轮 ≈1.2 行,触控板小
3131
+ // 增量连续累积),rAF 追逐成连续位移。**静止吸附**:滚轮停 ~170ms 后取整到最近
3132
+ // 整行(chase 平滑滑过去)——小数位置会让顶/底条露半截(「展示了 T23 的一部
3133
+ // 分」实测踩坑),用户要求静止时恰好完整 12 条。
2469
3134
  const stripRef = useRef(null)
3135
+ const listRef = useRef(null) // T5 虚拟滚动列表容器(键的父级,transform 平移)
3136
+ const snapTimerRef = useRef(0)
2470
3137
  useEffect(() => {
2471
3138
  const el = stripRef.current
2472
3139
  if (el === null) return undefined
2473
3140
  const onNativeWheel = (e) => {
2474
3141
  e.preventDefault()
2475
3142
  e.stopPropagation()
2476
- if (e.deltaY > 0) setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1))
2477
- else setWinOffset((o) => Math.max(0, o - 1))
3143
+ let dy = e.deltaY
3144
+ if (e.deltaMode === 1) dy *= 33
3145
+ else if (e.deltaMode === 2) dy *= 300
3146
+ setScrollPos((o) => Math.max(0, Math.min(Math.max(0, entries.length - WINDOW), o + dy * sfSettings.current.pianoWheelSpeed)))
3147
+ // 静止吸附:连续滚动不断顺延;停止后吸附到最近整行。
3148
+ if (snapTimerRef.current !== 0) clearTimeout(snapTimerRef.current)
3149
+ snapTimerRef.current = setTimeout(() => {
3150
+ snapTimerRef.current = 0
3151
+ setScrollPos((o) => Math.round(o))
3152
+ }, sfSettings.current.pianoSnapMs)
2478
3153
  }
2479
3154
  el.addEventListener('wheel', onNativeWheel, { passive: false })
2480
- return () => el.removeEventListener('wheel', onNativeWheel)
3155
+ return () => {
3156
+ el.removeEventListener('wheel', onNativeWheel)
3157
+ if (snapTimerRef.current !== 0) { clearTimeout(snapTimerRef.current); snapTimerRef.current = 0 }
3158
+ }
2481
3159
  }, [entries.length])
2482
3160
 
3161
+ // 面板锚定刷新:detailPos 在渲染时刻从键的实时矩形计算——mouseenter 常发生在
3162
+ // 收拢/半展开几何(root 宽 32),面板被锚在收拢条右缘=鼠标旁,且展开完成后无
3163
+ // 重渲染而停留在旧坐标。监听过渡结束(React 合成事件不含 transitionend 的完整
3164
+ // 语义),仅几何属性(width/height/max-height)且面板显示中时强制重渲染,用最终
3165
+ // 几何重算(面板可见延迟 .28s > 展开 .26s,用户看到的就是正确位置)。
3166
+ useEffect(() => {
3167
+ const el = rootRef.current
3168
+ if (el === null) return undefined
3169
+ const onEnd = (e) => {
3170
+ if (detailIdxRef.current !== null &&
3171
+ (e.propertyName === 'width' || e.propertyName === 'height' || e.propertyName === 'max-height')) {
3172
+ force((t) => t + 1)
3173
+ }
3174
+ }
3175
+ el.addEventListener('transitionend', onEnd)
3176
+ return () => el.removeEventListener('transitionend', onEnd)
3177
+ }, [])
3178
+
2483
3179
  // 右侧信息面板:hover 条目/键盘聚焦时弹出该轮信息(保留弹出面板、移除原生
2484
- // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目与面板间移动 150ms 宽限。
3180
+ // tooltip——tooltip 与面板重复且视觉凌乱);鼠标在条目间移动 250ms 宽限。
2485
3181
  const showDetail = (idx) => {
3182
+ const lock = collapseLockRef.current
3183
+ if (Date.now() < lock.until) {
3184
+ // 锁检查必须先于 clearTimeout:收拢条上的合成 mouseenter(skipped)不得
3185
+ // 清掉待执行的隐藏定时器(实测:连续 skipped 把面板拖了 ~1s 不走)。
3186
+ if (lock.kind === 'enter') {
3187
+ // 进入护栏期(hover-guard):推迟到锁过期后重试——驻留则面板照常出现,
3188
+ // 快速路过则随离场作废(重试前检查 root 仍 :hover)。
3189
+ if (lock.retry) clearTimeout(lock.retry)
3190
+ lock.retry = setTimeout(() => {
3191
+ lock.retry = null
3192
+ if (rootRef.current !== null && rootRef.current.matches(':hover')) showDetail(idx)
3193
+ }, Math.max(20, lock.until - Date.now() + 20))
3194
+ return
3195
+ }
3196
+ return
3197
+ }
3198
+ if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
2486
3199
  if (detailTimer.current) clearTimeout(detailTimer.current)
2487
3200
  setDetailIdx(idx)
2488
3201
  }
2489
3202
  const hideDetail = () => {
3203
+ const lock = collapseLockRef.current
3204
+ if (lock.retry) { clearTimeout(lock.retry); lock.retry = null }
2490
3205
  if (detailTimer.current) clearTimeout(detailTimer.current)
2491
- detailTimer.current = setTimeout(() => setDetailIdx(null), 150)
3206
+ // 宽限 250ms:标题行/首键边界窄带来回移动时,面板滑出动画(.24s)频繁被
3207
+ // 打断重入,加长宽限减少闪动。
3208
+ detailTimer.current = setTimeout(() => setDetailIdx(null), 250)
3209
+ }
3210
+ const collapseLockRef = useRef({ until: 0, timer: null, retry: null, kind: '' })
3211
+ // hover-guard / collapse-lock 二合一:进入锁 120ms(「快速路过闪动」——锁窗内
3212
+ // :hover 不展开,路过即零动画;驻留 120ms 后照常展开)+离开锁 300ms(防几何
3213
+ // 自反馈回路:展开态越界→收拢→胶囊条弹到静止鼠标下→合成 mouseenter→再展开)。
3214
+ // kind 区分 showDetail 策略:enter 锁→推迟重试(驻留后面板照常),leave 锁→跳过。
3215
+ const armCollapseLock = (ms, kind) => {
3216
+ const lock = collapseLockRef.current
3217
+ const el = rootRef.current
3218
+ const w = typeof ms === 'number' ? ms : 300
3219
+ if (el !== null && el.getAttribute('data-collapse-lock') === null) {
3220
+ el.setAttribute('data-collapse-lock', '')
3221
+ }
3222
+ lock.kind = kind === 'enter' ? 'enter' : 'leave'
3223
+ lock.until = Date.now() + w
3224
+ if (lock.timer) clearTimeout(lock.timer)
3225
+ lock.timer = setTimeout(() => {
3226
+ lock.until = 0
3227
+ lock.kind = ''
3228
+ if (lock.timer) { clearTimeout(lock.timer); lock.timer = null }
3229
+ const el2 = rootRef.current
3230
+ if (el2 !== null) el2.removeAttribute('data-collapse-lock')
3231
+ }, w)
2492
3232
  }
2493
3233
  useEffect(() => () => { if (detailTimer.current) clearTimeout(detailTimer.current) }, [])
2494
3234
  // 键盘聚焦跟随显示面板。
@@ -2496,6 +3236,9 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2496
3236
  if (kbGlobal !== null && entries[kbGlobal]) showDetail(kbGlobal)
2497
3237
  }, [kbGlobal])
2498
3238
 
3239
+ // detailIdx 镜像同步(供 deps [] 的过渡监听闭包读取当前值)。
3240
+ useEffect(() => { detailIdxRef.current = detailIdx }, [detailIdx])
3241
+
2499
3242
  const rootStyle = pos === null ? { display: 'none' } : {
2500
3243
  left: pos.left + 'px',
2501
3244
  top: pos.top + 'px',
@@ -2505,12 +3248,12 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2505
3248
 
2506
3249
  const detailEntry = detailIdx !== null ? entries[detailIdx] : null
2507
3250
  // 面板位置:absolute 相对悬浮条容器(容器有 translateY(-50%) transform,fixed
2508
- // 会退化为相对容器——必须按容器内偏移计算)。条目 = strip 直接子元素,
2509
- // 索引 = detailIdx - winOffset;越界修正按视口坐标换算回容器内。
3251
+ // 会退化为相对容器——必须按容器内偏移计算)。条目 = 列表(pk-list)直接子元素,
3252
+ // 索引 = detailIdx - renderStart(虚拟窗口含缓冲);越界修正按视口坐标换算回容器内。
2510
3253
  let detailPos = null
2511
3254
  const rootRect = rootRef.current ? rootRef.current.getBoundingClientRect() : null
2512
- if (detailEntry && rootRect) {
2513
- const el = stripRef.current ? stripRef.current.children[detailIdx - winOffset] : null
3255
+ if (detailEntry && rootRect && listRef.current) {
3256
+ const el = listRef.current.children[detailIdx - renderStart]
2514
3257
  if (el instanceof Element) {
2515
3258
  const r = el.getBoundingClientRect()
2516
3259
  const topV = Math.min(r.top, Math.max(4, window.innerHeight - 320))
@@ -2518,37 +3261,68 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2518
3261
  }
2519
3262
  }
2520
3263
 
2521
- return h('div', { 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle },
2522
- h('div', { className: 'pk-head' },
3264
+ return h('div', {
3265
+ 'data-dsh-piano-keys': '', ref: rootRef, style: rootStyle,
3266
+ onPointerEnter: () => {
3267
+ // 进入锁(hover-guard 120ms):快速路过收拢条零动画零闪动;驻留后照常展开。
3268
+ armCollapseLock(120, 'enter')
3269
+ },
3270
+ onPointerLeave: () => {
3271
+ // 面板随收拢同步消失:root 级离开=真实收拢开始,立即隐藏(淡出 .18s 与收拢
3272
+ // .22-.26s 同步收完)——不走 250ms 宽限+滑出拖尾 ~0.5s。
3273
+ if (detailIdx !== null) {
3274
+ if (detailTimer.current) clearTimeout(detailTimer.current)
3275
+ setDetailIdx(null)
3276
+ }
3277
+ // 锁只在「当前不在锁窗内」时武装:锁窗内的离开(条本就被抑制未展开)不续期,
3278
+ // 否则收拢条边缘快速进出让锁自续(实测 1.4s 不展开),300ms 后照常展开。
3279
+ if (Date.now() >= collapseLockRef.current.until) armCollapseLock()
3280
+ },
3281
+ },
3282
+ h('div', {
3283
+ className: 'pk-head',
3284
+ // 热区链:hover 标题行 ≡ hover 第一个键(CSS 侧高亮已链;JS 侧面板跟随)。
3285
+ // head↔gap↔首键连续同态,消灭「横条出现消失」的状态边界。
3286
+ onMouseEnter: () => showDetail(winOffset),
3287
+ onMouseLeave: hideDetail,
3288
+ },
2523
3289
  h('span', { className: 'pk-headLabel' }, STR.pianoTitle),
2524
3290
  h('div', { className: 'pk-navGroup' },
2525
- h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => setWinOffset((o) => Math.max(0, o - 1)), title: STR.expanded }, '▲'),
2526
- h('button', { className: 'pk-navBtn', disabled: !canDown, onClick: () => setWinOffset((o) => Math.min(Math.max(0, entries.length - WINDOW), o + 1)), title: STR.collapsed }, '▼'),
3291
+ h('button', { className: 'pk-navBtn', disabled: !canUp, onClick: () => { const n = Math.max(0, winOffset - 1); setScrollPos(n); showDetail(n) }, title: STR.expanded }, '▲'),
3292
+ 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 }, '▼'),
2527
3293
  ),
2528
3294
  ),
2529
3295
  h('div', { className: 'pk-strip', ref: stripRef, tabIndex: 0, onKeyDown, onMouseDown: (e) => { if (e.target === e.currentTarget) e.preventDefault() }, 'aria-label': STR.pianoTitle },
2530
- windowed.map((entry, idx) => {
2531
- const gi = winOffset + idx
2532
- const act = activeIdx === gi
2533
- return h('button', {
2534
- key: 'turn-' + gi + '-' + entry.seq,
2535
- type: 'button',
2536
- className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
2537
- // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
2538
- // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
2539
- onClick: (e) => { open(entry); e.currentTarget.blur() },
2540
- onMouseEnter: () => showDetail(gi),
2541
- onMouseLeave: hideDetail,
2542
- onContextMenu: (e) => {
2543
- e.preventDefault()
2544
- if (onOpenWorkbench) onOpenWorkbench()
3296
+ // T5 裁剪框(strip 内容盒):缓冲键全裁,可见区恰好 12 行;strip 本体保留
3297
+ // 内边距(视觉呼吸)+ 定位(relative)。
3298
+ h('div', { className: 'pk-clip' },
3299
+ // T5 虚拟滚动列表:transform 由 JS 逐帧驱动(applyShift),不设 style
3300
+ // 以免 React 重渲染覆盖 JS 值。
3301
+ h('div', { className: 'pk-list', ref: listRef },
3302
+ windowed.map((entry, idx) => {
3303
+ const gi = renderStart + idx
3304
+ const act = activeIdx === gi
3305
+ return h('button', {
3306
+ key: 'turn-' + gi + '-' + entry.seq,
3307
+ type: 'button',
3308
+ className: 'pk-key' + (act ? ' pk-keyActive' : '') + (kbGlobal === gi ? ' pk-keyFocus' : ''),
3309
+ // 点击后主动失焦:避免 button 焦点让 :focus-within 常驻展开
3310
+ // (鼠标移开后悬浮条收不拢,箭头「残留」——实测踩坑)。
3311
+ onClick: (e) => { open(entry); e.currentTarget.blur() },
3312
+ onMouseEnter: () => showDetail(gi),
3313
+ onMouseLeave: hideDetail,
3314
+ onContextMenu: (e) => {
3315
+ e.preventDefault()
3316
+ if (onOpenWorkbench) onOpenWorkbench()
3317
+ },
2545
3318
  },
2546
- },
2547
- // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
2548
- h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
2549
- h('span', { className: 'pk-label' }, entry.preview || '(空)'),
2550
- )
2551
- }),
3319
+ // 轮次序号:展开态常驻【最左侧】独立模块(与文本模块分离,互不侵占)。
3320
+ h('span', { className: 'pk-turnNo' }, 'T' + entry.turn),
3321
+ h('span', { className: 'pk-label' }, entry.preview || '(空)'),
3322
+ )
3323
+ }),
3324
+ ),
3325
+ ),
2552
3326
  ),
2553
3327
  // 信息面板:跟随 hover 条目右侧展开(fixed,位置由 detailPos 控制);
2554
3328
  // 常驻渲染 + hidden 类做滑入/滑出;内容 key 变化触发 pkSlideIn。
@@ -2569,27 +3343,171 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2569
3343
  function DetailsDockView(props) {
2570
3344
  const { sessionId, connection, onExit } = props
2571
3345
  const [state, setState] = useState({ phase: 'loading', error: null, data: null })
2572
- const [collapsed, setCollapsed] = useState(() => new Set())
3346
+ // T8-B:重挂载续播时折叠集合按钉住时间线播种(新回合照常并入折叠)。
3347
+ const [collapsed, setCollapsed] = useState(() => {
3348
+ const s = new Set()
3349
+ if (dockBridge.live && dockBridge.live.on && dockBridge.live.lastState) {
3350
+ for (const t of dockBridge.live.lastState.timeline || []) s.add('turn-' + t.turn)
3351
+ }
3352
+ return s
3353
+ })
2573
3354
  const collapsedInit = useRef(false)
2574
3355
  const [turnItems, setTurnItems] = useState(() => new Map())
2575
3356
  const loadingTurns = useRef(new Set())
2576
3357
 
2577
- useEffect(() => {
2578
- let alive = true
3358
+ // ── T6 dock 实时模式:复用实时通道(sessions.history → derive,与详情页 M6
3359
+ // 同构),但只显示「开播后」的对话——首轮记录基准回合集合,此后仅显示新出现
3360
+ // 的回合(含开播时正在运行的回合);不加载历史回合(档案 get 在实时期间跳过)。
3361
+ // ── T8-B 钉住原会话:dock 视图随会话切换整体重挂载(槽位 session 作用域
3362
+ // key),组件级实时状态必丢——实时状态提升到 dockBridge.live 模块级存储
3363
+ // (钉住 sid/基准/已见/定时器/最近状态跨挂载存活):开播即钉住当时的会话,
3364
+ // GUI 切走后右栏继续直播原会话并显示提示条(告知用户),可一键转播当前会话。
3365
+ const liveStore = dockBridge.live || (dockBridge.live = {
3366
+ on: false, sid: undefined, timer: null, base: null, seen: new Set(),
3367
+ lastState: null, conn: undefined, emit: null, tick: null,
3368
+ })
3369
+ const [liveOn, setLiveOn] = useState(() => liveStore.on)
3370
+ const [liveState, setLiveState] = useState(() => liveStore.lastState
3371
+ || { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
3372
+ const fetchLive = useRef(async () => {})
3373
+ fetchLive.current = async () => {
3374
+ const sid = liveStore.sid
3375
+ const conn = liveStore.conn
3376
+ if (sid === undefined || conn === undefined || conn.api === undefined || conn.api.sessions === undefined) {
3377
+ liveStore.lastState = { phase: 'error', error: STR.liveUnavailable, timeline: [], running: false, eventCount: 0, health: null }
3378
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3379
+ return
3380
+ }
3381
+ if (liveStore.emit !== null) liveStore.emit({ ...liveStateKeep(), phase: 'loading' })
3382
+ try {
3383
+ const resp = await conn.api.sessions.history({ sessionId: sid, mode: 'full', maxMessages: 400 })
3384
+ const val = resp && resp.result && resp.result.value
3385
+ if (val === undefined || !Array.isArray(val.events)) {
3386
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail }
3387
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3388
+ return
3389
+ }
3390
+ const events = val.events.map((entry) => (entry && entry.event) || entry)
3391
+ const derived = await api('derive', { events, now: Date.now() })
3392
+ if (!derived || !derived.ok) {
3393
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: (derived && derived.error) || STR.liveFail }
3394
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3395
+ return
3396
+ }
3397
+ const tl = (derived.timeline && derived.timeline.turns) || []
3398
+ const running = derived.running === true
3399
+ if (liveStore.base === null) {
3400
+ // 开播基准:当时已存在的全部回合;运行中的那个除外(它是「正在发生的
3401
+ // 实时对话」,从开播起就要展示其后续流式变化)。
3402
+ liveStore.base = new Set(tl.map((t) => t.turn))
3403
+ if (running && tl.length > 0) liveStore.base.delete(tl[tl.length - 1].turn)
3404
+ }
3405
+ // 只保留:开播基准之后新出现的回合 + 开播时正在运行的那个回合。
3406
+ const display = tl.filter((t) => !liveStore.base.has(t.turn))
3407
+ const newTurns = display.filter((t) => !liveStore.seen.has(t.turn))
3408
+ for (const t of display) liveStore.seen.add(t.turn)
3409
+ if (newTurns.length > 0 && liveStore.emit !== null) {
3410
+ setCollapsed((prev) => {
3411
+ const next = new Set(prev)
3412
+ for (const t of newTurns) next.add('turn-' + t.turn)
3413
+ return next
3414
+ })
3415
+ }
3416
+ liveStore.lastState = { phase: 'ready', error: null, timeline: display, running, eventCount: events.length, health: derived.health || null }
3417
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3418
+ } catch (e) {
3419
+ liveStore.lastState = { ...liveStateKeep(), phase: 'error', error: STR.liveFail + ': ' + String(e && e.message || e) }
3420
+ if (liveStore.emit !== null) liveStore.emit(liveStore.lastState)
3421
+ }
3422
+ }
3423
+ // 保留当前显示数据、只换 phase 的辅助(emit 前构造)。
3424
+ function liveStateKeep() {
3425
+ return liveStore.lastState || { phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null }
3426
+ }
3427
+ const enterLive = () => {
3428
+ liveStore.on = true
3429
+ liveStore.sid = sessionId // T8-B:开播即钉住当前会话
3430
+ liveStore.seen.clear()
3431
+ liveStore.base = null
3432
+ liveStore.lastState = null
3433
+ setLiveOn(true)
3434
+ liveFollowRef.current = true // T7:开播默认吸底
3435
+ fetchLive.current()
3436
+ if (liveStore.timer === null) {
3437
+ liveStore.timer = setInterval(() => { if (liveStore.tick !== null) liveStore.tick() }, sfSettings.current.livePollMs)
3438
+ }
3439
+ }
3440
+ const exitLive = () => {
3441
+ liveStore.on = false
3442
+ liveStore.sid = undefined
3443
+ liveStore.seen.clear()
3444
+ liveStore.base = null
3445
+ liveStore.lastState = null
3446
+ if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
3447
+ setLiveOn(false)
3448
+ setLiveState({ phase: 'idle', error: null, timeline: [], running: false, eventCount: 0, health: null })
3449
+ // 回到档案视图:重新加载(实时期间可能已产生新回合)。
2579
3450
  setState({ phase: 'loading', error: null, data: null })
2580
3451
  setTurnItems(new Map())
2581
3452
  collapsedInit.current = false
2582
- if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
2583
- api('get', { sessionId }).then((json) => {
2584
- if (!alive) return
2585
- if (json && json.ok) {
2586
- setState({ phase: 'ready', error: null, data: json })
2587
- } else {
2588
- setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
3453
+ }
3454
+ // T8-B:转播当前会话(提示条按钮)——重钉到当前 sessionId、基准重算。
3455
+ const rebindLive = () => {
3456
+ liveStore.sid = sessionId
3457
+ liveStore.seen.clear()
3458
+ liveStore.base = null
3459
+ fetchLive.current()
3460
+ }
3461
+ // 挂载注册:连接对象/状态发射器/定时器分发指向当前实例(跨重挂载续播);
3462
+ // 卸载时若 3s 内无新实例接管(dock 真正关闭走 onExit→exitLive),停表清理。
3463
+ useEffect(() => {
3464
+ liveStore.conn = props.connection
3465
+ liveStore.emit = setLiveState
3466
+ liveStore.tick = () => { fetchLive.current() }
3467
+ return () => {
3468
+ if (liveStore.emit === setLiveState) liveStore.emit = null
3469
+ if (liveStore.tick !== null) {
3470
+ const t = liveStore.tick
3471
+ setTimeout(() => { if (liveStore.tick === t && liveStore.emit === null) {
3472
+ if (liveStore.timer !== null) { clearInterval(liveStore.timer); liveStore.timer = null }
3473
+ liveStore.on = false; liveStore.tick = null
3474
+ } }, 500)
2589
3475
  }
2590
- }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
3476
+ }
3477
+ }, [])
3478
+ // T7 实时吸底跟随(dock):dk-body 距底 ≤40px 时每次刷新平滑滚到新底部。
3479
+ const dkBodyRef = useRef(null)
3480
+ const liveFollowRef = useRef(true)
3481
+ const onDkBodyScroll = () => {
3482
+ const el = dkBodyRef.current
3483
+ if (el === null) return
3484
+ liveFollowRef.current = el.scrollHeight - el.scrollTop - el.clientHeight <= sfSettings.current.liveFollowPx
3485
+ }
3486
+ useEffect(() => {
3487
+ if (!liveOn || liveState.timeline === null || liveState.timeline.length === 0) return
3488
+ const el = dkBodyRef.current
3489
+ if (el !== null && liveFollowRef.current) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
3490
+ }, [liveState.timeline, liveOn])
3491
+
3492
+ useEffect(() => {
3493
+ let alive = true
3494
+ // T8-B:实时钉住在 liveStore.sid(会话切换不重绑);档案加载仅非实时时进行。
3495
+ if (!liveOn) {
3496
+ setState({ phase: 'loading', error: null, data: null })
3497
+ setTurnItems(new Map())
3498
+ collapsedInit.current = false
3499
+ if (!sessionId) { setState({ phase: 'empty', error: null, data: null }); return }
3500
+ api('get', { sessionId }).then((json) => {
3501
+ if (!alive) return
3502
+ if (json && json.ok) {
3503
+ setState({ phase: 'ready', error: null, data: json })
3504
+ } else {
3505
+ setState({ phase: 'error', error: (json && json.error) || 'get failed', data: null })
3506
+ }
3507
+ }).catch((e) => { if (alive) setState({ phase: 'error', error: String(e), data: null }) })
3508
+ }
2591
3509
  return () => { alive = false }
2592
- }, [sessionId])
3510
+ }, [sessionId, liveOn])
2593
3511
 
2594
3512
  useEffect(() => {
2595
3513
  if (state.phase !== 'ready' || collapsedInit.current) return
@@ -2643,14 +3561,40 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2643
3561
 
2644
3562
  return h('div', { className: 'dk-root' },
2645
3563
  h('div', { className: 'dk-head' },
2646
- h('span', { className: 'dk-title' }, STR.dockHint),
3564
+ // T6:标题「会话流」+ 按钮**紧跟标题**(左侧区);spacer 吃掉右侧余量——
3565
+ // 彻底避开右上角 dsh-better-sidebar toggleCluster(视口级常驻浮钮区)。
3566
+ h('span', { className: 'dk-title', title: STR.dockHint }, STR.entry),
3567
+ props.connection !== undefined && h('button', {
3568
+ className: 'dk-btn' + (liveOn ? ' dk-btnLive' : ''),
3569
+ onClick: liveOn ? exitLive : enterLive,
3570
+ title: STR.dockLiveHint,
3571
+ }, liveOn ? STR.liveActive + ' ✕' : STR.live),
2647
3572
  h('button', { className: 'dk-btn', onClick: onExit }, STR.dockExit),
3573
+ h('span', { className: 'dk-headSpacer' }),
2648
3574
  ),
2649
- h('div', { className: 'dk-body' },
2650
- state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
2651
- state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
2652
- state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
2653
- state.phase === 'ready' && h('div', { className: 'dk-summary' },
3575
+ h('div', { className: 'dk-body', ref: dkBodyRef, onScroll: onDkBodyScroll },
3576
+ liveOn && h('div', { className: 'sf-liveBar', style: { flex: 'none' } },
3577
+ h('span', { className: 'sf-badge sf-badgeRun', style: { flex: 'none' } }, STR.liveActive),
3578
+ healthBadge(liveState.health),
3579
+ h('span', { className: 'sf-liveText' },
3580
+ h('span', { className: 'sf-muted', style: { flex: 'none' } }, (liveState.eventCount || 0) + ' ' + STR.liveEvents),
3581
+ liveState.phase === 'loading' && h('span', { className: 'sf-muted', style: { flex: 'none' } }, STR.liveRefreshing),
3582
+ ),
3583
+ ),
3584
+ liveOn && liveState.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, liveState.error),
3585
+ liveOn && liveStore.sid !== undefined && liveStore.sid !== sessionId && h('div', { className: 'dk-pinBar', title: liveStore.sid },
3586
+ h('span', { className: 'dk-pinText' }, STR.dockPinNotice),
3587
+ h('button', { className: 'dk-btn', onClick: rebindLive }, STR.dockPinRebind),
3588
+ ),
3589
+ liveOn && liveState.phase !== 'error' && liveState.timeline.length === 0 && h('div', { className: 'sf-hint' }, STR.dockLiveEmpty),
3590
+ liveOn && liveState.timeline.length > 0 && h(TimelineTurns, {
3591
+ turns: liveState.timeline, collapsed, toggle, liveActive: liveState.running === true,
3592
+ activeTurn: null, setActiveTurn: () => {},
3593
+ }),
3594
+ !liveOn && state.phase === 'loading' && h('div', { className: 'sf-hint' }, STR.detailLoading),
3595
+ !liveOn && state.phase === 'empty' && h('div', { className: 'sf-hint' }, STR.dockEmpty),
3596
+ !liveOn && state.phase === 'error' && h('div', { className: 'sf-hint', style: { color: '#d43b3b' } }, STR.detailFailed + ': ' + String(state.error)),
3597
+ !liveOn && state.phase === 'ready' && h('div', { className: 'dk-summary' },
2654
3598
  ruleTaskCount > 0 && h('div', { className: 'dk-summaryRow' },
2655
3599
  h('span', { className: 'dk-summaryTag' }, STR.summaryTaskCount),
2656
3600
  h('span', { className: 'dk-summaryText' }, ruleTaskCount),
@@ -2669,8 +3613,8 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2669
3613
  ),
2670
3614
  data.summary && h('div', { className: 'dk-summaryFull' }, data.summary),
2671
3615
  ),
2672
- state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
2673
- state.phase === 'ready' && h(TurnList, {
3616
+ !liveOn && state.phase === 'ready' && lightTurns.length === 0 && h('div', { className: 'sf-hint' }, STR.noTimeline),
3617
+ !liveOn && state.phase === 'ready' && h(TurnList, {
2674
3618
  sessionId,
2675
3619
  lightTurns,
2676
3620
  collapsed,
@@ -2888,7 +3832,7 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2888
3832
  if (!document.getElementById('dsh-session-flow-style')) {
2889
3833
  const style = document.createElement('style')
2890
3834
  style.id = 'dsh-session-flow-style'
2891
- style.textContent = STYLE
3835
+ style.textContent = STYLE + '\n' + SETTINGS_STYLE
2892
3836
  document.head.appendChild(style)
2893
3837
  }
2894
3838
 
@@ -2947,6 +3891,48 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2947
3891
  } catch (error) {
2948
3892
  console.error('[dsh-session-flow] conversation.view registration failed:', error)
2949
3893
  }
3894
+ // 会话健康芯片:官方 conversation.session.header.actions 槽位(ui-jobs/ui-subagent 同款),
3895
+ // order -9 紧随官方模式标识(agent-preset label, order -10)右侧。
3896
+ // 点击芯片 → workbenchBridge 意图桥直达该会话详情。
3897
+ try {
3898
+ slots.inject('conversation.session.header.actions', () => slots.register({
3899
+ name: 'conversation.session.header.actions',
3900
+ id: 'session-flow-health',
3901
+ order: -9,
3902
+ }, (slotProps) => h(SessionHealthChip, {
3903
+ sessionId: slotProps ? slotProps.sessionId : undefined,
3904
+ sessions,
3905
+ connection,
3906
+ onOpen: () => {
3907
+ const sid = slotProps && slotProps.sessionId
3908
+ if (sid) workbenchBridge.open(sid)
3909
+ controller.open()
3910
+ },
3911
+ })))
3912
+ } catch (error) {
3913
+ console.error('[dsh-session-flow] header health chip registration failed:', error)
3914
+ }
3915
+ // 插件设置页:settings.section 顶级 tab(order 140,排在皮肤中心 120/宠物 130 之后)。
3916
+ // 读写经 settingsScope(pet 同款:优先 webUiSettings 桥,回退 settingsScope);
3917
+ // 两者都不可用(旧宿主/未暴露)时不注册 tab。controller 闭包持有,
3918
+ // 卸载经 slots.inject 返回的 disposer 释放 scope 订阅。
3919
+ try {
3920
+ const settingsBinder = ctx.get('webUiSettings') ?? ctx.get('settingsScope')
3921
+ if (settingsBinder !== undefined && settingsBinder !== null && typeof settingsBinder.bind === 'function') {
3922
+ const sfSettingsCtrl = new SfSettingsController(settingsBinder.bind({ namespace: 'session-flow' }))
3923
+ slots.inject('settings.section', () => {
3924
+ const unregister = slots.register({
3925
+ name: 'settings.section',
3926
+ id: 'session-flow',
3927
+ order: 140,
3928
+ label: () => STR.settingsTitle,
3929
+ }, () => h(SessionFlowSettingsSection, { controller: sfSettingsCtrl }))
3930
+ return () => { sfSettingsCtrl.dispose(); unregister() }
3931
+ })
3932
+ }
3933
+ } catch (error) {
3934
+ console.error('[dsh-session-flow] settings.section registration failed:', error)
3935
+ }
2950
3936
  }
2951
3937
 
2952
3938
  // M12:详情并入右侧栏 —— details 槽位动态占用(priority -1 低于官方 tool-details)。
@@ -2965,66 +3951,10 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
2965
3951
  } catch (e) {}
2966
3952
  }
2967
3953
 
2968
- // M12×aionui 兼容层:官方 details 把手按 3 轨模型定位(left = frameWidth - details,
2969
- // 假设 details 是最后一轨);aionui-panel frame grid 扩为 5 轨
2970
- // (sidebar|center|details|preview|explorer)后,真实边界左移 preview+explorer px,
2971
- // 把手却停在旧位置(悬浮于预览列上方,见 docs/02-design/LAYOUT-COEXIST.md)。
2972
- // dock 激活期间按实际轨宽重算把手位置:三方写 frame style → MutationObserver;
2973
- // 纯缩放(grid 字符串不变)→ ResizeObserver。3 轨时公式退化为官方原值,无副作用。
2974
- const parseGridTracksCompat = (input) => {
2975
- const tracks = []
2976
- let depth = 0
2977
- let current = ''
2978
- for (const ch of String(input)) {
2979
- if (ch === '(') depth += 1
2980
- if (ch === ')') depth = Math.max(0, depth - 1)
2981
- if (ch === ' ' && depth === 0) {
2982
- if (current !== '') { tracks.push(current); current = '' }
2983
- continue
2984
- }
2985
- current += ch
2986
- }
2987
- if (current !== '') tracks.push(current)
2988
- return tracks
2989
- }
2990
- const detailsHandleCompat = {
2991
- frame: null,
2992
- observer: null,
2993
- sizeObserver: null,
2994
- start() {
2995
- if (this.observer !== null) return
2996
- const frame = document.querySelector('[data-dsh-frame]') ||
2997
- (document.querySelector('[class*="sidebarCol"]') || {}).parentElement || null
2998
- if (frame === null) return
2999
- this.frame = frame
3000
- this.observer = new MutationObserver(() => this.apply())
3001
- this.observer.observe(frame, { attributes: true, attributeFilter: ['style'] })
3002
- this.sizeObserver = new ResizeObserver(() => this.apply())
3003
- this.sizeObserver.observe(frame)
3004
- this.apply()
3005
- },
3006
- stop() {
3007
- if (this.observer !== null) { this.observer.disconnect(); this.observer = null }
3008
- if (this.sizeObserver !== null) { this.sizeObserver.disconnect(); this.sizeObserver = null }
3009
- this.frame = null
3010
- },
3011
- apply() {
3012
- const frame = this.frame
3013
- if (frame === null) return
3014
- const tracks = parseGridTracksCompat(frame.style.gridTemplateColumns)
3015
- if (tracks.length < 3) return
3016
- const trackPx = (t) => {
3017
- const m = /^(-?[\d.]+)px$/.exec(String(t).trim())
3018
- return m === null ? 0 : Number(m[1])
3019
- }
3020
- const details = trackPx(tracks[2])
3021
- const preview = tracks.length >= 4 ? trackPx(tracks[3]) : 0
3022
- const explorer = tracks.length >= 5 ? trackPx(tracks[4]) : 0
3023
- const handle = frame.querySelector('[data-side="details"]')
3024
- if (handle === null) return
3025
- handle.style.left = `${Math.round(frame.getBoundingClientRect().width - details - preview - explorer)}px`
3026
- },
3027
- }
3954
+ // M12×aionui:官方 details 把手在 5 grid 下的位置补偿已由上游
3955
+ // aionui-panel ≥0.2.0 接管(dsh-web-ui PR #311,applyGrid 每帧重算;
3956
+ // 3 轨时退化为官方原值)。本插件过渡兼容层 detailsHandleCompat 已移除
3957
+ // (2026-08-18,本机 aionui 0.2.0 实证含修复;旧版 aionui 请升级)。
3028
3958
  const ensureColumn = () => {
3029
3959
  if (dockBridge.open && layout !== undefined && typeof layout.openDetails === 'function') {
3030
3960
  try { layout.openDetails() } catch (e) {}
@@ -3066,7 +3996,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3066
3996
  if (!dockBridge.open) {
3067
3997
  dockBridge.open = true
3068
3998
  syncDetails()
3069
- detailsHandleCompat.start()
3070
3999
  // 会话切换时官方会自动 closeDetails —— 高频守护快速重开(无动画,内容直接刷新)。
3071
4000
  if (detailsGuard === null) {
3072
4001
  detailsGuard = setInterval(() => { ensureColumn() }, 150)
@@ -3078,7 +4007,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3078
4007
  if (dockBridge.open) {
3079
4008
  dockBridge.open = false
3080
4009
  syncDetails()
3081
- detailsHandleCompat.stop()
3082
4010
  if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3083
4011
  for (const fn of dockBridge.listeners) fn()
3084
4012
  }
@@ -3132,7 +4060,6 @@ window.__ModuleLoader__.load({ id: 'dsh-session-flow', factory: (require) => {
3132
4060
  }
3133
4061
  if (detailsGuard !== null) { clearInterval(detailsGuard); detailsGuard = null }
3134
4062
  setDockActive(false)
3135
- detailsHandleCompat.stop()
3136
4063
  if (layout !== undefined && typeof layout.closeDetails === 'function') {
3137
4064
  try { layout.closeDetails() } catch (e) {}
3138
4065
  }