@khorsheed/dsh-message-timeline 0.1.0 → 0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 变更记录
2
2
 
3
+ ## 0.2.0(2026-09-10)
4
+
5
+ 适配宿主 0.1.2 线。
6
+
7
+ - **BREAKING**:minHost 前移至 `0.1.2-rc.1`;宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 的用户请停留在 0.1.x 线(末版 `0.1.0`)
8
+ - 聊天快照改读 0.1.2 的 `useChat` 单座席(rc 线的 `useSession(...).chat` 读取臂退役);座席缺席时面板静默隐藏,绝不报错
9
+ - 导入面迁移:client bundle 不再引用宿主已删除的 `dsh-client-runtime`
10
+
3
11
  ## 0.1.0(2026-08-22)
4
12
 
5
13
  首个公开发布。
package/README.en.md CHANGED
@@ -2,19 +2,22 @@
2
2
 
3
3
  English | [中文](README.md)
4
4
 
5
- A jump-to-message timeline for the dsh web GUI: a floating rail on the chat's left edge with one row per user message. Hover to reveal previews, click to scroll the transcript straight to that message.
5
+ Every message you sent in a long conversation visible at a glance, one click to jump back.
6
6
 
7
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline1.png" width="480" alt="floating message timeline along the chat's left edge">
7
+ Once a session gets long, finding that requirement you mentioned three messages ago means scrolling up forever. This plugin parks a timeline on the chat's left edge: one row per message you sent, with a one-line preview. At rest it's just a strip of dimmed ticks that stays out of the way; hover to expand it, and clicking a row scrolls the transcript straight to that message. It only reads the session and never sends anything — the model doesn't notice it at all.
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline2.png" width="480" alt="the timeline rests as a thin rail out of sight, expanding on hover">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline1.png" width="640" alt="the expanded message timeline on the chat's left edge: one row per user message, click a row to jump to it">
10
10
 
11
11
  ## Features
12
12
 
13
- - **One row per user message** — tick plus ellipsized one-line preview; steering messages count too (configurable off).
13
+ - **One row per user message** — tick plus ellipsized one-line preview; steering messages count too (configurable off), and message-tools edited/restored bubbles keep a row. Withdrawn originals are not listed (their transcript row is hidden, so a click could never reach them).
14
14
  - **Ambient rest state** — only dimmed ticks show until you hover the strip or focus the list.
15
15
  - **Reading position tracking** — the current position's tick stays lit blue, anchoring to the user message a long answer is replying to.
16
16
  - **Click to jump** — a row scrolls the transcript to that message; the list follows the reading position.
17
- - **Long-history friendly** — a short list centers vertically; a long one scrolls and pages older history at its top.
17
+ - **Long-history friendly** — a short list centers vertically; a long one scrolls and pages older history at its top, its bottom-most row flush with the chat input box; goal/todo dock cards never push the timeline up.
18
+ - **Never covers the message flow** — the panel's width is capped by the scrollport's left gutter; a gutter too small for the minimum width hides it rather than overlapping the transcript.
19
+
20
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline2.png" width="640" alt="the timeline at rest: a strip of dimmed ticks that stays out of the way, expanding on hover">
18
21
 
19
22
  ## Install
20
23
 
@@ -39,12 +42,15 @@ dsh plugin --profile web remove @khorsheed/dsh-message-timeline
39
42
 
40
43
  ## Compatibility
41
44
 
42
- - npm release line (`@deepseek-ai/dsh@0.1.1-rc.2`): ✅ full — the rc.8→0.1.1-rc.1 API audit (2026-08-21) confirms every surface this plugin consumes is unchanged or additive (the ProjectionDefinition restructure, cacheHitPercent return-type change, and the credentials/updated event rename do not touch this package); no source change was needed; re-audited for rc.2 (2026-08-22): consumed surface unchanged, full build+test green.
43
- - source line (deepseek-harness master): ✅
45
+ - npm release line (`@deepseek-ai/dsh@0.1.2-rc.1`): ✅ full — baseline moved to the 0.1.2-rc.1 API surface (single-arm 0.1.2 API consumption; the 0.1.1-rc.2 runtime arm is retired), full build+test green; minHost moves up to 0.1.2-rc.1 older hosts stay on the previous release line.
46
+ - source line (deepseek-harness master): ✅ (verifiedHost: 0.1.2-rc.1)
47
+
48
+ **Version line mapping**: 0.2.0 and up support host `0.1.2-rc.1` and later; hosts on `0.1.0-rc.6` ~ `0.1.1-rc.2` stay on the 0.1.x release line (last release `0.1.0`).
44
49
 
45
50
  ## Known Limitations
46
51
 
47
52
  - **DOM probe coupling** — the panel targets official row attributes and the `[data-conversation-scroll]` scrollport; if the official DOM changes, it hides itself (one `console.warn`) until the probe is updated.
53
+ - **Best-effort width fallback** — while the message-flow probe is unanswered, the panel renders at a capped fraction of the scrollport instead of hiding, so it may overlap the flow until the probe recovers; with a healthy probe the width never crosses the flow's left edge.
48
54
  - **Single rendered session** — rows and jumps address only the currently rendered conversation.
49
55
  - **Loaded history only** — rows cover materialized nodes; older messages arrive one page at a time when the panel is scrolled to its top.
50
56
  - **No full index view yet** — a searchable message-index tab is planned.
@@ -57,14 +63,16 @@ dsh plugin --profile web remove @khorsheed/dsh-message-timeline
57
63
  The plugin is purely additive and modifies no official code.
58
64
 
59
65
  - `src/client/index.ts` — plugin body (`apply`/`inject`)
60
- - `src/client/rail-tracker.ts` — the only DOM the plugin touches: read-only probes plus the jump's scroll write
66
+ - `src/client/rail-tracker.ts` — the only DOM the plugin touches: read-only probes, the reading-position resolution, plus the jump's scroll write
61
67
  - `src/client/TimelineRail.tsx` — the panel component
68
+ - `src/client/timeline-kinds.ts` — the single source of truth for which node kinds are rows and which carry a hidden span
69
+ - `src/client/hidden-spans.ts` — folds the message-tools withdraw/edit spans so covered originals are dropped as dead rows
62
70
  - `src/client/preview.ts` — message content to one-line preview text
63
71
  - `src/index.ts` — empty host `apply`, anchors the plugin into the host Loader
64
72
 
65
- **Mount & data** — one entry in the official `conversation.session.header.utilities` slot anchors the plugin into the session scope; the panel renders through a body portal with fixed geometry measured from the official scrollport. Rows derive from the framework `useSession` chat snapshot (`s.chat.order` / `s.chat.nodes`), filtered to `user` / `steering` nodes — no store outside the session, no event registration.
73
+ **Mount & data** — one entry in the official `conversation.session.header.utilities` slot anchors the plugin into the session scope; the panel renders through a body portal with fixed geometry measured from the official scrollport. Rows derive from the framework `useSession` chat snapshot (`s.chat.order` / `s.chat.nodes`): the ordinary `user` (and optional `steering`) rows come from the host order, and message-tools `message-tools-edited`/`message-tools-restored` bubbles that the host order does not always surface are appended from the node store — no store outside the session, no event registration. A bubble for a withdrawn original is dropped (its row is hidden by the DOM hider, so a click could never reach it), and appended bubbles are sorted by their anchor seq so the newest message is always last.
66
74
 
67
- **Jump & degradation** — clicking a row finds the transcript row by the official `data-chat-anchor-key` attribute and writes `scrollTop`; the official ChatView treats that as a normal reader move (bottom-follow and scroll memory keep working). The probed attributes are official render output, not a declared API: when they change, the panel hides itself with one `console.warn`; nothing throws and the boot never fails.
75
+ **Jump & degradation** — clicking a row finds the transcript row by the official `data-chat-anchor-key` attribute and writes `scrollTop`; the official ChatView treats that as a normal reader move (bottom-follow and scroll memory keep working). The probed attributes are official render output, not a declared API: when they change, the panel hides itself with one `console.warn`; nothing throws and the boot never fails. The panel's width is capped by the scrollport's left gutter so it never covers the message flow; while the flow probe is unanswered (an official structure change) the width degrades to a capped fraction of the scrollport as a best-effort fallback — the panel may then overlap the flow until the probe recovers.
68
76
 
69
77
  **Model experience: none.** The panel reads the session snapshot and scrolls the transcript; it never sends prompts, appends session events, or enters the session log. KV cache effect: none. The `enabled` config turns the plugin off entirely; removing it from cordis.yml removes every surface it adds.
70
78
 
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # last confirmed-consistent state. Both languages carry equal authority; after
3
3
  # editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/message-timeline/README.en.md
5
- packages/message-timeline/README.en.md: 6c23db63db34214d844f5db121fb8e87b9565a05
6
- packages/message-timeline/README.md: da68ead740c42b8ceb770047aefdb8f61ce733d2
5
+ packages/message-timeline/README.en.md: 5c8d6f4b24c2852e1d64db8aeb0a4817e0832c64
6
+ packages/message-timeline/README.md: 1718fba1afa90e0cdd1e23956cea3aa45fde0502
package/README.md CHANGED
@@ -1,20 +1,23 @@
1
- # dsh-message-timeline
1
+ # @khorsheed/dsh-message-timeline
2
2
 
3
3
  [English](README.en.md) | 中文
4
4
 
5
- dsh web GUI 的历史消息导览:会话左缘的一条悬浮时间轴,每行一条用户消息。悬停显示预览,点击直接把会话滚动到对应消息。
5
+ 长对话里你说过的每句话,一眼看到、一点就跳回去。
6
6
 
7
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline1.png" width="480" alt="会话左缘的悬浮消息时间轴">
7
+ 会话一长,想找回三条消息前提过的那个要求,就只能一路往上滚。这个插件在对话左缘放了一条时间轴:你说过的每句话占一行,带单行预览;平时它收成一排压淡的刻度,不占视线,鼠标一悬停才展开,点哪一行就把会话滚到哪一句。它只读会话、不发任何消息,模型完全无感。
8
8
 
9
- <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline2.png" width="480" alt="时间轴日常收成一条细线不占视线,悬停才展开">
9
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline1.png" width="640" alt="对话左缘展开的消息时间轴:每行一条用户消息,点行即跳转到对应消息">
10
10
 
11
11
  ## 特性
12
12
 
13
- - **每行一条用户消息**——竖刻度加单行省略预览;steering 消息也计入(可配置关闭)。
14
- - **环境化静止态**——只显示压淡的刻度,悬停刻度条或聚焦列表才展开文字。
15
- - **阅读位置跟踪**——当前位置的刻度保持蓝色高亮,停在超长回复中时锚定在所回答的用户消息上。
16
- - **点击跳转**——点击行把会话滚动到对应消息;列表跟随阅读位置。
17
- - **长历史友好**——列表短时垂直居中,长时隐形滚动并在顶部翻页加载更早历史。
13
+ - **每行一条用户消息**——竖刻度加单行省略预览;steering 消息也计入(可配置关闭),message-tools 的编辑/恢复气泡也保留一行。被撤回的原消息不展示(其会话行被隐藏,点击也到不了)。
14
+ - **环境化静止态**——只显示压淡的刻度,悬停刻度条或聚焦列表才展开文字。
15
+ - **阅读位置跟踪**——当前位置的刻度保持蓝色高亮,停在超长回复中时锚定在所回答的用户消息上。
16
+ - **点击跳转**——点击行把会话滚动到对应消息;列表跟随阅读位置。
17
+ - **长历史友好**——列表短时垂直居中;长时隐形滚动并在顶部翻页加载更早历史,最底部贴着聊天输入框;目标/任务等 dock 卡片不会把时间轴顶上去。
18
+ - **绝不遮住消息流**——面板宽度受滚动区左缘沟槽约束;沟槽放不下最小宽度时隐藏而不是盖住会话。
19
+
20
+ <img src="https://raw.githubusercontent.com/Khorsheed/dsh-web-basic/main/docs/screenshots/message-timeline2.png" width="640" alt="时间轴的静止态:收成一排压淡的刻度条,不挡视线,悬停才展开">
18
21
 
19
22
  ## 安装
20
23
 
@@ -22,7 +25,7 @@ dsh web GUI 的历史消息导览:会话左缘的一条悬浮时间轴,每行一
22
25
  dsh plugin --profile web add @khorsheed/dsh-message-timeline
23
26
  ```
24
27
 
25
- 然后重启 web 实例。卸载:
28
+ 然后重启 web 实例。卸载:
26
29
 
27
30
  ```sh
28
31
  dsh plugin --profile web remove @khorsheed/dsh-message-timeline
@@ -32,47 +35,52 @@ dsh plugin --profile web remove @khorsheed/dsh-message-timeline
32
35
 
33
36
  | 字段 | 默认 | 含义 |
34
37
  | --- | --- | --- |
35
- | `enabled` | `true` | 总开关;false 时面板完全不渲染。 |
36
- | `includeSteering` | `true` | 回合中插入的用户消息(steering)是否也算行。 |
37
- | `panelWidth` | `360` | 面板宽度(px,限 120–640);窄列自动收缩,左缘沟槽放不下时整条隐藏。 |
38
- | `initialPages` | `5` | 面板打开时预取的历史页数(每页 50 条事件);更早历史在面板滚动到顶部时按需加载(限 1–20)。 |
38
+ | `enabled` | `true` | 总开关;false 时面板完全不渲染。 |
39
+ | `includeSteering` | `true` | 回合中插入的用户消息(steering)是否也算行。 |
40
+ | `panelWidth` | `360` | 面板宽度(px,限 120–640);窄列自动收缩,左缘沟槽放不下时整条隐藏。 |
41
+ | `initialPages` | `5` | 面板打开时预取的历史页数(每页 50 条事件);更早历史在面板滚动到顶部时按需加载(限 1–20)。 |
39
42
 
40
43
  ## Compatibility
41
44
 
42
- - npm 发布线(`@deepseek-ai/dsh@0.1.1-rc.2`):✅ 完整——rc.8→0.1.1-rc.1 API 审计(2026-08-21)确认本插件消费的所有面无变化或纯增量(ProjectionDefinition 重构、cacheHitPercent 返回值变更、credentials/updated 事件改名均不涉及本包),无需改动源码;rc.1→rc.2 复核(2026-08-22):消费面无变化,全量构建测试通过。
43
- - 源码线(deepseek-harness master):✅
45
+ - npm 发布线(`@deepseek-ai/dsh@0.1.2-rc.1`):✅ 完整——基线迁移至 0.1.2-rc.1 API 面(单臂消费 0.1.2 API,0.1.1-rc.2 运行臂已退役),全量构建测试通过;minHost 前移至 0.1.2-rc.1,旧宿主请停留在旧发布线。
46
+ - 源码线(deepseek-harness master):✅(verifiedHost: 0.1.2-rc.1)
47
+
48
+ **版本线对照**:0.2.0 起支持宿主 `0.1.2-rc.1` 及以后;宿主 `0.1.0-rc.6` ~ `0.1.1-rc.2` 的用户请停留在 0.1.x 发布线(末版 `0.1.0`)。
44
49
 
45
50
  ## 已知限制
46
51
 
47
- - **依赖官方 DOM 探针** —— 面板针对官方行属性与 `[data-conversation-scroll]` 滚动区;官方结构变化时面板自行隐藏(console.warn 一次)直到探针更新。
52
+ - **依赖官方 DOM 探针** —— 面板针对官方行属性与 `[data-conversation-scroll]` 滚动区;官方结构变化时面板自行隐藏(console.warn 一次)直到探针更新。
53
+ - **best-effort 宽度兜底** —— 消息流探针无应答时,面板以滚动区受限比例渲染而非隐藏,可能压到消息流直到探针恢复;探针健康时宽度绝不越过消息流左缘。
48
54
  - **单会话渲染** —— 行与跳转只作用于当前渲染的会话。
49
- - **仅已加载历史** —— 行覆盖已物化的节点;更早消息在面板滚动到顶部时逐页加载。
55
+ - **仅已加载历史** —— 行覆盖已物化的节点;更早消息在面板滚动到顶部时逐页加载。
50
56
  - **暂无整页导览** —— 可搜索的消息索引标签页在计划中。
51
57
 
52
58
  ## 实现原理
53
59
 
54
60
  <details>
55
- <summary>内部结构(点击展开)</summary>
61
+ <summary>内部结构(点击展开)</summary>
56
62
 
57
63
  插件纯增量、不改任何官方代码。
58
64
 
59
- - `src/client/index.ts` —— 插件主体(`apply`/`inject`)
60
- - `src/client/rail-tracker.ts` —— 插件唯一接触的 DOM:只读探针加跳转时的滚动写入
65
+ - `src/client/index.ts` —— 插件主体(`apply`/`inject`)
66
+ - `src/client/rail-tracker.ts` —— 插件唯一接触的 DOM:只读探针、阅读位置解析,加跳转时的滚动写入
61
67
  - `src/client/TimelineRail.tsx` —— 面板组件
68
+ - `src/client/timeline-kinds.ts` —— 哪些 kind 算行、哪些 kind 携带隐藏范围,的唯一权威
69
+ - `src/client/hidden-spans.ts` —— 折叠 message-tools 撤回/编辑范围,把被覆盖的原消息作为死行丢弃
62
70
  - `src/client/preview.ts` —— 消息内容转单行预览文本
63
- - `src/index.ts` —— 空的宿主 `apply`,只负责把插件锚定进宿主 Loader
71
+ - `src/index.ts` —— 空的宿主 `apply`,只负责把插件锚定进宿主 Loader
64
72
 
65
- **挂载与数据** —— 一个条目注册进官方 `conversation.session.header.utilities` 槽位,把插件锚定进会话作用域;面板通过 body portal 以固定几何渲染,数据从官方滚动区实测。行取自框架 `useSession` 会话快照(`s.chat.order` / `s.chat.nodes`),过滤 `user` / `steering` 节点——不持有会话外状态,不注册事件。
73
+ **挂载与数据** —— 一个条目注册进官方 `conversation.session.header.utilities` 槽位,把插件锚定进会话作用域;面板通过 body portal 以固定几何渲染,数据从官方滚动区实测。行取自框架 `useSession` 会话快照(`s.chat.order` / `s.chat.nodes`):普通 `user`(及可选 `steering`)行来自宿主 order,而宿主 order 未必补上的 message-tools `message-tools-edited`/`message-tools-restored` 气泡从节点库追加——不持有会话外状态,不注册事件。被撤回原消息的气泡会被丢弃(其会话行已被 DOM hider 隐藏,点击到不了),追加的气泡按锚定 seq 排序,保证最新消息恒为最后一行。
66
74
 
67
- **跳转与降级** —— 点击行按官方 `data-chat-anchor-key` 属性找到会话行并写 `scrollTop`;官方 ChatView 把这种程序化滚动当正常读者移动处理(底部跟随与滚动记忆照常工作)。探测的属性是官方渲染产物而非契约 API:官方改结构时面板自行隐藏并 console.warn 一次,不抛错、boot 永不失败。
75
+ **跳转与降级** —— 点击行按官方 `data-chat-anchor-key` 属性找到会话行并写 `scrollTop`;官方 ChatView 把这种程序化滚动当正常读者移动处理(底部跟随与滚动记忆照常工作)。探测的属性是官方渲染产物而非契约 API:官方改结构时面板自行隐藏并 console.warn 一次,不抛错、boot 永不失败。面板宽度受左缘沟槽约束、不越过消息流左缘;流探针无应答(官方结构变化)时宽度退化为滚动区受限比例以 best-effort 兜底——此时面板可能压到消息流,直到探针恢复。
68
76
 
69
- **模型体验:无。** 面板只读会话快照并滚动会话,不发送提示词、不追加会话事件、不进会话日志。KV 缓存影响:无。`enabled` 配置可整体关闭插件;从 cordis.yml 移除本插件即移除它添加的所有界面。
77
+ **模型体验:无。** 面板只读会话快照并滚动会话,不发送提示词、不追加会话事件、不进会话日志。KV 缓存影响:无。`enabled` 配置可整体关闭插件;从 cordis.yml 移除本插件即移除它添加的所有界面。
70
78
 
71
79
  </details>
72
80
 
73
81
  ## 开发
74
82
 
75
- 隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/message-timeline`)。问题与贡献请移步该仓库。
83
+ 隶属 [dsh-plugins](https://github.com/Khorsheed/dsh-plugins) monorepo(`packages/message-timeline`)。问题与贡献请移步该仓库。
76
84
 
77
85
  ## 变更记录
78
86
 
package/lib/client.js CHANGED
@@ -40,6 +40,27 @@ window.__ModuleLoader__.load({
40
40
  "rail.panel": "Message timeline",
41
41
  "rail.empty": "No user messages"
42
42
  };
43
+ /**
44
+ * Whether a node kind renders as a timeline row. The rail lists user messages
45
+ * (and optionally steering), plus the message-tools edited/restored bubbles
46
+ * that the host `order` does not always surface.
47
+ * @param kind - the node's kind string.
48
+ * @param includeSteering - whether steering rows count as user messages.
49
+ * @returns true when the kind belongs on the timeline.
50
+ */
51
+ function isTimelineRowKind(kind, includeSteering) {
52
+ return kind === "user" || includeSteering && kind === "steering" || kind === "message-tools-edited" || kind === "message-tools-restored";
53
+ }
54
+ /**
55
+ * Whether a node kind carries a hidden span (`{ hiddenStartSeq, seq }`) that
56
+ * the rail must fold to drop the covered originals. The withdrawal divider and
57
+ * the edited bubble both declare the span they shadow.
58
+ * @param kind - the node's kind string.
59
+ * @returns true when the kind declares a hidden span.
60
+ */
61
+ function isHiddenSpanCarrierKind(kind) {
62
+ return kind === "message-tools-withdrawn" || kind === "message-tools-edited";
63
+ }
43
64
  //#endregion
44
65
  //#region src/client/rail-tracker.ts
45
66
  /** Horizontal inset of the rail from the scrollport's left edge (px). */
@@ -62,10 +83,15 @@ window.__ModuleLoader__.load({
62
83
  };
63
84
  /**
64
85
  * Measure the panel's viewport box from one scrollport: its rect inset by the
65
- * panel padding, minus the sticky composer seat at the bottom and the
66
- * conversation tab strip at the top. The tabs render just above the
67
- * scrollport, but centering reads against the whole window, so the strip
68
- * height leaves the box either way — otherwise the list sits visibly high.
86
+ * panel padding, minus the chat input card at the bottom and the conversation
87
+ * tab strip at the top. The tabs render just above the scrollport, but
88
+ * centering reads against the whole window, so the strip height leaves the
89
+ * box either way — otherwise the list sits visibly high.
90
+ * The bottom ends at the `[data-composer-card]` top — the chat box — NOT the
91
+ * whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
92
+ * the input inside the seat, and counting them would push the timeline up off
93
+ * the conversation. Falls back to the seat top, then the column bottom, when
94
+ * the markers are absent.
69
95
  * The scrollport's own width rides along for the width-cap fallback when the
70
96
  * message-flow probe is unanswered.
71
97
  * @param scrollport - the official conversation scrollport element.
@@ -75,7 +101,6 @@ window.__ModuleLoader__.load({
75
101
  function measureGeometry(scrollport) {
76
102
  const rect = scrollport.getBoundingClientRect();
77
103
  if (rect.width === 0 && rect.height === 0) return null;
78
- const composerHeight = scrollport.querySelector("[data-composer-seat]")?.getBoundingClientRect().height ?? 0;
79
104
  let topInset = RAIL_VERTICAL_PADDING;
80
105
  for (const tabs of scrollport.ownerDocument.querySelectorAll("[role=\"tablist\"]")) {
81
106
  const tabsRect = tabs.getBoundingClientRect();
@@ -84,25 +109,40 @@ window.__ModuleLoader__.load({
84
109
  break;
85
110
  }
86
111
  }
112
+ const card = scrollport.querySelector("[data-composer-card]");
113
+ const seat = scrollport.querySelector("[data-composer-seat]");
114
+ const bottom = card !== null ? card.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : seat !== null ? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : rect.bottom - RAIL_VERTICAL_PADDING;
87
115
  return {
88
116
  left: rect.left + RAIL_LEFT_INSET,
89
117
  top: rect.top + topInset,
90
- height: Math.max(0, rect.height - composerHeight - topInset - RAIL_VERTICAL_PADDING),
118
+ height: Math.max(0, bottom - rect.top - topInset),
91
119
  width: rect.width
92
120
  };
93
121
  }
94
122
  /**
95
123
  * The viewport x of the message flow's left edge: the left of the first
96
- * rendered `[data-chat-flow-kind]` row, which sits flush inside the official
97
- * centered content column (max 748px, `margin: 0 auto`). Every flow row
98
- * shares that edge, so the first one found suffices. The panel's right edge
99
- * stays left of it — the panel may only occupy the scrollport's left gutter.
124
+ * laid-out `[data-chat-flow-kind]` row, which sits flush inside the official
125
+ * centered content column (max 748px, `margin: 0 auto`). Laid-out flow rows
126
+ * share that edge, so the first meaningful one suffices. The panel's right
127
+ * edge stays left of it — the panel may only occupy the scrollport's left
128
+ * gutter.
129
+ *
130
+ * Rows that are not laid out in the flow are skipped: a message-tools edit
131
+ * leaves the withdrawn originals in the DOM (hidden, zero-size, or off the
132
+ * column at x=0), and probing their left edge yields 0 — which would make the
133
+ * width gate compute a negative left gutter and hide the entire rail. Any
134
+ * real flow row sits inside the conversation column at a positive x.
100
135
  * @param scrollport - the official conversation scrollport element.
101
- * @returns the flow's left edge, or null while no flow row is rendered.
136
+ * @returns the flow's left edge, or null while no laid-out flow row is rendered.
102
137
  */
103
138
  function flowLeftX(scrollport) {
104
- const row = scrollport.querySelector("[data-chat-flow-kind]");
105
- return row === null ? null : row.getBoundingClientRect().left;
139
+ for (const row of scrollport.querySelectorAll("[data-chat-flow-kind]")) {
140
+ const rect = row.getBoundingClientRect();
141
+ if (rect.width === 0 && rect.height === 0) continue;
142
+ if (rect.left <= 0) continue;
143
+ return rect.left;
144
+ }
145
+ return null;
106
146
  }
107
147
  /**
108
148
  * Resolve the key of the user-message row the reading position belongs to:
@@ -120,7 +160,7 @@ window.__ModuleLoader__.load({
120
160
  let lastAbove = null;
121
161
  for (const row of scrollport.querySelectorAll("[data-chat-flow-kind]")) {
122
162
  const kind = row.dataset.chatFlowKind;
123
- if (kind !== "user" && !(includeSteering && kind === "steering")) continue;
163
+ if (kind === void 0 || !isTimelineRowKind(kind, includeSteering)) continue;
124
164
  if (row.getBoundingClientRect().bottom <= viewTop) {
125
165
  lastAbove = row.dataset.chatAnchorKey ?? lastAbove;
126
166
  continue;
@@ -159,6 +199,7 @@ window.__ModuleLoader__.load({
159
199
  let resizeObserver;
160
200
  let rafPending = false;
161
201
  let warned = false;
202
+ let bindToken = 0;
162
203
  const nextFrame = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => {
163
204
  setTimeout(callback, 16);
164
205
  };
@@ -168,9 +209,28 @@ window.__ModuleLoader__.load({
168
209
  state = next;
169
210
  for (const fn of [...listeners]) fn();
170
211
  };
212
+ const attach = (el) => {
213
+ if (scrollport !== null) scrollport.removeEventListener("scroll", onScroll);
214
+ resizeObserver?.disconnect();
215
+ scrollport = el;
216
+ scrollport.addEventListener("scroll", onScroll, { passive: true });
217
+ if (typeof ResizeObserver === "function") {
218
+ resizeObserver = new ResizeObserver(scheduleUpdate);
219
+ resizeObserver.observe(el);
220
+ const composer = el.querySelector("[data-composer-seat]");
221
+ if (composer !== null) resizeObserver.observe(composer);
222
+ }
223
+ };
171
224
  const update = () => {
172
- if (scrollport === null) return;
173
- const geometry = measureGeometry(scrollport);
225
+ let el = scrollport;
226
+ if (el === null || !el.isConnected) {
227
+ if (activeSession === void 0) return;
228
+ const found = document.querySelector("[data-conversation-scroll]");
229
+ if (found === null) return;
230
+ attach(found);
231
+ el = found;
232
+ }
233
+ const geometry = measureGeometry(el);
174
234
  if (geometry === null) return;
175
235
  publish({
176
236
  sessionId: activeSession,
@@ -179,9 +239,9 @@ window.__ModuleLoader__.load({
179
239
  top: geometry.top,
180
240
  height: geometry.height,
181
241
  scrollportWidth: geometry.width,
182
- flowLeft: flowLeftX(scrollport),
183
- activeKey: activeRowKey(scrollport, includeSteering),
184
- chatView: scrollport.querySelector("[data-chat-flow]") !== null
242
+ flowLeft: flowLeftX(el),
243
+ activeKey: activeRowKey(el, includeSteering),
244
+ chatView: el.querySelector("[data-chat-flow]") !== null
185
245
  });
186
246
  };
187
247
  const scheduleUpdate = () => {
@@ -196,6 +256,7 @@ window.__ModuleLoader__.load({
196
256
  scheduleUpdate();
197
257
  };
198
258
  const teardownBind = () => {
259
+ bindToken++;
199
260
  if (scrollport !== null) scrollport.removeEventListener("scroll", onScroll);
200
261
  scrollport = null;
201
262
  resizeObserver?.disconnect();
@@ -210,7 +271,9 @@ window.__ModuleLoader__.load({
210
271
  publish(IDLE);
211
272
  return;
212
273
  }
274
+ const token = bindToken;
213
275
  nextFrame(() => {
276
+ if (token !== bindToken) return;
214
277
  const found = document.querySelector("[data-conversation-scroll]");
215
278
  if (found === null) {
216
279
  if (!warned) {
@@ -220,14 +283,7 @@ window.__ModuleLoader__.load({
220
283
  publish(IDLE);
221
284
  return;
222
285
  }
223
- scrollport = found;
224
- scrollport.addEventListener("scroll", onScroll, { passive: true });
225
- if (typeof ResizeObserver === "function") {
226
- resizeObserver = new ResizeObserver(scheduleUpdate);
227
- resizeObserver.observe(scrollport);
228
- const composer = scrollport.querySelector("[data-composer-seat]");
229
- if (composer !== null) resizeObserver.observe(composer);
230
- }
286
+ attach(found);
231
287
  update();
232
288
  });
233
289
  };
@@ -235,7 +291,7 @@ window.__ModuleLoader__.load({
235
291
  bind(ctx.sessions.list.getSnapshot().current);
236
292
  };
237
293
  const stopList = ctx.sessions.list.subscribe(bindCurrent);
238
- const stopProvide = ctx.sessions.currentProvideInfo.subscribe(bindCurrent);
294
+ const stopProvide = ctx.sessions.currentProvideInfo?.subscribe(bindCurrent);
239
295
  bindCurrent();
240
296
  const onWindowResize = () => {
241
297
  scheduleUpdate();
@@ -264,7 +320,7 @@ window.__ModuleLoader__.load({
264
320
  },
265
321
  dispose: () => {
266
322
  stopList();
267
- stopProvide();
323
+ stopProvide?.();
268
324
  if (typeof window !== "undefined") window.removeEventListener("resize", onWindowResize);
269
325
  mutationObserver?.disconnect();
270
326
  teardownBind();
@@ -272,6 +328,26 @@ window.__ModuleLoader__.load({
272
328
  };
273
329
  }
274
330
  //#endregion
331
+ //#region src/client/chat-hook.ts
332
+ /** Empty slice behind the absent-prop degrade: no rows, so the panel hides. */
333
+ const EMPTY_SLICE = {
334
+ order: [],
335
+ nodes: {
336
+ get: () => void 0,
337
+ values: () => []
338
+ }
339
+ };
340
+ /** Constant hook answering every selector from the empty slice. */
341
+ const EMPTY_HOOK = (selector) => selector(EMPTY_SLICE);
342
+ /**
343
+ * Pick the live chat hook for this entry.
344
+ * @param props - the entry's composed props (useChat present on 0.1.2 hosts).
345
+ * @returns the `useChat` prop, or the empty-slice hook when the seat is absent.
346
+ */
347
+ function chatHookOf(props) {
348
+ return props.useChat ?? EMPTY_HOOK;
349
+ }
350
+ //#endregion
275
351
  //#region src/client/preview.ts
276
352
  /**
277
353
  * Join the text blocks of one message into a single preview string.
@@ -284,7 +360,44 @@ window.__ModuleLoader__.load({
284
360
  return parts.length === 0 ? null : parts.join("\n");
285
361
  }
286
362
  //#endregion
287
- //#region \0dsh-css:/Users/zhuyudan/code/dsh-plugins/packages/message-timeline/src/client/TimelineRail.module.css.mjs
363
+ //#region src/client/hidden-spans.ts
364
+ /**
365
+ * Fold the hidden seq spans out of the store into the flat
366
+ * `[start, endExclusive, ...]` pair list ordered by start — the same shape
367
+ * message-tools' renderers and DOM hider consume (`{ hiddenStartSeq, seq }`,
368
+ * inclusive start, exclusive end). A malformed carrier (missing or inverted
369
+ * bounds) is skipped rather than poisoning the fold.
370
+ * @param nodes - every materialized chat node (any order).
371
+ * @returns the flattened hidden spans; empty when no withdraw/edit landed.
372
+ */
373
+ function foldHiddenSpans(nodes) {
374
+ const pairs = [];
375
+ for (const node of nodes) {
376
+ if (!isHiddenSpanCarrierKind(node.kind)) continue;
377
+ const data = node.data;
378
+ if (data.hiddenStartSeq === void 0 || data.seq === void 0) continue;
379
+ if (data.hiddenStartSeq >= data.seq) continue;
380
+ pairs.push([data.hiddenStartSeq, data.seq]);
381
+ }
382
+ pairs.sort((left, right) => left[0] - right[0]);
383
+ return pairs.flat();
384
+ }
385
+ /**
386
+ * Whether a node seq falls inside one of the folded hidden spans.
387
+ * @param spans - the flattened spans from {@link foldHiddenSpans}.
388
+ * @param seq - the node seq to test.
389
+ * @returns true when the seq is hidden (a withdrawn original).
390
+ */
391
+ function isSeqHidden(spans, seq) {
392
+ for (let index = 0; index + 1 < spans.length; index += 2) {
393
+ const start = spans[index];
394
+ const end = spans[index + 1];
395
+ if (start !== void 0 && end !== void 0 && seq >= start && seq < end) return true;
396
+ }
397
+ return false;
398
+ }
399
+ //#endregion
400
+ //#region \0dsh-css:src/client/TimelineRail.module.css.mjs
288
401
  const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel:before,._8u17xW_panel:after{content:\"\";margin:auto}._8u17xW_panel::-webkit-scrollbar{display:none}._8u17xW_item{width:100%;max-width:20px;color:var(--dsw-alias-label-tertiary);text-align:left;cursor:pointer;-webkit-user-select:none;user-select:none;pointer-events:auto;opacity:.55;background:0 0;border:none;flex:none;align-items:center;gap:8px;padding:5px 0;font-size:13px;line-height:1.5;transition:opacity .12s;display:flex}._8u17xW_panel:hover ._8u17xW_item,._8u17xW_panel:focus-within ._8u17xW_item{opacity:1;max-width:100%}._8u17xW_tick{background:var(--dsw-static-deepseek-400);border-radius:2px;flex:none;width:3px;height:14px}._8u17xW_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:.75}._8u17xW_tickCurrent{background:var(--dsw-static-deepseek-500)}._8u17xW_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}._8u17xW_panel:hover ._8u17xW_itemText,._8u17xW_panel:focus-within ._8u17xW_itemText{opacity:1;transition-delay:90ms}._8u17xW_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}._8u17xW_item:focus:not(:focus-visible){outline:none}._8u17xW_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}";
289
402
  const tagId = "@khorsheed/dsh-message-timeline/TimelineRail.module.css";
290
403
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
@@ -295,13 +408,13 @@ window.__ModuleLoader__.load({
295
408
  document.head.appendChild(tag);
296
409
  }
297
410
  var TimelineRail_module_css_default = {
298
- "panel": "_8u17xW_panel",
299
- "itemText": "_8u17xW_itemText",
411
+ "item": "_8u17xW_item",
300
412
  "tick": "_8u17xW_tick",
301
413
  "itemCurrent": "_8u17xW_itemCurrent",
302
- "itemFocused": "_8u17xW_itemFocused",
414
+ "panel": "_8u17xW_panel",
303
415
  "tickCurrent": "_8u17xW_tickCurrent",
304
- "item": "_8u17xW_item"
416
+ "itemText": "_8u17xW_itemText",
417
+ "itemFocused": "_8u17xW_itemFocused"
305
418
  };
306
419
  //#endregion
307
420
  //#region src/client/TimelineRail.tsx
@@ -337,24 +450,47 @@ window.__ModuleLoader__.load({
337
450
  * @param props - composed props (see {@link TimelineRailProps}).
338
451
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
339
452
  */
340
- function TimelineRail({ useSession, sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t }) {
453
+ function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
341
454
  const rail = useRail((s) => s);
342
- const order = useSession((s) => s.chat.order);
343
- const nodes = useSession((s) => s.chat.nodes);
455
+ const useChatSlice = chatHookOf(standard);
456
+ const useSession = standard.useSession;
457
+ const order = useChatSlice((c) => c.order);
458
+ const nodes = useChatSlice((c) => c.nodes);
344
459
  const hasMore = useSession((s) => s.hasMore);
345
460
  const loadingOlder = useSession((s) => s.loadingOlder);
346
461
  const items = (0, react.useMemo)(() => {
462
+ let store = [];
463
+ let spans = [];
464
+ try {
465
+ store = nodes.values();
466
+ spans = foldHiddenSpans(store);
467
+ } catch {}
468
+ const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
347
469
  const result = [];
470
+ const seen = /* @__PURE__ */ new Set();
348
471
  for (const key of order) {
349
472
  const node = nodes.get(key);
350
473
  if (node === void 0) continue;
351
- const kind = node.kind;
352
- if (kind !== "user" && !(includeSteering && kind === "steering")) continue;
474
+ if (!isTimelineRowKind(node.kind, includeSteering)) continue;
475
+ if (hidden(node)) continue;
353
476
  result.push({
354
477
  key,
355
478
  node
356
479
  });
480
+ seen.add(key);
481
+ }
482
+ for (const node of store) {
483
+ if (node.kind !== "message-tools-edited" && node.kind !== "message-tools-restored") continue;
484
+ if (node.visibility === "hidden") continue;
485
+ if (seen.has(node.key)) continue;
486
+ if (hidden(node)) continue;
487
+ result.push({
488
+ key: node.key,
489
+ node
490
+ });
491
+ seen.add(node.key);
357
492
  }
493
+ result.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
358
494
  return result;
359
495
  }, [
360
496
  order,