dsh-turn-navigator 0.1.0 → 0.1.1

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,16 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.1] - 2026-08-19
4
+
5
+ ### Changed
6
+
7
+ - **History-as-data architecture (fixes stalls on very long sessions)**: the rail's turn list is now read from the HOST through the browser→host `sessions.history` RPC — every persisted turn (including ones far outside the conversation window) is shown as plain data, paged incrementally, with **zero prepends into the conversation flow on open**. Previously the rail extended the flow window by auto-clicking "Load earlier", which re-renders the whole flow per page and stalled the UI on ~150-turn sessions. Now the flow window is only extended **on demand**: clicking a capsule for a turn already in the window scrolls directly; for a turn outside the window, the rail extends the window page by page (respecting the paging button's in-flight state) until the target turn is in the window, then scrolls and highlights it.
8
+ - **Immediate jump feedback**: clicking an out-of-window capsule now shows instant feedback — the clicked capsule pulses and a "Locating turn N…" bubble appears beside it (in the DSH tooltip visual style) for the whole duration of the on-demand window extension; on failure a brief "Could not locate turn N" notice shows instead. No more silent waits.
9
+ - **Oldest-turn jumps load to the true start**: jumping to the oldest turn now extends the window until there is no more history to load (hasMore false) — previously the window could include the target's boundary while earlier events were still pending, so the jump landed before the true first turn with a "Load earlier" button remaining. Readiness is judged by the target row being actually rendered in the DOM, not just the turn being listed in the window.
10
+ - **Bilingual README switch**: README (English default) and README.zh.md now link to each other.
11
+ - **Layer stacking**: the rail's z-index is now 10 — above the conversation flow content (max 8) but below full-screen overlays like the kanban board plugin (z-index 50) — matching the header's "Session log" button tier, so an open full-screen page always paints over the rail.
12
+ - **README screenshot**: added `docs/turn-nav-rail.png` to the bilingual README (English default) as marketing, shipped in the npm package.
13
+
3
14
  ## [0.1.0] - 2026-08-19
4
15
 
5
16
  ### Added
package/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # dsh-turn-navigator
2
2
 
3
+ **English · [简体中文](README.zh.md)**
4
+
3
5
  An external [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) plugin that adds a **piano-key turn rail** to the conversation interface — a vertical column of tiny capsules on the right edge of the conversation, one per turn, so you can see every turn at a glance, hover to preview it, and click to jump to its start.
4
6
 
7
+ ![Turn navigation rail](docs/turn-nav-rail.png)
8
+
5
9
  ## Why
6
10
 
7
11
  In the default DSH web UI, finding a specific turn in a long conversation means scrolling — a lot. There's no overview of how many turns happened, what each turn was about, or where the current scroll position sits. `dsh-turn-navigator` solves this with a minimap-style rail:
@@ -9,7 +13,7 @@ In the default DSH web UI, finding a specific turn in a long conversation means
9
13
  - A **vertical capsule per turn** floats on the right edge of the conversation (grey, ~3px tall, piano-key style).
10
14
  - **Hovering** a capsule makes it glow with the theme's primary label color and widen to 150% (the two neighbours widen to 125% too), so sliding across the rail ripples like a wave — and shows the full turn info (index, timestamp, user-message summary) in a native DSH tooltip.
11
15
  - **Clicking** a capsule scrolls the conversation to that turn's start and briefly highlights it.
12
- - **Older history auto-loads**: the conversation paginates its window; the plugin keeps loading earlier pages so every turn is reachable, with no manual "Load earlier" clicks.
16
+ - **Every turn is shown as data, instantly**: the rail reads the full conversation history from the host (`sessions.history` RPC) as lightweight data — no prepends into the conversation flow, so even a 150-turn session opens without re-rendering the flow. Clicking a capsule for a turn outside the window extends the window on demand to reach it.
13
17
 
14
18
  ## Installation
15
19
 
@@ -28,8 +32,8 @@ dsh web
28
32
  1. Open any conversation with at least one completed turn.
29
33
  2. A vertical rail of grey capsules appears on the right edge of the conversation (one capsule per turn). The rail **auto-sizes**: its length grows with the turn count, capped at **30vh** — a short conversation gets a short rail, a long one hits the cap and scrolls internally with a **hidden scrollbar** (no layout jitter). **Up/down scroll buttons** at its top and bottom support both click and **hover-hold auto-scroll**, and are greyed out when there is nothing to scroll in that direction — so the rail never stretches past the viewport, and you can wheel, click, or hold to move through the turns.
30
34
  3. Hover a capsule to see the turn's index, timestamp, and user-message summary in a DSH-style tooltip anchored to the left of the rail, vertically centered on the hovered capsule and always fully inside the viewport. The capsule glows with the theme color and widens 150% LEFTWARDS (right-aligned — the right edge never moves), with the two neighbours widening a little too, a wave ripple across the rail.
31
- 4. Click a capsule to jump to that turn's start (the target row briefly highlights); the activated capsule scrolls to the center of the rail unless it is the first or last turn.
32
- 5. Older history loads automatically the rail fills its visible height first, and scrolling the rail to its top keeps loading earlier turns. No manual "Load earlier" clicks needed.
35
+ 4. Click a capsule to jump to that turn's start. If the turn is outside the conversation window, the rail shows immediate feedback — the clicked capsule pulses and a "Locating turn N…" bubble appears beside it — while the window is extended on demand; when the turn is in view it scrolls to it and briefly highlights the target row. The activated capsule also scrolls to the center of the rail unless it is the first or last turn.
36
+ 5. Every turn is visible immediately (read from history as data, without loading the flow); clicking a turn outside the conversation window loads just enough history to reach it.
33
37
 
34
38
  ## How it works
35
39
 
@@ -41,14 +45,15 @@ The plugin registers **one additive slot** — **no DSH source code is modified*
41
45
 
42
46
  Because the rail is session-scoped, it reads the live `ConversationSnapshot` straight from the framework `useSession` kit and renders as `position: fixed` (so it does not occupy the header's flex row).
43
47
 
44
- - **Turn extraction**: `chat.timeline.turnOrder` + `turns` map for boundaries; `chat.locations.getTurn(turn)` for each turn's node keys; the first `kind === 'user'` node's first text block for the summary; `turnTimings` for the timestamp. Turns without a user message fall back to their first node's kind.
48
+ - **Turn extraction**: the rail's full turn list comes from the browser→host `sessions.history` RPC (paged, incremental) every persisted turn is derived as data (`turn/start` / `user/message` / `turn/end` events → turn number, timestamp, first user-message summary), including turns far outside the conversation window. The live window snapshot (`useSession` `chat.timeline.turnOrder`) supplements the newest still-running turns. Turns without a user message fall back to their first node's kind.
45
49
  - **Jump-to-turn**: locate the turn's first chat-node key, find the DOM row via `data-chat-anchor-key="<key>"`, compute its position in the `[data-conversation-scroll]` scrollport, and set `scrollTop` precisely (more predictable than `scrollIntoView`). If the target row is not yet rendered (older page not loaded), it auto-clicks the "Load earlier" button and retries until the row appears — no "scroll once first" friction.
46
- - **Auto-load (stall-free)**: the conversation paginates its history each page prepend re-renders the whole flow, so loading everything at once would stall the UI. The rail instead (a) slowly fills its visible height after open (slow cadence, back-pressure, page cap), then (b) keeps loading earlier turns while the rail is scrolled near its top scroll-driven, the same way the conversation itself loads. Clicking a capsule also loads on demand to reach its turn. A shared busy-lock prevents the auto-load and scroll-load paths from ever clicking the paging button concurrently.
50
+ - **History-as-data (no flow prepends on open)**: the conversation window only materializes a page of events as DOM, and extending it (`loadOlder`) re-renders the whole flow expensive on long sessions. The rail instead reads the full persisted history through the browser→host `sessions.history` RPC (paged, incremental) and derives every turn as plain data, so opening a session never touches the flow DOM. **On demand**: clicking a capsule for a turn already in the window scrolls to it directly; for a turn outside the window, the rail extends the window page by page (clicking the "Load earlier" paging button, respecting its in-flight state) until that turn is in the window, then scrolls and highlights it the only path that prepends into the flow, and it runs only when the user clicks.
47
51
 
48
52
  ## Compatibility
49
53
 
50
54
  - DeepSeek Harness (dsh) with the web client (`dsh web`).
51
55
  - Requires the `conversation.session.header.utilities` slot declaration (present in current DSH).
56
+ - Coexists with full-screen plugin pages (e.g. the kanban board): the rail sits below their overlay layer, so an open page always covers it.
52
57
 
53
58
  ## License
54
59
 
package/README.zh.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # dsh-turn-navigator
2
2
 
3
+ **[English](README.md) · 简体中文**
4
+
3
5
  一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 外部插件,为会话界面增加**钢琴键式轮次胶囊条**——在会话界面右侧悬浮一条竖向胶囊列,每轮一个小胶囊,让你一眼纵览整场会话有哪几轮,悬停预览,点击跳转到任意轮次起始点。
4
6
 
7
+ ![轮次导航胶囊条](docs/turn-nav-rail.png)
8
+
5
9
  ## 为什么需要
6
10
 
7
11
  DSH Web UI 中,在长会话里找某一轮只能靠滚动——没有轮次概览,不知道有几轮、每轮说了什么、当前滚到哪里。`dsh-turn-navigator` 用类似 minimap 的胶囊条解决这个问题:
@@ -9,7 +13,7 @@ DSH Web UI 中,在长会话里找某一轮只能靠滚动——没有轮次概
9
13
  - 会话界面右侧**悬浮一条竖向胶囊列**(每轮一个灰色小胶囊,约 3px 高,钢琴键式)。
10
14
  - **悬停**某个胶囊:该胶囊以**主题色**亮起并加宽 150%,相邻两个胶囊也稍微加宽(125%)——鼠标滑过时像波浪一样起伏;同时以 DSH 原生 Tooltip 完整展示该轮信息(序号、时间戳、用户消息摘要)。
11
15
  - **点击**某个胶囊:会话滚动到该轮起点并短暂高亮。
12
- - **历史自动加载**:会话历史是分页的,插件自动持续加载更早的页面,直到全部轮次可定位,无需手动点击"加载更早"。
16
+ - **全部轮次即时展示为数据**:胶囊条通过 host 的 `sessions.history` RPC 直接读取完整会话历史(轻量数据,**不 prepend 进会话流**)——即使 150 轮的会话,打开也不重渲染会话流。点击窗口外的轮次时才按需扩展窗口。
13
17
 
14
18
  ## 安装
15
19
 
@@ -28,8 +32,8 @@ dsh web
28
32
  1. 打开任意有至少一轮完成的会话。
29
33
  2. 会话界面右侧出现一条竖向灰色胶囊列(每轮一个胶囊)。胶囊条**长度自适应**:随轮次多少自动增减,**上限 30vh**——轮次少时胶囊条短,轮次多时达到上限后内部滚动;滚动条隐藏(避免抖动)。上下各有一个**滚动按钮**:支持点击和**悬停持续自动滚动**,没有可滚动内容时自动置灰——轮次再多也不会超出浏览器范围。
30
34
  3. 悬停某个胶囊:胶囊**以主题色亮起并向左加宽 150%**(右对齐,右缘不动;相邻两个胶囊也稍微加宽——滑过时形成波浪波纹),胶囊条左侧弹出 DSH 风格 Tooltip,**垂直居中于悬停的胶囊**、始终完整在视口内,展示完整信息(序号、时间戳、用户消息摘要)。
31
- 4. 点击某个胶囊:会话滚动到该轮起点(目标行短暂高亮),**该胶囊自动滚动到胶囊条中央**(首尾两条除外)。
32
- 5. 更早的历史会自动加载——胶囊条先填充自身可视高度,**滚动胶囊条到顶部继续加载更早轮次**。无需手动点击"加载更早"。
35
+ 4. 点击某个胶囊:会话滚动到该轮起点(目标行短暂高亮),**该胶囊自动滚动到胶囊条中央**(首尾两条除外)。若目标轮在会话窗口之外,胶囊条会**立即给出反馈**——被点击的胶囊脉冲闪烁,胶囊条左侧弹出"正在定位第 N 轮…"气泡——按需扩展窗口;目标轮进入窗口后滚动定位并高亮。
36
+ 5. 所有轮次即时可见(从历史以数据形式读取,不加载会话流);点击窗口外的轮次时,只加载到达那一轮所需的历史。
33
37
 
34
38
  ## 原理
35
39
 
@@ -41,14 +45,15 @@ dsh web
41
45
 
42
46
  因为胶囊条是 session 作用域,它直接从框架 `useSession` kit 读取实时 `ConversationSnapshot`,并以 `position: fixed` 渲染(不占据 header 的 flex 行)。
43
47
 
44
- - **轮次提取**:`chat.timeline.turnOrder` + `turns` map 得到轮次边界;`chat.locations.getTurn(turn)` 取该轮 node keys;首个 `kind === 'user'` 节点的首个 text block 作摘要;`turnTimings` 取时间戳。无用户消息的轮次降级显示其首个节点的 kind。
48
+ - **轮次提取**:胶囊条的完整轮次列表来自 browser→host `sessions.history` RPC(分页、增量)——每一轮持久化轮次都以数据派生(`turn/start` / `user/message` / `turn/end` 事件 → 轮次号、时间戳、首条用户消息摘要),包括远在会话窗口之外的轮次。实时窗口快照(`useSession` `chat.timeline.turnOrder`)补充最新的运行中轮次。无用户消息的轮次降级显示其首个节点的 kind。
45
49
  - **跳转定位**:取该轮第一个 chat-node key,通过 `data-chat-anchor-key="<key>"` 找到 DOM 行,在 `[data-conversation-scroll]` 滚动容器中精确计算并设置 `scrollTop`(比 `scrollIntoView` 更可控)。若目标行尚未渲染(更早页面未加载),自动点击"加载更早"按钮并重试直到行出现——无需先滚动一下。
46
- - **自动加载(不卡顿)**:会话历史是分页的,每页 prepend 都会重渲染整个会话流,一次性全加载会卡住界面。胶囊条改为:(a) 打开后以缓慢节奏(背压 + 页数上限)先填充自身可视高度;(b) 之后**滚动胶囊条到顶部时继续加载更早轮次**(滚动驱动,与会话自身加载方式一致)。点击胶囊跳转时也会按需加载到目标轮。自动加载与滚动加载通过共享 busy 锁互斥,绝不并发点击分页按钮。
50
+ - **历史即数据(打开零 prepend)**:会话窗口只把一页事件物化为 DOM,扩展窗口(`loadOlder`)会重渲染整个会话流——长会话代价高。胶囊条改为通过 browser→host `sessions.history` RPC 分页读取完整持久化历史,把每轮派生为纯数据,**打开会话完全不触碰会话流 DOM**。**按需加载**:点击窗口内轮次直接滚动;点击窗口外轮次时,胶囊条逐页扩展窗口(点击"加载更早"分页按钮,尊重其加载中状态)直到目标轮进入窗口,再滚动并高亮——这是唯一会 prepend 会话流的路径,且只在用户点击时发生。
47
51
 
48
52
  ## 兼容性
49
53
 
50
54
  - DeepSeek Harness (dsh) Web 客户端(`dsh web`)。
51
55
  - 需要 `conversation.session.header.utilities` slot 声明(当前 DSH 已包含)。
56
+ - 与全屏插件页面(如看板)共存:胶囊条层级位于全屏 overlay 之下,打开全屏页面时会覆盖胶囊条。
52
57
 
53
58
  ## 许可证
54
59
 
Binary file
package/lib/client.js CHANGED
@@ -9,12 +9,12 @@ window.__ModuleLoader__.load({
9
9
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
10
10
  let react_jsx_runtime = require("react/jsx-runtime");
11
11
  //#region src/client/turns.ts
12
- const SUMMARY_MAX_CHARS = 80;
12
+ const SUMMARY_MAX_CHARS$1 = 80;
13
13
  /**
14
14
  * Extract the text of the first `type: 'text'` content block from a node's
15
15
  * content array.
16
16
  */
17
- function firstText(content) {
17
+ function firstText$1(content) {
18
18
  if (content === void 0) return "";
19
19
  for (const block of content) if (block.type === "text" && typeof block.text === "string") return block.text;
20
20
  return "";
@@ -52,15 +52,15 @@ window.__ModuleLoader__.load({
52
52
  if (node.kind === "user") {
53
53
  const userData = node.data;
54
54
  if (userData !== void 0) {
55
- fullText = firstText(userData.content);
56
- summary = fullText.length > SUMMARY_MAX_CHARS ? `${fullText.slice(0, 79)}…` : fullText;
55
+ fullText = firstText$1(userData.content);
56
+ summary = fullText.length > SUMMARY_MAX_CHARS$1 ? `${fullText.slice(0, 79)}…` : fullText;
57
57
  }
58
58
  break;
59
59
  }
60
60
  if (summary === "") {
61
61
  const peek = peekNodeText(node);
62
62
  fullText = peek;
63
- summary = peek.length > SUMMARY_MAX_CHARS ? `${peek.slice(0, 79)}…` : peek;
63
+ summary = peek.length > SUMMARY_MAX_CHARS$1 ? `${peek.slice(0, 79)}…` : peek;
64
64
  }
65
65
  }
66
66
  entries.push({
@@ -113,24 +113,123 @@ window.__ModuleLoader__.load({
113
113
  return snap.chat.locations.getTurn(turn)[0];
114
114
  }
115
115
  //#endregion
116
+ //#region src/client/history.ts
117
+ const SUMMARY_MAX_CHARS = 80;
118
+ /** Safety cap on history pages read (50 events each). */
119
+ const MAX_HISTORY_PAGES = 500;
120
+ function firstText(content) {
121
+ if (content === void 0) return "";
122
+ for (const block of content) if (block.type === "text" && typeof block.text === "string") return block.text;
123
+ return "";
124
+ }
125
+ function truncate(text) {
126
+ return text.length > SUMMARY_MAX_CHARS ? `${text.slice(0, 79)}…` : text;
127
+ }
128
+ /**
129
+ * Read the full persisted history of a session and derive every turn.
130
+ *
131
+ * Pages are requested newest-first (a page walks back via `beforeSeq`); all
132
+ * events are collected, sorted by seq ascending, then folded into turns.
133
+ * `onPage` is called after each page with the turns derived so far (the rail
134
+ * can render incrementally without waiting for the whole history).
135
+ *
136
+ * @param api - the browser→host sessions API.
137
+ * @param sessionId - the session to read.
138
+ * @param onPage - incremental callback (turns so far, in ascending turn order).
139
+ */
140
+ async function fetchAllTurns(api, sessionId, onPage) {
141
+ const allEvents = [];
142
+ let beforeSeq;
143
+ for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
144
+ const response = await api.sessions.history({
145
+ sessionId,
146
+ beforeSeq,
147
+ maxMessages: 50
148
+ });
149
+ if (response.result === void 0 || response.result.ok !== true) break;
150
+ const value = response.result.value;
151
+ if (value === void 0) break;
152
+ const { events, hasMore } = value;
153
+ if (events.length === 0) break;
154
+ for (const entry of events) allEvents.push(entry.event);
155
+ onPage(buildTurns(allEvents));
156
+ if (!hasMore) break;
157
+ beforeSeq = events[0].event.seq;
158
+ }
159
+ allEvents.sort((a, b) => a.seq - b.seq);
160
+ const turns = buildTurns(allEvents);
161
+ onPage(turns);
162
+ return turns;
163
+ }
164
+ /** Fold a (seq-ascending) event list into ordered turns. */
165
+ function buildTurns(events) {
166
+ const sorted = [...events].sort((a, b) => a.seq - b.seq);
167
+ const turns = [];
168
+ let current = null;
169
+ for (const event of sorted) switch (event.type) {
170
+ case "turn/start": {
171
+ if (current !== null) turns.push(closeTurn(current));
172
+ const turn = typeof event.data.turn === "number" ? event.data.turn : NaN;
173
+ if (Number.isFinite(turn)) current = {
174
+ turn,
175
+ startSeq: event.seq,
176
+ time: event.time,
177
+ summary: "",
178
+ fullText: ""
179
+ };
180
+ break;
181
+ }
182
+ case "turn/end":
183
+ if (current !== null && event.data.turn === current.turn) {
184
+ turns.push(closeTurn(current));
185
+ current = null;
186
+ }
187
+ break;
188
+ case "user/message": if (current !== null && current.summary === "") {
189
+ const text = firstText(event.data.content);
190
+ current.summary = truncate(text);
191
+ current.fullText = text;
192
+ current.time = event.time;
193
+ }
194
+ }
195
+ if (current !== null) turns.push(closeTurn(current));
196
+ turns.sort((a, b) => a.turn - b.turn);
197
+ return turns.map((turn, i) => ({
198
+ ...turn,
199
+ index: i + 1
200
+ }));
201
+ }
202
+ function closeTurn(t) {
203
+ return {
204
+ turn: t.turn,
205
+ index: 0,
206
+ summary: t.summary || "(no user message)",
207
+ fullText: t.fullText,
208
+ startTime: Number.isFinite(t.time) ? t.time : void 0,
209
+ startSeq: t.startSeq,
210
+ status: "closed"
211
+ };
212
+ }
213
+ //#endregion
116
214
  //#region src/client/TurnNavRail.tsx
117
215
  /**
118
216
  * Turn navigation rail: a vertical "piano-key" rail floating on the right
119
- * edge of the conversation. One capsule per turn (grey, ~3px tall), stacked
120
- * vertically. Hovering a capsule makes it glow white and grow (with the two
121
- * neighbours slightly raised — a wave ripple), and shows the full turn info
122
- * in a DSH Tooltip. Clicking a capsule jumps the conversation to that turn's
123
- * start.
217
+ * edge of the conversation. One capsule per turn, stacked vertically.
218
+ * Hovering a capsule makes it glow with the theme color and widen (a wave
219
+ * ripple) and shows the full turn info in a tooltip; clicking a capsule jumps
220
+ * the conversation to that turn's start.
124
221
  *
125
222
  * Registered into `conversation.session.header.utilities` (session scope), so
126
- * this component reads the live `ConversationSnapshot` directly via
127
- * `useSession` — no cross-scope bridge needed.
223
+ * this component reads the live `ConversationSnapshot` via `useSession`.
128
224
  *
129
- * Also drives "auto-load older history": the conversation paginates its
130
- * window (a "Load earlier" button appears at the top when `hasMore`); this
131
- * rail keeps clicking it until all history is loaded, so every turn is
132
- * reachable. The same load-and-retry loop backs click-to-jump when the target
133
- * row is not yet rendered.
225
+ * DATA & PERFORMANCE: the rail's turn list is read from the HOST through the
226
+ * `sessions.history` browser→host RPC every persisted turn (including ones
227
+ * far outside the conversation's window) is shown as plain data, with ZERO
228
+ * prepends into the conversation flow. The flow window is only extended
229
+ * (via the "Load earlier" paging button) on demand, when a capsule is
230
+ * clicked to jump to a turn that is not yet in the window. This keeps a very
231
+ * long conversation (hundreds of turns) responsive: opening it never re-
232
+ * renders the flow, and jumping loads only what is needed to reach the target.
134
233
  */
135
234
  /** Scrollport selector: the active conversation's scroll container. */
136
235
  const SCROLL_SELECTOR = "[data-conversation-scroll]";
@@ -138,22 +237,13 @@ window.__ModuleLoader__.load({
138
237
  const ANCHOR_ATTR = "data-chat-anchor-key";
139
238
  /** CSS class for the jump highlight flash. */
140
239
  const HIGHLIGHT_CLASS = "tn-jump-highlight";
141
- /** Loaded-but-not-found retry window for click-to-jump (ms). */
142
- const JUMP_TIMEOUT_MS = 5e3;
143
- /** Delay between auto-load button clicks lets the conversation render the
144
- * prepended page (a heavy full-flow re-render) before the next one. */
145
- const LOAD_RENDER_SETTLE_MS = 700;
146
- /** Cap on pages auto-loaded per session open. Loading the entire history of a
147
- * very long conversation at once would stall the UI; the rail fills up
148
- * first (visual cap) and the rest is loaded on-demand. */
149
- const MAX_AUTO_PAGES = 30;
150
- /** Cap on pages loaded while hunting a specific turn's row (click-to-jump). */
151
- const MAX_JUMP_PAGES = 40;
240
+ /** Delay between loadOlder clicks while expanding the window to a clicked turn. */
241
+ const LOAD_RENDER_SETTLE_MS = 900;
242
+ /** Cap on pages loaded while expanding the window to a clicked turn. */
243
+ const MAX_JUMP_PAGES = 100;
152
244
  /** Extra vertical margin when scrolling a target row into view. */
153
245
  const JUMP_MARGIN_PX = 16;
154
- /** Localized "Load earlier" paging button labels — idle AND in-flight (the
155
- * conversation swaps the label to a "loading…" copy while a page is being
156
- * fetched; we must still recognize the button so loading state is tracked). */
246
+ /** Localized "Load earlier" paging button labels — idle AND in-flight. */
157
247
  const LOAD_OLDER_TEXTS = /* @__PURE__ */ new Set([
158
248
  "加载更早",
159
249
  "Load earlier",
@@ -171,11 +261,7 @@ window.__ModuleLoader__.load({
171
261
  function findScrollport() {
172
262
  return document.querySelector(SCROLL_SELECTOR) ?? void 0;
173
263
  }
174
- /**
175
- * Find the "Load earlier" paging button inside the conversation flow, or null
176
- * when all history is already loaded (button absent) / currently loading
177
- * (button disabled).
178
- */
264
+ /** Find the "Load earlier" paging button, or null (absent / mid-flight). */
179
265
  function findLoadOlderButton() {
180
266
  const scrollport = findScrollport();
181
267
  if (scrollport === void 0) return null;
@@ -183,57 +269,20 @@ window.__ModuleLoader__.load({
183
269
  for (const btn of candidates) if (isLoadOlderButton(btn)) return btn;
184
270
  return null;
185
271
  }
186
- /**
187
- * Wait for a chat row with the given node key to appear in the DOM, driving
188
- * the "load earlier" button meanwhile (the target turn may live in a not-yet
189
- * loaded page). Resolves with the row, or null on timeout / page cap.
190
- */
191
- function waitForRow(scrollport, key, timeoutMs) {
272
+ /** Promise-based sleep. */
273
+ function sleep(ms) {
192
274
  return new Promise((resolve) => {
193
- const deadline = Date.now() + timeoutMs;
194
- let pages = 0;
195
- const check = () => {
196
- const row = scrollport.querySelector(`[${ANCHOR_ATTR}="${CSS.escape(key)}"]`);
197
- if (row !== null) {
198
- resolve(row);
199
- return;
200
- }
201
- if (Date.now() > deadline || pages >= MAX_JUMP_PAGES) {
202
- resolve(null);
203
- return;
204
- }
205
- const btn = findLoadOlderButton();
206
- if (btn !== null && !btn.disabled) {
207
- btn.click();
208
- pages += 1;
209
- }
210
- setTimeout(check, 150);
211
- };
212
- check();
275
+ setTimeout(resolve, ms);
213
276
  });
214
277
  }
215
- /**
216
- * Jump to a turn: locate its first chat-node row (auto-loading earlier
217
- * history until it renders) and scroll the conversation flow to it, with a
218
- * brief highlight flash.
219
- */
220
- async function jumpToTurn(snapshot, turn) {
221
- if (snapshot === void 0) return;
222
- const key = firstNodeKeyOfTurn(snapshot, turn);
223
- if (key === void 0) return;
224
- const scrollport = findScrollport();
225
- if (scrollport === void 0) return;
226
- const row = await waitForRow(scrollport, key, JUMP_TIMEOUT_MS);
227
- if (row === null) return;
228
- const targetTop = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top + scrollport.scrollTop;
229
- scrollport.scrollTop = Math.max(0, targetTop - JUMP_MARGIN_PX);
230
- row.classList.add(HIGHLIGHT_CLASS);
231
- setTimeout(() => row.classList.remove(HIGHLIGHT_CLASS), 1500);
278
+ /** Keep the jump-feedback bubble inside the viewport vertically. */
279
+ function clampFeedbackY(y) {
280
+ return Math.max(24, Math.min(y, window.innerHeight - 24));
232
281
  }
233
282
  /** Tooltip body for one turn: index, time, full summary. */
234
- function tooltipText(entry, index, t) {
283
+ function tooltipText(entry, t) {
235
284
  const time = formatTime(entry.startTime);
236
- const label = t("turnLabel", { n: String(index) });
285
+ const label = t("turnLabel", { n: String(entry.index) });
237
286
  const body = entry.fullText || entry.summary || t("noSummary");
238
287
  const lines = [label];
239
288
  if (time !== "") lines.push(time);
@@ -248,81 +297,42 @@ window.__ModuleLoader__.load({
248
297
  }
249
298
  /**
250
299
  * The piano-key rail. Session scope: reads the conversation snapshot directly
251
- * and renders a floating vertical capsule per turn.
300
+ * and renders a floating vertical capsule per turn (full history read from
301
+ * the host as data; the flow window is extended only on click-to-jump).
252
302
  */
253
- function TurnNavRail({ useSession, t }) {
303
+ function TurnNavRail({ useSession, sessionId, t, api }) {
254
304
  const snapshot = useSession?.((s) => s);
255
- const turns = (0, react.useMemo)(() => extractTurns(snapshot), [snapshot]);
305
+ const snapshotRef = (0, react.useRef)(snapshot);
306
+ snapshotRef.current = snapshot;
307
+ const windowTurns = (0, react.useMemo)(() => extractTurns(snapshot), [snapshot]);
308
+ const [historyTurns, setHistoryTurns] = (0, react.useState)([]);
256
309
  const [hoverIndex, setHoverIndex] = (0, react.useState)(-1);
257
310
  const [hoverY, setHoverY] = (0, react.useState)(0);
258
311
  const [tipTop, setTipTop] = (0, react.useState)(0);
259
312
  const [canScrollUp, setCanScrollUp] = (0, react.useState)(false);
260
313
  const [canScrollDown, setCanScrollDown] = (0, react.useState)(false);
261
- const autoPagesRef = (0, react.useRef)(0);
262
- const loadBusyRef = (0, react.useRef)(false);
314
+ const [jumpState, setJumpState] = (0, react.useState)(null);
263
315
  const railRef = (0, react.useRef)(null);
264
316
  const tipRef = (0, react.useRef)(null);
265
317
  const hoverScrollRef = (0, react.useRef)(null);
266
- const loadMoreOnce = (0, react.useCallback)((then) => {
267
- if (loadBusyRef.current) {
268
- then(false);
269
- return;
270
- }
271
- const btn = findLoadOlderButton();
272
- if (btn === null || btn.disabled) {
273
- then(false);
274
- return;
275
- }
276
- loadBusyRef.current = true;
277
- btn.click();
278
- setTimeout(() => {
279
- loadBusyRef.current = false;
280
- then(true);
281
- }, LOAD_RENDER_SETTLE_MS);
282
- }, []);
283
- const hasTurns = turns.length > 0;
284
318
  (0, react.useEffect)(() => {
285
- if (!hasTurns) return;
286
- autoPagesRef.current = 0;
287
- let timer = null;
288
- let stopped = false;
289
- const tick = () => {
290
- if (stopped || autoPagesRef.current >= MAX_AUTO_PAGES) return;
291
- const rail = railRef.current;
292
- if (rail !== null && rail.scrollHeight > rail.clientHeight + 2) return;
293
- if (findLoadOlderButton() === null) return;
294
- loadMoreOnce((loaded) => {
295
- if (loaded) autoPagesRef.current += 1;
296
- if (!stopped) timer = setTimeout(tick, 300);
297
- });
298
- };
299
- timer = setTimeout(tick, 300);
319
+ if (api === void 0 || sessionId === void 0) return;
320
+ let cancelled = false;
321
+ fetchAllTurns(api, sessionId, (pageTurns) => {
322
+ if (!cancelled) setHistoryTurns(pageTurns);
323
+ }).then((finalTurns) => {
324
+ if (!cancelled) setHistoryTurns(finalTurns);
325
+ });
300
326
  return () => {
301
- stopped = true;
302
- if (timer !== null) clearTimeout(timer);
327
+ cancelled = true;
303
328
  };
304
- }, [hasTurns, loadMoreOnce]);
305
- (0, react.useEffect)(() => {
306
- const rail = railRef.current;
307
- if (rail === null) return;
308
- const onScroll = () => {
309
- if (rail.scrollTop > 60) return;
310
- const btn = findLoadOlderButton();
311
- if (btn === null) return;
312
- if (loadBusyRef.current || btn.disabled) {
313
- setTimeout(onScroll, 150);
314
- return;
315
- }
316
- loadBusyRef.current = true;
317
- btn.click();
318
- setTimeout(() => {
319
- loadBusyRef.current = false;
320
- if (rail.scrollTop <= 60) setTimeout(onScroll, 150);
321
- }, LOAD_RENDER_SETTLE_MS);
322
- };
323
- rail.addEventListener("scroll", onScroll, { passive: true });
324
- return () => rail.removeEventListener("scroll", onScroll);
325
- }, [hasTurns]);
329
+ }, [api, sessionId]);
330
+ const turns = (0, react.useMemo)(() => {
331
+ if (historyTurns.length === 0) return windowTurns;
332
+ const historySet = new Set(historyTurns.map((entry) => entry.turn));
333
+ const extras = windowTurns.filter((entry) => !historySet.has(entry.turn));
334
+ return [...historyTurns, ...extras].sort((a, b) => a.turn - b.turn);
335
+ }, [historyTurns, windowTurns]);
326
336
  (0, react.useEffect)(() => {
327
337
  const rail = railRef.current;
328
338
  if (rail === null) return;
@@ -384,6 +394,64 @@ window.__ModuleLoader__.load({
384
394
  const target = btnRect.top - contentTop - (rail.clientHeight - btnRect.height) / 2;
385
395
  rail.scrollTop = Math.max(0, target);
386
396
  };
397
+ const jumpToTurn = async (turn) => {
398
+ const scrollport = findScrollport();
399
+ if (scrollport === void 0) return false;
400
+ const isOldest = turns[0]?.turn === turn;
401
+ const scrollToRow = (row) => {
402
+ const targetTop = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top + scrollport.scrollTop;
403
+ scrollport.scrollTop = Math.max(0, targetTop - JUMP_MARGIN_PX);
404
+ row.classList.add(HIGHLIGHT_CLASS);
405
+ setTimeout(() => row.classList.remove(HIGHLIGHT_CLASS), 1500);
406
+ };
407
+ for (let i = 0; i < MAX_JUMP_PAGES; i += 1) {
408
+ const snap = snapshotRef.current;
409
+ const key = snap === void 0 ? void 0 : firstNodeKeyOfTurn(snap, turn);
410
+ const row = key === void 0 ? null : scrollport.querySelector(`[${ANCHOR_ATTR}="${CSS.escape(key)}"]`);
411
+ if (row !== null) {
412
+ const more = findLoadOlderButton();
413
+ if (!isOldest || more === null) {
414
+ scrollToRow(row);
415
+ return true;
416
+ }
417
+ }
418
+ const btn = findLoadOlderButton();
419
+ if (btn === null) {
420
+ if (row !== null) {
421
+ scrollToRow(row);
422
+ return true;
423
+ }
424
+ return false;
425
+ }
426
+ if (btn.disabled) {
427
+ await sleep(150);
428
+ continue;
429
+ }
430
+ btn.click();
431
+ await sleep(LOAD_RENDER_SETTLE_MS);
432
+ }
433
+ return false;
434
+ };
435
+ const handleCapsuleClick = (turn, index, e) => {
436
+ centerCapsule(index);
437
+ const y = e.currentTarget.getBoundingClientRect().top + e.currentTarget.getBoundingClientRect().height / 2;
438
+ setJumpState({
439
+ turn,
440
+ y,
441
+ phase: "loading"
442
+ });
443
+ jumpToTurn(turn).then((ok) => {
444
+ if (ok) setJumpState(null);
445
+ else {
446
+ setJumpState({
447
+ turn,
448
+ y,
449
+ phase: "error"
450
+ });
451
+ setTimeout(() => setJumpState(null), 2500);
452
+ }
453
+ });
454
+ };
387
455
  if (turns.length === 0) return null;
388
456
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
389
457
  className: "tn-wrap",
@@ -411,19 +479,18 @@ window.__ModuleLoader__.load({
411
479
  className: "tn-rail",
412
480
  children: turns.map((entry, i) => {
413
481
  const dist = hoverIndex === -1 ? Infinity : Math.abs(i - hoverIndex);
482
+ const cls = dist === 0 ? " tn-cap-hot" : dist === 1 ? " tn-cap-warm" : "";
483
+ const loading = jumpState !== null && jumpState.phase === "loading" && jumpState.turn === entry.turn;
414
484
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
415
485
  type: "button",
416
- className: `tn-cap-btn${dist === 0 ? " tn-cap-hot" : dist === 1 ? " tn-cap-warm" : ""}`,
486
+ className: `tn-cap-btn${cls}${loading ? " tn-loading" : ""}`,
417
487
  onMouseEnter: (e) => {
418
488
  setHoverIndex(i);
419
489
  const rect = e.currentTarget.getBoundingClientRect();
420
490
  setHoverY(rect.top + rect.height / 2);
421
491
  },
422
- onClick: () => {
423
- jumpToTurn(snapshot, entry.turn);
424
- centerCapsule(i);
425
- },
426
- "aria-label": tooltipText(entry, entry.index, t).replace(/\n/g, " — "),
492
+ onClick: (e) => handleCapsuleClick(entry.turn, i, e),
493
+ "aria-label": tooltipText(entry, t).replace(/\n/g, " — "),
427
494
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "tn-cap" })
428
495
  }, entry.turn);
429
496
  })
@@ -440,12 +507,19 @@ window.__ModuleLoader__.load({
440
507
  onMouseLeave: stopHoverScroll,
441
508
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 12 })
442
509
  }),
510
+ jumpState !== null && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
511
+ className: `tn-jump-feedback${jumpState.phase === "error" ? " tn-jump-error" : ""}`,
512
+ style: { top: clampFeedbackY(jumpState.y) },
513
+ role: "status",
514
+ "aria-live": "polite",
515
+ children: jumpState.phase === "loading" ? t("locatingTurn", { n: String(jumpState.turn) }) : t("locateFailed", { n: String(jumpState.turn) })
516
+ }), document.body),
443
517
  hoverEntry !== void 0 && (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
444
518
  ref: tipRef,
445
519
  className: "tn-tip",
446
520
  style: { top: tipTop },
447
521
  role: "tooltip",
448
- children: tooltipText(hoverEntry, hoverEntry.index, t)
522
+ children: tooltipText(hoverEntry, t)
449
523
  }), document.body)
450
524
  ]
451
525
  });
@@ -466,13 +540,17 @@ window.__ModuleLoader__.load({
466
540
  const en = {
467
541
  rail: "Turn navigation",
468
542
  turnLabel: "Turn {n}",
469
- noSummary: "(no user message)"
543
+ noSummary: "(no user message)",
544
+ locatingTurn: "Locating turn {n}…",
545
+ locateFailed: "Could not locate turn {n}"
470
546
  };
471
547
  /** Chinese strings (same keys as {@link en}). */
472
548
  const zh = {
473
549
  rail: "轮次导航",
474
550
  turnLabel: "第 {n} 轮",
475
- noSummary: "(无用户消息)"
551
+ noSummary: "(无用户消息)",
552
+ locatingTurn: "正在定位第 {n} 轮…",
553
+ locateFailed: "无法定位第 {n} 轮"
476
554
  };
477
555
  //#endregion
478
556
  //#region src/client/styles.ts
@@ -489,7 +567,11 @@ window.__ModuleLoader__.load({
489
567
  const TURN_NAV_STYLES = `
490
568
  /* Wrapper: fixed column on the right edge (scroll buttons + rail + tooltip).
491
569
  The whole wrapper is pointer-events:auto so the wheel scrolls the rail
492
- anywhere on it; buttons sit above and below the rail. */
570
+ anywhere on it; buttons sit above and below the rail.
571
+ z-index is deliberately LOW (10): above the conversation flow content
572
+ (max 8) but below full-screen overlays like the kanban board plugin
573
+ (z-index 50) — same order of magnitude as the header's "Session log"
574
+ button, so an open full-screen page always paints over the rail. */
493
575
  .tn-wrap {
494
576
  position: fixed;
495
577
  right: 6px;
@@ -498,7 +580,7 @@ window.__ModuleLoader__.load({
498
580
  display: flex;
499
581
  flex-direction: column;
500
582
  align-items: center;
501
- z-index: 30;
583
+ z-index: 10;
502
584
  pointer-events: auto;
503
585
  }
504
586
  /* Up/down scroll controls at the top and bottom of the rail. Disabled (grey,
@@ -615,6 +697,53 @@ window.__ModuleLoader__.load({
615
697
  from { opacity: 0; }
616
698
  }
617
699
 
700
+ /* On-demand jump feedback: a bubble to the LEFT of the rail showing that a
701
+ turn is being located (loading, with a pulsing dot) or that locating
702
+ failed. Mirrors the tooltip visual but with a status dot. */
703
+ .tn-jump-feedback {
704
+ position: fixed;
705
+ right: 40px;
706
+ transform: translateY(-50%);
707
+ z-index: 101;
708
+ display: flex;
709
+ align-items: center;
710
+ gap: 7px;
711
+ padding: 5px 10px;
712
+ border-radius: 8px;
713
+ background: var(--dsw-alias-tooltip-bg);
714
+ color: var(--dsw-static-neutral-bluish-00);
715
+ font-size: 12px;
716
+ line-height: 18px;
717
+ white-space: nowrap;
718
+ max-width: 45vw;
719
+ pointer-events: none;
720
+ animation: tn-tooltip-in 150ms var(--ds-ease-in-out);
721
+ }
722
+ .tn-jump-feedback::before {
723
+ content: '';
724
+ flex: none;
725
+ width: 8px;
726
+ height: 8px;
727
+ border-radius: 50%;
728
+ background: currentColor;
729
+ animation: tn-loading-pulse 1s ease-in-out infinite;
730
+ }
731
+ .tn-jump-feedback.tn-jump-error {
732
+ color: var(--dsw-alias-state-error-primary);
733
+ }
734
+ .tn-jump-feedback.tn-jump-error::before {
735
+ background: var(--dsw-alias-state-error-primary);
736
+ animation: none;
737
+ }
738
+ /* The clicked capsule pulses while its turn is being located. */
739
+ .tn-cap-btn.tn-loading .tn-cap {
740
+ animation: tn-loading-pulse 900ms ease-in-out infinite;
741
+ }
742
+ @keyframes tn-loading-pulse {
743
+ 0%, 100% { opacity: 1; }
744
+ 50% { opacity: 0.25; }
745
+ }
746
+
618
747
  /* Jump highlight flash on the target row in the conversation flow. */
619
748
  @keyframes tn-highlight-flash {
620
749
  0% { background: color-mix(in srgb, var(--dsw-alias-brand-primary) 26%, transparent); }
@@ -645,7 +774,11 @@ window.__ModuleLoader__.load({
645
774
  /** Dictionary namespace owned by this plugin. */
646
775
  const NS = "dsh-turn-navigator";
647
776
  /** Required services (cordis fiber inject). */
648
- const inject = ["slots", "locale"];
777
+ const inject = [
778
+ "slots",
779
+ "locale",
780
+ "connection"
781
+ ];
649
782
  /**
650
783
  * Browser plugin body: registers the turn rail into the session header
651
784
  * utilities seat.
@@ -657,12 +790,16 @@ window.__ModuleLoader__.load({
657
790
  en
658
791
  }), "dsh-turn-navigator: copy dictionaries");
659
792
  const t = ctx.locale.bind(NS);
793
+ const api = ctx.get("connection")?.api;
660
794
  ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
661
795
  name: "conversation.session.header.utilities",
662
796
  id: "dsh-turn-navigator",
663
797
  order: 20,
664
798
  locale: NS,
665
- inject: () => ({ t })
799
+ inject: () => ({
800
+ t,
801
+ api
802
+ })
666
803
  }, TurnNavRail));
667
804
  }
668
805
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-turn-navigator",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "External DeepSeek Harness plugin: a turn-navigation drawer that lists every conversation turn (user message summary + timestamp), jumps to any turn's start, and highlights the current turn as you scroll.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -17,6 +17,7 @@
17
17
  "lib/index.js",
18
18
  "lib/client.js",
19
19
  "cordis.patch.yml",
20
+ "docs/turn-nav-rail.png",
20
21
  "README.md",
21
22
  "README.zh.md",
22
23
  "CHANGELOG.md",