dsh-turn-navigator 0.1.1 → 0.4.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,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-08-29
4
+
5
+ ### Added
6
+
7
+ - **Rail display mode (Settings → General → Turn navigation)**: three-way choice between `DSH official` (the built-in rail), `DSH STN` (this plugin's rail — default), and `Hide all`. Registered into the official `settings.general.item` seat (root scope), styled like the built-in preference rows (EnterBehaviorRow pattern), persisted browser-locally (localStorage — the official `settingsScope` store needs a Host-registered namespace that an external client-only plugin has no seam to create).
8
+ - **Subtractive takeover of the official rail**: the official built-in TurnNavigator has no off-switch, so in `DSH STN` mode it is hidden with a container-scoped stylesheet override (`body.tn-hide-official [data-conversation-scroll] nav { display: none !important }` — our rail is fixed OUTSIDE the conversation scroll container, so the rule cannot match ours), and our rail takes over the right-edge center position. `Hide all` hides both.
9
+ - **Official-rail detection fix**: the old check matched `nav[aria-label*="轮次"/"Turn navigation"]` anywhere, which also matched OUR OWN rail (same localized label) — so the rail stayed nudged into the header zone even when the official rail was absent (e.g. 1-turn sessions). The check is now scoped to `[data-conversation-scroll]` and reads the COMPUTED display value, so the nudge follows reality: official visible → we nudge; official hidden by our override → we stay centered.
10
+ - **Brand name**: DSH Smoothly Turn Nav (DSH STN) — used in the README, settings row, and changelog.
11
+
12
+ # Changelog
13
+
14
+ ## [0.3.0] - 2026-08-29
15
+
16
+ ### Added
17
+
18
+ - **Full history restored on dsh 0.1.2+ (journal channel)**: the 0.2.0 refactor removed the browser→host `sessions.history` RPC, so the rail degraded to loaded-window turns. This release restores the full-history rail in the browser with **zero host changes and zero new dependencies**: the plugin pages the same persisted log the official window reads through the Typert Remote `session/page` endpoint (`ctx.remote.session` — the namespace is mounted by the base web assembly into `ctx.get('remote.session')`, read without adding to the cordis `inject` list because `ctx.get` is the inject-free store read; the traced `ctx.get('remote').session` path would hit the "without inject" gate). Every persisted turn (including turns far outside the window) is shown as plain data, paged incrementally, with no prepends into the conversation flow on open — the same performance story as 0.1.x, now purely client-side.
19
+ - **Official-store jump path**: window expansion for out-of-window jumps now goes through the official session store (`sessions.binding(id).session.loadOlder()`), with the authoritative `hasMore` from `binding(id).eventSource.getSnapshot().hasMore` as the loop terminator (no more DOM "Load earlier" button sniffing on 0.1.2+), and row polling instead of fixed sleeps so fast renders jump immediately. The "Load earlier" button path remains as a fallback on older hosts.
20
+ - **Journal readiness wait**: the official session binding is staged a moment after the conversation view mounts; the full-history fetch now waits (bounded, 15s) for the window's seq bounds instead of silently skipping, so the rail fills with all turns shortly after open.
21
+
22
+ ### Changed
23
+
24
+ - History-channel priority: 0.1.2+ journal (`session/page`) → legacy `sessions.history` RPC → window-only turns (each layer degrades gracefully, including when `remote.session` is not yet mounted at boot — handles are resolved lazily and retried at render).
25
+
26
+ ## [0.2.0] - 2026-08-29
27
+
28
+ ### Changed
29
+
30
+ - **Adapted to dsh 0.1.2+ (ui-chat refactor)**: the conversation data model changed — `ConversationSnapshot` lost its `chat` field and the old `dsh-client-runtime` package was removed. The rail now reads turn data through the new `useChat` hook (ChatSnapshot, `navigation.items()`), falls back to the legacy `.chat`-wrapped snapshot on older dsh, and imports `ClientContext` from `@deepseek-ai/cordis` (with the `dsh-client-ui-renderer/client` merge for `ctx.slots`). This fixes the crash that hid the rail after upgrading.
31
+ - **Coexists with the official built-in TurnNavigator**: the official dsh rail (in-chat, always rendered, cannot be disabled) now overlaps-free — when it is present our rail nudges up into the header zone (`.tn-nudge`) instead of the scrollport center. Mirrored the official narrow-viewport hide (`@media max-width: 900px`).
32
+ - **Added follow-scroll active-turn highlight**: the capsule for the turn at the reading line is tinted (brand color) as you scroll — a capability the official rail has and we previously lacked.
33
+ - **History-as-data is window-scoped on 0.1.2+**: the browser→host `sessions.history` RPC (`connection.api`) was removed in the refactor, so the full-history read is unavailable in the browser on new dsh; the rail degrades to the loaded-window turns (same data as the official rail) and keeps its UI/UX (wave hover, scroll buttons, center-on-click, tooltip, jump feedback, follow highlight). Full-history support is a known follow-up (host-half route) if desired.
34
+
3
35
  ## [0.1.1] - 2026-08-19
4
36
 
5
37
  ### Changed
package/README.md CHANGED
@@ -1,19 +1,54 @@
1
- # dsh-turn-navigator
1
+ # DSH Smoothly Turn Nav (DSH STN)
2
2
 
3
3
  **English · [简体中文](README.zh.md)**
4
4
 
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.
5
+ **Every turn of your conversation, one glance away.**
6
+
7
+ DSH Smoothly Turn Nav (**DSH STN**) is an external plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) that puts a **piano-key turn rail** on the right edge of every conversation — a vertical column of tiny capsules, one per turn. It gives you a full minimap of the conversation: **every turn ever made** (not just the ones currently loaded), hover previews, click-to-jump to any turn's start, and follow-scroll highlighting. It can also **replace the official built-in turn rail**, which has no off-switch of its own.
6
8
 
7
9
  ![Turn navigation rail](docs/turn-nav-rail.png)
8
10
 
9
11
  ## Why
10
12
 
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:
12
-
13
- - A **vertical capsule per turn** floats on the right edge of the conversation (grey, ~3px tall, piano-key style).
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.
15
- - **Clicking** a capsule scrolls the conversation to that turn's start and briefly highlights it.
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
+ In the default DSH web UI, the official turn rail only shows the **currently loaded window** of turns in a long conversation, most turns are invisible until you scroll and load more. DSH STN solves this:
14
+
15
+ - **Full history at a glance** every persisted turn is shown as plain data, including turns far outside the loaded window. No scrolling, no loading, no waiting.
16
+ - **Hover to preview** the capsule glows with the theme color and widens in a wave ripple; a DSH-style tooltip shows the turn's number, time, and user-message summary.
17
+ - **Click to jump anywhere** jumps to any turn's start, even turns not yet loaded (the window is extended on demand with instant feedback).
18
+ - **Know where you are** — the current turn is highlighted as you scroll.
19
+
20
+ ## Features
21
+
22
+ | | |
23
+ |---|---|
24
+ | 🗺️ **Full-history minimap** | All turns visible immediately — read from the persisted session log as data, **zero prepends** into the conversation flow on open (long sessions stay responsive) |
25
+ | 🎹 **Piano-key design** | One capsule per turn, ~3px tall, right-aligned on the right edge; auto-sizing (up to 30vh) with an internal hidden scrollbar |
26
+ | 🌊 **Wave hover** | Hovered capsule glows with the theme color and widens 150% leftward; its two neighbours widen 125% — a ripple across the rail |
27
+ | 💬 **Rich tooltip** | Turn number, timestamp, and full user-message summary, always fully inside the viewport |
28
+ | 🎯 **Jump to any turn** | Precise `scrollTop` targeting (no `scrollIntoView` fights); out-of-window jumps extend the window on demand with a "Locating turn N…" pulse + bubble; the oldest-turn jump loads to the **true first turn** (`hasMore = false`) |
29
+ | 👁️ **Follow-scroll highlight** | The capsule of the turn at the reading line is tinted as you scroll |
30
+ | ⬆️⬇️ **Scroll buttons** | Click or hover-hold to scroll the rail; greyed out when there is nothing to scroll |
31
+ | 🎛️ **Rail mode switch** | Settings → General → *Turn navigation*: `DSH official` / `DSH STN` (default) / `Hide all` — persisted across reloads |
32
+ | 🔌 **Pure external plugin** | No DSH source code modified; no host changes; no new dependencies; read-only DOM access |
33
+
34
+ ## vs. the official DSH turn rail
35
+
36
+ The official built-in `TurnNavigator` has **no off-switch** and is always rendered in the chat view. DSH STN is the upgrade:
37
+
38
+ | Capability | DSH official rail | DSH STN |
39
+ |---|---|---|
40
+ | Turns shown | Only the **loaded window** | **Every persisted turn** (full history) |
41
+ | Jump to a turn outside the window | ❌ Not visible, not reachable | ✅ On-demand window extension + feedback |
42
+ | Long-session open performance | Window rendering | **Zero prepend** — no flow re-render, no stall |
43
+ | Follow-scroll highlight | ✅ | ✅ |
44
+ | Hover preview | Prompt + response (≤160 chars each) | Number + time + full summary tooltip |
45
+ | Wave ripple animation | ❌ | ✅ |
46
+ | Scroll buttons (click / hover-hold) | ❌ | ✅ |
47
+ | Rail height | Fixed band, compressed proportionally (≤420px) | Auto-sized (≤30vh), internal scroll |
48
+ | Narrow viewport (<900px) | Auto-hidden | Auto-hidden (mirrors official) |
49
+ | Hide / switch rail | ❌ No off-switch | ✅ Settings → General → 3 modes |
50
+ | Keyboard reachable | ✅ (focus ring) | ✅ (buttons) |
51
+ | Source | Built-in, cannot be disabled | External plugin, **can be replaced/disabled** |
17
52
 
18
53
  ## Installation
19
54
 
@@ -29,31 +64,41 @@ dsh web
29
64
 
30
65
  ## Usage
31
66
 
67
+ 0. **Choose which rail to show** (Settings → General → **Turn navigation**): `DSH official` (the built-in rail), `DSH STN` (this plugin's rail — **default**), or `Hide all`. The official rail has no off-switch, so choosing DSH STN hides it with a stylesheet override and our rail takes over the right-edge center position. The choice persists across reloads.
32
68
  1. Open any conversation with at least one completed turn.
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.
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.
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.
69
+ 2. A vertical rail of grey capsules appears on the right edge (one per turn). It **auto-sizes** — short conversations get a short rail, long ones hit the 30vh cap and scroll internally (hidden scrollbar, no layout jitter).
70
+ 3. **Hover** a capsule: it glows, widens in a wave, and shows the turn's index, timestamp, and summary in a tooltip to the left of the rail.
71
+ 4. **Click** a capsule to jump to that turn's start. Out-of-window turns pulse the capsule and show a "Locating turn N…" bubble while the window is extended on demand; the target row is highlighted on arrival. The activated capsule centers in the rail (unless it is the first or last turn).
72
+ 5. **Scroll** with the mouse wheel, the up/down buttons, or hover-hold on the buttons.
37
73
 
38
74
  ## How it works
39
75
 
40
- The plugin registers **one additive slot** — **no DSH source code is modified**:
76
+ The plugin registers **two additive slots** — **no DSH source code is modified**:
41
77
 
42
78
  | Slot | Scope | Role |
43
79
  |------|-------|------|
44
- | `conversation.session.header.utilities` | session | The floating turn rail; reads `useSession` directly |
45
-
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).
80
+ | `conversation.session.header.utilities` | session | The floating turn rail (renders `position: fixed`; reads the live chat snapshot via the framework `useChat`/`useSession` kit) |
81
+ | `settings.general.item` | root | The *Turn navigation* mode switch in Settings → General |
47
82
 
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.
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.
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.
83
+ - **Full history as data**: on dsh 0.1.2+ the rail pages the same persisted log the official window reads, through the Typert Remote `session/page` channel (`ctx.remote.session` mounted by the base web assembly; **no host changes, no new dependencies**). On older dsh it uses the `sessions.history` RPC. Every turn is derived from raw `turn/start` / `user/message` / `turn/end` events as plain data.
84
+ - **On-demand jumps**: clicking a turn already in the window scrolls directly; for a turn outside it, the rail extends the window page by page through the official session store (`loadOlder`, with the authoritative `hasMore` as the loop terminator) until the target is in view the only path that touches the flow, and it runs only when you click.
85
+ - **Precise scrolling**: the rail locates the turn's first chat-node key, finds its DOM row via `data-chat-anchor-key`, and sets the scrollport's `scrollTop` directly (more predictable than `scrollIntoView`).
86
+ - **Replacing the official rail**: the official rail lives inside the conversation scroll container; a container-scoped stylesheet rule (driven by the mode switch) hides it, and the rail takes over its right-edge center position. If a future dsh changes that structure, the worst case is the official rail reappearing (side by side) — never a crash.
87
+ - **Read-only**: the plugin never writes to DSH state, never sends data anywhere, and only reads the DOM for targeting.
51
88
 
52
89
  ## Compatibility
53
90
 
54
91
  - DeepSeek Harness (dsh) with the web client (`dsh web`).
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.
92
+ - Requires the `conversation.session.header.utilities` and `settings.general.item` slot declarations (present in current DSH).
93
+ - Default `DSH STN` mode hides the official rail (stylesheet override) and centers our rail in its place; `DSH official` mode shows the built-in rail instead; `Hide all` hides both. Both rails auto-hide below 900px width.
94
+ - Coexists with full-screen plugin pages (e.g. the kanban board): the rail sits below their overlay layer.
95
+
96
+ ## Development
97
+
98
+ - `pnpm typecheck` / `pnpm test` — TypeScript check (tsdown does not typecheck).
99
+ - `pnpm bundle` — build the module-table client bundle into `lib/`.
100
+ - `scripts/verify-*.mjs` — Playwright acceptance scripts against a live `dsh web` (rail, full-history journal, mode switch, jump, feedback, overlay, sizing, UI).
101
+ - `pnpm release:check` — release gates (version, tag, tree, build, registry).
57
102
 
58
103
  ## License
59
104
 
package/README.zh.md CHANGED
@@ -1,19 +1,54 @@
1
- # dsh-turn-navigator
1
+ # DSH Smoothly Turn Nav(DSH STN)
2
2
 
3
3
  **[English](README.md) · 简体中文**
4
4
 
5
- 一个 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 外部插件,为会话界面增加**钢琴键式轮次胶囊条**——在会话界面右侧悬浮一条竖向胶囊列,每轮一个小胶囊,让你一眼纵览整场会话有哪几轮,悬停预览,点击跳转到任意轮次起始点。
5
+ **整场会话,一眼纵览。**
6
+
7
+ DSH Smoothly Turn Nav(**DSH STN**)是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的外部插件,在每条会话右侧放一条**钢琴键式轮次胶囊条**——竖向一列小胶囊,一轮一个。它是整场会话的迷你地图:**全部历史轮次一目了然**(不只是当前已加载的窗口),悬停预览、点击跳转到任意轮次起点、滚动跟随高亮。它还可以**取代官方内置轮次胶囊条**(官方没有自己的关闭开关)。
6
8
 
7
9
  ![轮次导航胶囊条](docs/turn-nav-rail.png)
8
10
 
9
11
  ## 为什么需要
10
12
 
11
- DSH Web UI 中,在长会话里找某一轮只能靠滚动——没有轮次概览,不知道有几轮、每轮说了什么、当前滚到哪里。`dsh-turn-navigator` 用类似 minimap 的胶囊条解决这个问题:
12
-
13
- - 会话界面右侧**悬浮一条竖向胶囊列**(每轮一个灰色小胶囊,约 3px 高,钢琴键式)。
14
- - **悬停**某个胶囊:该胶囊以**主题色**亮起并加宽 150%,相邻两个胶囊也稍微加宽(125%)——鼠标滑过时像波浪一样起伏;同时以 DSH 原生 Tooltip 完整展示该轮信息(序号、时间戳、用户消息摘要)。
15
- - **点击**某个胶囊:会话滚动到该轮起点并短暂高亮。
16
- - **全部轮次即时展示为数据**:胶囊条通过 host 的 `sessions.history` RPC 直接读取完整会话历史(轻量数据,**不 prepend 进会话流**)——即使 150 轮的会话,打开也不重渲染会话流。点击窗口外的轮次时才按需扩展窗口。
13
+ DSH Web UI 的官方轮次胶囊条只显示**当前已加载窗口内**的轮次——长会话里,大部分轮次要滚动加载后才可见。DSH STN 解决这个问题:
14
+
15
+ - **全量历史一眼可见**——所有持久化轮次以纯数据呈现,包括远在已加载窗口之外的轮次。不滚动、不加载、不等待。
16
+ - **悬停即预览**——胶囊以主题色亮起并泛起波浪涟漪;DSH 风格 Tooltip 展示该轮序号、时间与用户消息摘要。
17
+ - **点击跳转任意轮**——包括尚未加载的轮次(按需扩展窗口,带即时反馈)。
18
+ - **随时知道自己在哪**——滚动时当前轮次高亮。
19
+
20
+ ## 特性
21
+
22
+ | | |
23
+ |---|---|
24
+ | 🗺️ **全量历史迷你地图** | 所有轮次立即可见——从持久化会话日志以数据读取,**打开会话零 prepend**(长会话保持流畅) |
25
+ | 🎹 **钢琴键设计** | 每轮一个约 3px 高的胶囊,右缘右对齐;长度自适应(上限 30vh),超出后内部滚动(滚动条隐藏) |
26
+ | 🌊 **波浪悬停** | 悬停的胶囊以主题色亮起并向左加宽 150%,相邻两个加宽 125%——滑过时如波浪起伏 |
27
+ | 💬 **丰富 Tooltip** | 轮次序号、时间戳、完整用户消息摘要,始终完整显示在视口内 |
28
+ | 🎯 **跳转任意轮** | 精确 `scrollTop` 定位(不与 `scrollIntoView` 打架);窗口外跳转按需扩展窗口并显示"正在定位第 N 轮…"脉冲+气泡;最老轮次跳转加载到**真正第一轮**(`hasMore = false`) |
29
+ | 👁️ **滚动跟随高亮** | 阅读线所在轮次的胶囊随滚动点亮 |
30
+ | ⬆️⬇️ **滚动按钮** | 点击或悬停持续滚动;无可滚动内容时置灰 |
31
+ | 🎛️ **胶囊条模式开关** | 设置 → 通用 → *轮次导航*:`DSH 官方` / `DSH STN`(默认)/ `全部隐藏`——跨刷新持久保存 |
32
+ | 🔌 **纯外部插件** | 不改 DSH 源码;零宿主改动;零新增依赖;仅只读 DOM |
33
+
34
+ ## 与官方 DSH 轮次胶囊条对比
35
+
36
+ 官方内置 `TurnNavigator` **没有关闭开关**,始终渲染在会话视图里。DSH STN 是它的升级版:
37
+
38
+ | 能力 | DSH 官方胶囊条 | DSH STN |
39
+ |---|---|---|
40
+ | 显示的轮次 | 仅**已加载窗口** | **全部持久化轮次**(全量历史) |
41
+ | 跳转窗口外轮次 | ❌ 看不到也跳不了 | ✅ 按需扩展窗口 + 即时反馈 |
42
+ | 长会话打开性能 | 窗口渲染 | **零 prepend**——不重渲染会话流、不卡顿 |
43
+ | 滚动跟随高亮 | ✅ | ✅ |
44
+ | 悬停预览 | prompt + response(各 ≤160 字符) | 序号 + 时间 + 完整摘要 Tooltip |
45
+ | 波浪涟漪动画 | ❌ | ✅ |
46
+ | 滚动按钮(点击 / 悬停持续) | ❌ | ✅ |
47
+ | 胶囊条高度 | 固定区域按比例压缩(≤420px) | 自适应(≤30vh)+ 内部滚动 |
48
+ | 窄窗口(<900px) | 自动隐藏 | 自动隐藏(与官方对齐) |
49
+ | 隐藏 / 切换胶囊条 | ❌ 无开关 | ✅ 设置 → 通用 → 三档 |
50
+ | 键盘可达 | ✅(焦点环) | ✅(按钮) |
51
+ | 来源 | 内置、无法关闭 | 外部插件,**可替换 / 可关闭** |
17
52
 
18
53
  ## 安装
19
54
 
@@ -29,31 +64,41 @@ dsh web
29
64
 
30
65
  ## 使用
31
66
 
32
- 1. 打开任意有至少一轮完成的会话。
33
- 2. 会话界面右侧出现一条竖向灰色胶囊列(每轮一个胶囊)。胶囊条**长度自适应**:随轮次多少自动增减,**上限 30vh**——轮次少时胶囊条短,轮次多时达到上限后内部滚动;滚动条隐藏(避免抖动)。上下各有一个**滚动按钮**:支持点击和**悬停持续自动滚动**,没有可滚动内容时自动置灰——轮次再多也不会超出浏览器范围。
34
- 3. 悬停某个胶囊:胶囊**以主题色亮起并向左加宽 150%**(右对齐,右缘不动;相邻两个胶囊也稍微加宽——滑过时形成波浪波纹),胶囊条左侧弹出 DSH 风格 Tooltip,**垂直居中于悬停的胶囊**、始终完整在视口内,展示完整信息(序号、时间戳、用户消息摘要)。
35
- 4. 点击某个胶囊:会话滚动到该轮起点(目标行短暂高亮),**该胶囊自动滚动到胶囊条中央**(首尾两条除外)。若目标轮在会话窗口之外,胶囊条会**立即给出反馈**——被点击的胶囊脉冲闪烁,胶囊条左侧弹出"正在定位第 N 轮…"气泡——按需扩展窗口;目标轮进入窗口后滚动定位并高亮。
36
- 5. 所有轮次即时可见(从历史以数据形式读取,不加载会话流);点击窗口外的轮次时,只加载到达那一轮所需的历史。
67
+ 0. **选择显示哪个胶囊条**(设置 → 通用 → **轮次导航**):`DSH 官方`(内置 rail)、`DSH STN`(本插件 rail——**默认**)、或`全部隐藏`。官方 rail 没有关闭开关,选择 DSH STN 时以样式覆盖将其隐藏,我们的 rail 接管右缘居中位置。选择会跨刷新持久保存。
68
+ 1. 打开任意包含至少一轮已完成轮次的会话。
69
+ 2. 会话右侧出现一条竖向灰色胶囊列(每轮一个)。胶囊条**长度自适应**:轮次少则短,轮次多则达 30vh 上限后内部滚动(滚动条隐藏,无布局抖动)。
70
+ 3. **悬停**某个胶囊:它以主题色亮起并泛起波浪、向左加宽,胶囊条左侧弹出 Tooltip(序号、时间、摘要),始终完整在视口内。
71
+ 4. **点击**某个胶囊:会话滚动到该轮起点并短暂高亮目标行;窗口外轮次会先脉冲闪烁 + 弹出"正在定位第 N 轮…"气泡,按需扩展窗口后定位。被点击的胶囊自动滚动到胶囊条中央(首尾两条除外)。
72
+ 5. **滚动**:滚轮、上下按钮、或按住按钮持续滚动。
37
73
 
38
74
  ## 原理
39
75
 
40
- 插件只注册**一个加法 slot**——**不修改 DSH 源码**:
76
+ 插件注册**两个加法 slot**——**不修改 DSH 源码**:
41
77
 
42
78
  | Slot | 作用域 | 职责 |
43
79
  |------|--------|------|
44
- | `conversation.session.header.utilities` | session | 悬浮轮次胶囊条;直接通过 `useSession` 读取 |
45
-
46
- 因为胶囊条是 session 作用域,它直接从框架 `useSession` kit 读取实时 `ConversationSnapshot`,并以 `position: fixed` 渲染(不占据 header 的 flex 行)。
80
+ | `conversation.session.header.utilities` | session | 悬浮轮次胶囊条(`position: fixed`;通过框架 `useChat`/`useSession` kit 读取实时会话快照) |
81
+ | `settings.general.item` | root | 设置 → 通用 的 *轮次导航* 模式开关 |
47
82
 
48
- - **轮次提取**:胶囊条的完整轮次列表来自 browser→host `sessions.history` RPC(分页、增量)——每一轮持久化轮次都以数据派生(`turn/start` / `user/message` / `turn/end` 事件 → 轮次号、时间戳、首条用户消息摘要),包括远在会话窗口之外的轮次。实时窗口快照(`useSession` → `chat.timeline.turnOrder`)补充最新的运行中轮次。无用户消息的轮次降级显示其首个节点的 kind。
49
- - **跳转定位**:取该轮第一个 chat-node key,通过 `data-chat-anchor-key="<key>"` 找到 DOM 行,在 `[data-conversation-scroll]` 滚动容器中精确计算并设置 `scrollTop`(比 `scrollIntoView` 更可控)。若目标行尚未渲染(更早页面未加载),自动点击"加载更早"按钮并重试直到行出现——无需先滚动一下。
50
- - **历史即数据(打开零 prepend)**:会话窗口只把一页事件物化为 DOM,扩展窗口(`loadOlder`)会重渲染整个会话流——长会话代价高。胶囊条改为通过 browser→host 的 `sessions.history` RPC 分页读取完整持久化历史,把每轮派生为纯数据,**打开会话完全不触碰会话流 DOM**。**按需加载**:点击窗口内轮次直接滚动;点击窗口外轮次时,胶囊条逐页扩展窗口(点击"加载更早"分页按钮,尊重其加载中状态)直到目标轮进入窗口,再滚动并高亮——这是唯一会 prepend 会话流的路径,且只在用户点击时发生。
83
+ - **全量历史即数据**:dsh 0.1.2+ 上,胶囊条通过 Typert Remote `session/page` 通道分页读取与官方窗口同一条持久化日志(`ctx.remote.session`——由基础 web 装配挂载;**零宿主改动、零新增依赖**);旧版 dsh 走 `sessions.history` RPC。每轮从 `turn/start` / `user/message` / `turn/end` 原始事件派生为纯数据。
84
+ - **按需跳转**:点击窗口内轮次直接滚动;窗口外轮次通过官方会话 store `loadOlder()` 逐页扩展窗口(以权威 `hasMore` 为终止条件)直到目标进入视图——这是唯一触碰会话流的路径,且只在点击时发生。
85
+ - **精确定位**:取该轮第一个 chat-node key,通过 `data-chat-anchor-key` 找到 DOM 行,直接设置滚动容器 `scrollTop`(比 `scrollIntoView` 更可控)。
86
+ - **取代官方胶囊条**:官方 rail 位于会话滚动容器内;模式开关驱动的容器限定样式规则将其隐藏,我们的 rail 接管右缘居中位置。若未来 dsh 改变该结构,最坏情况是官方 rail 重新出现(并存)——绝不会崩溃。
87
+ - **只读**:插件从不写入 DSH 状态、从不外发数据,仅读取 DOM 用于定位。
51
88
 
52
89
  ## 兼容性
53
90
 
54
91
  - DeepSeek Harness (dsh) Web 客户端(`dsh web`)。
55
- - 需要 `conversation.session.header.utilities` slot 声明(当前 DSH 已包含)。
56
- - 与全屏插件页面(如看板)共存:胶囊条层级位于全屏 overlay 之下,打开全屏页面时会覆盖胶囊条。
92
+ - 需要 `conversation.session.header.utilities` 与 `settings.general.item` slot 声明(当前 DSH 已包含)。
93
+ - 默认 `DSH STN` 模式以样式覆盖隐藏官方 rail,我们的 rail 居中接管;`DSH 官方` 模式显示内置 rail;`全部隐藏` 两者皆隐。900px 以下都自动隐藏。
94
+ - 与全屏插件页面(如看板)共存:胶囊条层级位于全屏 overlay 之下。
95
+
96
+ ## 开发
97
+
98
+ - `pnpm typecheck` / `pnpm test` — TypeScript 检查(tsdown 只转译不检查)。
99
+ - `pnpm bundle` — 构建模块表 client bundle 到 `lib/`。
100
+ - `scripts/verify-*.mjs` — 针对真实 `dsh web` 的 Playwright 验收脚本(rail、全量历史 journal、模式开关、跳转、反馈、overlay、尺寸、UI)。
101
+ - `pnpm release:check` — 发布门禁(版本、tag、工作树、构建、registry)。
57
102
 
58
103
  ## 许可证
59
104
 
Binary file
package/lib/client.js CHANGED
@@ -20,19 +20,37 @@ window.__ModuleLoader__.load({
20
20
  return "";
21
21
  }
22
22
  /**
23
- * Derive the flat turn list from a conversation snapshot.
23
+ * Derive the flat turn list from a chat snapshot.
24
24
  *
25
25
  * For each turn in `timeline.turnOrder`, the function looks up the turn's
26
26
  * chat-node keys via `locations.getTurn`, finds the first node whose `kind`
27
27
  * is `'user'`, and extracts the text summary from its content blocks. The
28
- * timestamp comes from `turnTimings` (preferred) or `turn.start.time`.
28
+ * timestamp comes from `legacy.turnTimings` (preferred) or `turn.start.time`.
29
29
  *
30
- * @param snap - the conversation snapshot (structural subset).
30
+ * Accepts BOTH the new (0.1.2+) ChatSnapshot (from `useChat`) and the legacy
31
+ * `ConversationSnapshot` (which had a `.chat` field) — so an upgrade never
32
+ * crashes if the host is still on the older shape.
33
+ *
34
+ * @param snap - the chat snapshot (structural subset).
31
35
  * @returns ordered turn entries (empty when the snapshot has no turns).
32
36
  */
33
37
  function extractTurns(snap) {
34
- if (snap === void 0) return [];
35
- const chat = snap.chat;
38
+ const chat = unwrapChat(snap);
39
+ if (chat === void 0) return [];
40
+ const turnTimings = chat.legacy?.turnTimings ?? (snap !== void 0 && "turnTimings" in snap ? snap.turnTimings : void 0);
41
+ const navItems = chat.navigation?.items?.() ?? [];
42
+ if (navItems.length > 0) return navItems.map((item, index) => {
43
+ const loc = chat.timeline.turns.get(item.turn);
44
+ const status = loc?.status ?? "closed";
45
+ return {
46
+ turn: item.turn,
47
+ index: index + 1,
48
+ summary: item.prompt || "(no user message)",
49
+ fullText: item.prompt || "",
50
+ startTime: turnTimings?.get(item.turn)?.startTime ?? loc?.start?.time,
51
+ status
52
+ };
53
+ });
36
54
  const timeline = chat.timeline;
37
55
  const turnOrder = timeline.turnOrder;
38
56
  if (turnOrder.length === 0) return [];
@@ -42,7 +60,7 @@ window.__ModuleLoader__.load({
42
60
  displayIndex += 1;
43
61
  const loc = timeline.turns.get(turn);
44
62
  const status = loc?.status ?? "unknown";
45
- const startTime = snap.turnTimings.get(turn)?.startTime ?? loc?.start?.time;
63
+ const startTime = turnTimings?.get(turn)?.startTime ?? loc?.start?.time;
46
64
  let summary = "";
47
65
  let fullText = "";
48
66
  const keys = chat.locations.getTurn(turn);
@@ -74,6 +92,12 @@ window.__ModuleLoader__.load({
74
92
  }
75
93
  return entries;
76
94
  }
95
+ /** Narrow either the new ChatSnapshot or the legacy `.chat`-wrapped snapshot to a chat. */
96
+ function unwrapChat(snap) {
97
+ if (snap === void 0) return void 0;
98
+ if ("chat" in snap && snap.chat !== void 0) return snap.chat;
99
+ return snap;
100
+ }
77
101
  /** Best-effort text peek from a non-user chat node's data (erased shape). */
78
102
  function peekNodeText(node) {
79
103
  const data = node.data;
@@ -109,14 +133,36 @@ window.__ModuleLoader__.load({
109
133
  * @returns the first node key in that turn (typically the user message), or undefined.
110
134
  */
111
135
  function firstNodeKeyOfTurn(snap, turn) {
112
- if (snap === void 0) return void 0;
113
- return snap.chat.locations.getTurn(turn)[0];
136
+ const chat = unwrapChat(snap);
137
+ if (chat === void 0) return void 0;
138
+ return chat.locations.getTurn(turn)[0];
139
+ }
140
+ /**
141
+ * Reverse-lookup: given a chat-node key, find which turn it belongs to.
142
+ *
143
+ * Used by the scroll-follow highlight: the scroll listener finds the topmost
144
+ * visible `[data-chat-anchor-key]` row, then this function maps its key back
145
+ * to a turn number so the drawer can highlight the matching entry.
146
+ *
147
+ * @param snap - the conversation snapshot.
148
+ * @param key - the chat-node key from the DOM anchor.
149
+ * @returns the owning turn number, or undefined if not found.
150
+ */
151
+ function turnOfNodeKey(snap, key) {
152
+ const chat = unwrapChat(snap);
153
+ if (chat === void 0) return void 0;
154
+ const { turnOrder, turns } = chat.timeline;
155
+ for (const turn of turnOrder) if (chat.locations.getTurn(turn).includes(key)) return turn;
114
156
  }
115
157
  //#endregion
116
158
  //#region src/client/history.ts
117
159
  const SUMMARY_MAX_CHARS = 80;
118
160
  /** Safety cap on history pages read (50 events each). */
119
161
  const MAX_HISTORY_PAGES = 500;
162
+ /** Journal page size in MESSAGES (user/assistant count) — no host cap, fewer round trips. */
163
+ const JOURNAL_PAGE_MESSAGES = 200;
164
+ /** Safety cap on journal pages read. */
165
+ const MAX_JOURNAL_PAGES = 100;
120
166
  function firstText(content) {
121
167
  if (content === void 0) return "";
122
168
  for (const block of content) if (block.type === "text" && typeof block.text === "string") return block.text;
@@ -138,6 +184,10 @@ window.__ModuleLoader__.load({
138
184
  * @param onPage - incremental callback (turns so far, in ascending turn order).
139
185
  */
140
186
  async function fetchAllTurns(api, sessionId, onPage) {
187
+ if (api === void 0 || typeof api.sessions?.history !== "function") {
188
+ console.warn("[dsh-turn-nav] sessions.history RPC unavailable — falling back to window-only turns");
189
+ return [];
190
+ }
141
191
  const allEvents = [];
142
192
  let beforeSeq;
143
193
  for (let page = 0; page < MAX_HISTORY_PAGES; page += 1) {
@@ -146,7 +196,10 @@ window.__ModuleLoader__.load({
146
196
  beforeSeq,
147
197
  maxMessages: 50
148
198
  });
149
- if (response.result === void 0 || response.result.ok !== true) break;
199
+ if (response.result === void 0 || response.result.ok !== true) {
200
+ console.warn("[dsh-turn-nav] sessions.history page failed", response.error?.code ?? "no result");
201
+ break;
202
+ }
150
203
  const value = response.result.value;
151
204
  if (value === void 0) break;
152
205
  const { events, hasMore } = value;
@@ -161,6 +214,59 @@ window.__ModuleLoader__.load({
161
214
  onPage(turns);
162
215
  return turns;
163
216
  }
217
+ /**
218
+ * Read the FULL persisted history through the 0.1.2+ journal channel
219
+ * (`ctx.remote.session.page`), newest page first, walking back via `beforeSeq`
220
+ * until `hasMore` is false. Only events BELOW the loaded window are fetched
221
+ * (the window itself already covers the tail), so the conversation flow is
222
+ * never touched — this is what keeps very long sessions responsive.
223
+ *
224
+ * Returns `undefined` when the journal channel is unavailable (older dsh), so
225
+ * the caller can fall back to the legacy `sessions.history` RPC or to
226
+ * window-only turns.
227
+ *
228
+ * @param journal - the mounted journal namespace face, or undefined.
229
+ * @param sessionId - the session to read.
230
+ * @param window - oldest/newest event seq of the currently loaded window.
231
+ * @param onPage - incremental callback (turns derived so far, ascending).
232
+ * @param signal - optional caller cancellation.
233
+ */
234
+ async function fetchJournalTurns(journal, sessionId, window, onPage, signal) {
235
+ if (journal === void 0 || typeof journal.page !== "function") return void 0;
236
+ if (window.lastSeq === void 0 || window.firstSeq === 0) return [];
237
+ const before0 = window.firstSeq !== void 0 && window.firstSeq > 0 ? window.firstSeq - 1 : void 0;
238
+ if (before0 === void 0) return [];
239
+ const allEvents = [];
240
+ let beforeSeq = before0;
241
+ for (let page = 0; page < MAX_JOURNAL_PAGES; page += 1) {
242
+ if (signal?.aborted) break;
243
+ const result = await journal.page({
244
+ address: {
245
+ kind: "session",
246
+ sessionId
247
+ },
248
+ throughSeq: window.lastSeq,
249
+ beforeSeq,
250
+ maxMessages: JOURNAL_PAGE_MESSAGES
251
+ }, signal);
252
+ if (!result.ok || result.value === void 0) {
253
+ console.warn("[dsh-turn-nav] session/page failed", result.error?.code ?? "no result");
254
+ break;
255
+ }
256
+ const { records, hasMore } = result.value;
257
+ if (records.length === 0) break;
258
+ for (const record of records) if (record.type === "event") allEvents.push(record.event);
259
+ onPage(buildTurns(allEvents));
260
+ if (!hasMore) break;
261
+ let minSeq = Infinity;
262
+ for (const record of records) minSeq = Math.min(minSeq, record.event.seq);
263
+ if (!Number.isFinite(minSeq)) break;
264
+ beforeSeq = minSeq;
265
+ }
266
+ const turns = buildTurns(allEvents);
267
+ onPage(turns);
268
+ return turns;
269
+ }
164
270
  /** Fold a (seq-ascending) event list into ordered turns. */
165
271
  function buildTurns(events) {
166
272
  const sorted = [...events].sort((a, b) => a.seq - b.seq);
@@ -211,6 +317,61 @@ window.__ModuleLoader__.load({
211
317
  };
212
318
  }
213
319
  //#endregion
320
+ //#region src/client/mode.ts
321
+ /** localStorage key owning the persisted mode. */
322
+ const STORAGE_KEY = "dsh-turn-navigator.mode";
323
+ /** Default mode: our rail, official rail hidden (subtractive takeover). */
324
+ const DEFAULT_MODE = "stn";
325
+ const MODES = [
326
+ "stn",
327
+ "official",
328
+ "hidden"
329
+ ];
330
+ /** Module-level current mode (stable reference for useSyncExternalStore). */
331
+ let current = readStored();
332
+ const listeners = /* @__PURE__ */ new Set();
333
+ function readStored() {
334
+ try {
335
+ const raw = window.localStorage.getItem(STORAGE_KEY);
336
+ if (MODES.includes(raw)) return raw;
337
+ } catch {}
338
+ return DEFAULT_MODE;
339
+ }
340
+ /** Read the current rail mode (useSyncExternalStore getSnapshot). */
341
+ function getRailMode() {
342
+ return current;
343
+ }
344
+ /** Subscribe to rail-mode changes (useSyncExternalStore subscribe). */
345
+ function subscribeRailMode(listener) {
346
+ listeners.add(listener);
347
+ return () => {
348
+ listeners.delete(listener);
349
+ };
350
+ }
351
+ /** Persist and publish a new rail mode. */
352
+ function setRailMode(mode) {
353
+ if (mode === current) return;
354
+ current = mode;
355
+ try {
356
+ window.localStorage.setItem(STORAGE_KEY, mode);
357
+ } catch {}
358
+ applyModeToBody();
359
+ for (const listener of [...listeners]) try {
360
+ listener();
361
+ } catch {}
362
+ }
363
+ /**
364
+ * Sync the `tn-hide-official` body class with the current mode. The class
365
+ * drives the stylesheet rule that hides the OFFICIAL rail (our rail is hidden
366
+ * by React instead). The official rail is hidden in every mode except
367
+ * `official` (where the user chose to see it). Called at plugin apply (body
368
+ * exists by then) and on every mode change.
369
+ */
370
+ function applyModeToBody() {
371
+ if (typeof document === "undefined") return;
372
+ document.body.classList.toggle("tn-hide-official", current !== "official");
373
+ }
374
+ //#endregion
214
375
  //#region src/client/TurnNavRail.tsx
215
376
  /**
216
377
  * Turn navigation rail: a vertical "piano-key" rail floating on the right
@@ -241,6 +402,8 @@ window.__ModuleLoader__.load({
241
402
  const LOAD_RENDER_SETTLE_MS = 900;
242
403
  /** Cap on pages loaded while expanding the window to a clicked turn. */
243
404
  const MAX_JUMP_PAGES = 100;
405
+ /** Bounded wait for the official session binding/window to appear on mount. */
406
+ const JOURNAL_BINDING_WAIT_MS = 15e3;
244
407
  /** Extra vertical margin when scrolling a target row into view. */
245
408
  const JUMP_MARGIN_PX = 16;
246
409
  /** Localized "Load earlier" paging button labels — idle AND in-flight. */
@@ -300,11 +463,12 @@ window.__ModuleLoader__.load({
300
463
  * and renders a floating vertical capsule per turn (full history read from
301
464
  * the host as data; the flow window is extended only on click-to-jump).
302
465
  */
303
- function TurnNavRail({ useSession, sessionId, t, api }) {
304
- const snapshot = useSession?.((s) => s);
305
- const snapshotRef = (0, react.useRef)(snapshot);
306
- snapshotRef.current = snapshot;
307
- const windowTurns = (0, react.useMemo)(() => extractTurns(snapshot), [snapshot]);
466
+ function TurnNavRail({ useSession, useChat, sessionId, t, api, journal, sessionAccess }) {
467
+ const legacySession = useSession?.((s) => s);
468
+ const chat = useChat?.((c) => c);
469
+ const chatRef = (0, react.useRef)(chat ?? legacySession);
470
+ chatRef.current = chat ?? legacySession;
471
+ const windowTurns = (0, react.useMemo)(() => extractTurns(chat ?? legacySession), [chat, legacySession]);
308
472
  const [historyTurns, setHistoryTurns] = (0, react.useState)([]);
309
473
  const [hoverIndex, setHoverIndex] = (0, react.useState)(-1);
310
474
  const [hoverY, setHoverY] = (0, react.useState)(0);
@@ -312,27 +476,112 @@ window.__ModuleLoader__.load({
312
476
  const [canScrollUp, setCanScrollUp] = (0, react.useState)(false);
313
477
  const [canScrollDown, setCanScrollDown] = (0, react.useState)(false);
314
478
  const [jumpState, setJumpState] = (0, react.useState)(null);
479
+ const [officialRail, setOfficialRail] = (0, react.useState)(false);
315
480
  const railRef = (0, react.useRef)(null);
316
481
  const tipRef = (0, react.useRef)(null);
317
482
  const hoverScrollRef = (0, react.useRef)(null);
318
483
  (0, react.useEffect)(() => {
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);
484
+ let timer = null;
485
+ const check = () => {
486
+ const scroll = document.querySelector("[data-conversation-scroll]");
487
+ const nav = scroll === null ? null : scroll.querySelector("nav[aria-label*=\"轮次\"], nav[aria-label*=\"Turn navigation\"]");
488
+ const visible = nav !== null && getComputedStyle(nav).display !== "none";
489
+ setOfficialRail((prev) => prev === visible ? prev : visible);
490
+ };
491
+ check();
492
+ timer = setInterval(check, 1500);
493
+ const scroll = document.querySelector("[data-conversation-scroll]");
494
+ const observer = scroll !== null && typeof MutationObserver !== "undefined" ? new MutationObserver(check) : null;
495
+ if (observer !== null && scroll !== null) observer.observe(scroll, {
496
+ childList: true,
497
+ subtree: true
325
498
  });
499
+ return () => {
500
+ if (timer !== null) clearInterval(timer);
501
+ observer?.disconnect();
502
+ };
503
+ }, []);
504
+ (0, react.useEffect)(() => {
505
+ if (sessionId === void 0) return;
506
+ let cancelled = false;
507
+ const applyPage = (turns) => {
508
+ if (!cancelled) setHistoryTurns(turns);
509
+ };
510
+ const run = async () => {
511
+ let window = sessionAccess?.windowSeq(sessionId) ?? {
512
+ firstSeq: void 0,
513
+ lastSeq: void 0
514
+ };
515
+ if (sessionAccess !== void 0) {
516
+ const deadline = Date.now() + JOURNAL_BINDING_WAIT_MS;
517
+ while (window.lastSeq === void 0 && Date.now() < deadline) {
518
+ await sleep(300);
519
+ if (cancelled) return;
520
+ window = sessionAccess.windowSeq(sessionId);
521
+ }
522
+ }
523
+ const journalTurns = await fetchJournalTurns(journal, sessionId, window, applyPage);
524
+ if (cancelled) return;
525
+ if (journalTurns !== void 0) {
526
+ setHistoryTurns(journalTurns);
527
+ return;
528
+ }
529
+ if (api !== void 0) await fetchAllTurns(api, sessionId, applyPage).then((finalTurns) => {
530
+ if (!cancelled) setHistoryTurns(finalTurns);
531
+ });
532
+ };
533
+ run();
326
534
  return () => {
327
535
  cancelled = true;
328
536
  };
329
- }, [api, sessionId]);
537
+ }, [
538
+ api,
539
+ journal,
540
+ sessionAccess,
541
+ sessionId
542
+ ]);
543
+ const railMode = (0, react.useSyncExternalStore)(subscribeRailMode, getRailMode);
330
544
  const turns = (0, react.useMemo)(() => {
331
545
  if (historyTurns.length === 0) return windowTurns;
332
546
  const historySet = new Set(historyTurns.map((entry) => entry.turn));
333
547
  const extras = windowTurns.filter((entry) => !historySet.has(entry.turn));
334
548
  return [...historyTurns, ...extras].sort((a, b) => a.turn - b.turn);
335
549
  }, [historyTurns, windowTurns]);
550
+ const [activeTurn, setActiveTurn] = (0, react.useState)(null);
551
+ (0, react.useEffect)(() => {
552
+ const scroll = document.querySelector("[data-conversation-scroll]");
553
+ if (scroll === null) return;
554
+ let frame = null;
555
+ const compute = () => {
556
+ frame = null;
557
+ const scrollport = document.querySelector("[data-conversation-scroll]");
558
+ if (scrollport === null) return;
559
+ const rect = scrollport.getBoundingClientRect();
560
+ const readingLine = rect.top + Math.min(96, rect.height * .2);
561
+ const rows = Array.from(scrollport.querySelectorAll("[data-chat-anchor-key]"));
562
+ let row = null;
563
+ for (const r of rows) {
564
+ const rr = r.getBoundingClientRect();
565
+ if (rr.bottom > readingLine && rr.top < rect.bottom) {
566
+ row = r;
567
+ break;
568
+ }
569
+ }
570
+ const key = row?.getAttribute("data-chat-anchor-key") ?? null;
571
+ if (key !== null) setActiveTurn(turnOfNodeKey(chatRef.current, key) ?? null);
572
+ else if (rows.length > 0) setActiveTurn(null);
573
+ };
574
+ const schedule = () => {
575
+ if (frame !== null) return;
576
+ frame = requestAnimationFrame(compute);
577
+ };
578
+ scroll.addEventListener("scroll", schedule, { passive: true });
579
+ schedule();
580
+ return () => {
581
+ scroll.removeEventListener("scroll", schedule);
582
+ if (frame !== null) cancelAnimationFrame(frame);
583
+ };
584
+ }, [turns.length]);
336
585
  (0, react.useEffect)(() => {
337
586
  const rail = railRef.current;
338
587
  if (rail === null) return;
@@ -404,16 +653,51 @@ window.__ModuleLoader__.load({
404
653
  row.classList.add(HIGHLIGHT_CLASS);
405
654
  setTimeout(() => row.classList.remove(HIGHLIGHT_CLASS), 1500);
406
655
  };
656
+ const waitForRow = async (timeoutMs) => {
657
+ const deadline = Date.now() + timeoutMs;
658
+ while (Date.now() < deadline) {
659
+ const snap = chatRef.current;
660
+ const key = snap === void 0 ? void 0 : firstNodeKeyOfTurn(snap, turn);
661
+ if (key !== void 0) {
662
+ const row = scrollport.querySelector(`[${ANCHOR_ATTR}="${CSS.escape(key)}"]`);
663
+ if (row !== null) return row;
664
+ }
665
+ await sleep(80);
666
+ }
667
+ return null;
668
+ };
669
+ const settled = (row) => {
670
+ if (row === null) return false;
671
+ if (!isOldest) return true;
672
+ return !(sessionAccess !== void 0 ? sessionAccess.hasMore(sessionId ?? "") : findLoadOlderButton() !== null);
673
+ };
407
674
  for (let i = 0; i < MAX_JUMP_PAGES; i += 1) {
408
- const snap = snapshotRef.current;
675
+ const snap = chatRef.current;
409
676
  const key = snap === void 0 ? void 0 : firstNodeKeyOfTurn(snap, turn);
410
677
  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);
678
+ if (settled(row)) {
679
+ scrollToRow(row);
680
+ return true;
681
+ }
682
+ if (sessionAccess !== void 0) {
683
+ if (!sessionAccess.hasMore(sessionId ?? "")) {
684
+ if (row !== null) {
685
+ scrollToRow(row);
686
+ return true;
687
+ }
688
+ return false;
689
+ }
690
+ try {
691
+ await sessionAccess.loadOlder(sessionId ?? "");
692
+ } catch {
693
+ return row !== null ? (scrollToRow(row), true) : false;
694
+ }
695
+ const awaited = await waitForRow(LOAD_RENDER_SETTLE_MS);
696
+ if (awaited !== null && settled(awaited)) {
697
+ scrollToRow(awaited);
415
698
  return true;
416
699
  }
700
+ continue;
417
701
  }
418
702
  const btn = findLoadOlderButton();
419
703
  if (btn === null) {
@@ -452,9 +736,9 @@ window.__ModuleLoader__.load({
452
736
  }
453
737
  });
454
738
  };
455
- if (turns.length === 0) return null;
739
+ if (turns.length === 0 || railMode !== "stn") return null;
456
740
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
457
- className: "tn-wrap",
741
+ className: `tn-wrap${officialRail ? " tn-nudge" : ""}`,
458
742
  role: "navigation",
459
743
  "aria-label": t("rail"),
460
744
  onMouseLeave: () => {
@@ -481,9 +765,10 @@ window.__ModuleLoader__.load({
481
765
  const dist = hoverIndex === -1 ? Infinity : Math.abs(i - hoverIndex);
482
766
  const cls = dist === 0 ? " tn-cap-hot" : dist === 1 ? " tn-cap-warm" : "";
483
767
  const loading = jumpState !== null && jumpState.phase === "loading" && jumpState.turn === entry.turn;
768
+ const isActive = activeTurn === entry.turn;
484
769
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
485
770
  type: "button",
486
- className: `tn-cap-btn${cls}${loading ? " tn-loading" : ""}`,
771
+ className: `tn-cap-btn${cls}${loading ? " tn-loading" : ""}${isActive ? " tn-cap-active" : ""}`,
487
772
  onMouseEnter: (e) => {
488
773
  setHoverIndex(i);
489
774
  const rect = e.currentTarget.getBoundingClientRect();
@@ -534,15 +819,94 @@ window.__ModuleLoader__.load({
534
819
  });
535
820
  }
536
821
  //#endregion
822
+ //#region src/client/SettingsNavModeRow.tsx
823
+ /**
824
+ * Settings → General row: WHICH turn-navigation rail to show.
825
+ *
826
+ * Registered into `settings.general.item` (root scope) by the plugin's apply;
827
+ * the General section renders each contribution as one row, so this component
828
+ * draws its own title, description, and a three-way selector:
829
+ *
830
+ * DSH official | DSH STN (Smoothly Turn Nav) | Hide all
831
+ *
832
+ * The choice is persisted browser-locally (see mode.ts) and drives both the
833
+ * rail component (React-side visibility) and the official-rail stylesheet
834
+ * override (body class).
835
+ */
836
+ const OPTIONS = [
837
+ {
838
+ id: "official",
839
+ label: "modeOfficial"
840
+ },
841
+ {
842
+ id: "stn",
843
+ label: "modeSTN"
844
+ },
845
+ {
846
+ id: "hidden",
847
+ label: "modeHidden"
848
+ }
849
+ ];
850
+ /** Render the rail display-mode preference row. */
851
+ function SettingsNavModeRow({ t }) {
852
+ const mode = (0, react.useSyncExternalStore)(subscribeRailMode, getRailMode);
853
+ const [open, setOpen] = (0, react.useState)(false);
854
+ const selectedLabel = OPTIONS.find((option) => option.id === mode)?.label ?? "modeSTN";
855
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
856
+ className: "tn-mode-row",
857
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
858
+ className: "tn-mode-row-text",
859
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
860
+ className: "tn-mode-title",
861
+ children: t("modeRowTitle")
862
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
863
+ className: "tn-mode-desc",
864
+ children: t("modeRowDesc")
865
+ })]
866
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Menu, {
867
+ open,
868
+ onClose: () => {
869
+ setOpen(false);
870
+ },
871
+ items: OPTIONS.map((option) => ({
872
+ id: option.id,
873
+ label: t(option.label)
874
+ })),
875
+ selectedId: mode,
876
+ onSelect: (id) => {
877
+ setOpen(false);
878
+ setRailMode(id);
879
+ },
880
+ align: "end",
881
+ portal: true,
882
+ anchor: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
883
+ type: "button",
884
+ className: "tn-mode-selector",
885
+ "aria-haspopup": "menu",
886
+ "aria-expanded": open,
887
+ onClick: () => {
888
+ setOpen((value) => !value);
889
+ },
890
+ children: [t(selectedLabel), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: "tn-mode-chevron" })]
891
+ })
892
+ })]
893
+ });
894
+ }
895
+ //#endregion
537
896
  //#region src/client/locales.ts
538
- /** Copy dictionaries for the dsh-turn-navigator plugin. */
897
+ /** Copy dictionaries for the dsh-turn-navigator plugin (DSH Smoothly Turn Nav). */
539
898
  /** English strings (the key-set source of truth for this pair). */
540
899
  const en = {
541
900
  rail: "Turn navigation",
542
901
  turnLabel: "Turn {n}",
543
902
  noSummary: "(no user message)",
544
903
  locatingTurn: "Locating turn {n}…",
545
- locateFailed: "Could not locate turn {n}"
904
+ locateFailed: "Could not locate turn {n}",
905
+ modeRowTitle: "Turn navigation",
906
+ modeRowDesc: "Which turn-navigation rail to display: the DSH built-in, DSH STN (Smoothly Turn Nav), or none.",
907
+ modeOfficial: "DSH official",
908
+ modeSTN: "DSH STN",
909
+ modeHidden: "Hide all"
546
910
  };
547
911
  /** Chinese strings (same keys as {@link en}). */
548
912
  const zh = {
@@ -550,7 +914,12 @@ window.__ModuleLoader__.load({
550
914
  turnLabel: "第 {n} 轮",
551
915
  noSummary: "(无用户消息)",
552
916
  locatingTurn: "正在定位第 {n} 轮…",
553
- locateFailed: "无法定位第 {n} 轮"
917
+ locateFailed: "无法定位第 {n} 轮",
918
+ modeRowTitle: "轮次导航",
919
+ modeRowDesc: "选择显示哪个轮次胶囊条:DSH 官方、DSH STN(Smoothly Turn Nav),或全部隐藏。",
920
+ modeOfficial: "DSH 官方",
921
+ modeSTN: "DSH STN",
922
+ modeHidden: "全部隐藏"
554
923
  };
555
924
  //#endregion
556
925
  //#region src/client/styles.ts
@@ -583,6 +952,81 @@ window.__ModuleLoader__.load({
583
952
  z-index: 10;
584
953
  pointer-events: auto;
585
954
  }
955
+ /* When the built-in (official) TurnNavigator rail is present in the transcript
956
+ (right edge, vertically centered on the scrollport band), nudge our rail up
957
+ into the header zone so the two never overlap: same right edge, but pinned
958
+ below the session header instead of the scrollport center. */
959
+ .tn-wrap.tn-nudge {
960
+ top: 64px;
961
+ transform: none;
962
+ max-height: calc(100vh - 140px);
963
+ }
964
+ /* Narrow viewport: mirror the official rail's @container (max-width: 900px)
965
+ hide, so we never fight the compact layout for the right edge. */
966
+ @media (max-width: 900px) {
967
+ .tn-wrap {
968
+ display: none;
969
+ }
970
+ }
971
+ /* Subtractive takeover (settings → Turn navigation = DSH STN): hide the
972
+ OFFICIAL built-in rail. The official rail lives inside the conversation
973
+ scroll container (data-conversation-scroll) — our rail is fixed outside
974
+ it — so a container-scoped rule cannot match ours. The tn-hide-official
975
+ body class is toggled by mode.ts; the official rail itself has no
976
+ off-switch, so this stylesheet override is the only way to replace it. */
977
+ body.tn-hide-official [data-conversation-scroll] nav {
978
+ display: none !important;
979
+ }
980
+ /* Settings → General preference row (which rail to show). Mirrors the official
981
+ EnterBehaviorRow tokens. */
982
+ .tn-mode-row {
983
+ display: flex;
984
+ align-items: center;
985
+ gap: 8px;
986
+ padding: 16px 0;
987
+ border-bottom: 1px solid var(--dsw-alias-border-l2);
988
+ }
989
+ .tn-mode-row-text {
990
+ flex: 1;
991
+ min-width: 0;
992
+ display: flex;
993
+ flex-direction: column;
994
+ gap: 4px;
995
+ padding-right: 48px;
996
+ }
997
+ .tn-mode-title {
998
+ font-size: 14px;
999
+ font-weight: 400;
1000
+ line-height: 22px;
1001
+ color: var(--dsw-alias-label-primary);
1002
+ }
1003
+ .tn-mode-desc {
1004
+ font-size: 12px;
1005
+ font-weight: 400;
1006
+ line-height: 18px;
1007
+ color: var(--dsw-alias-label-tertiary);
1008
+ }
1009
+ .tn-mode-selector {
1010
+ display: inline-flex;
1011
+ align-items: center;
1012
+ gap: 12px;
1013
+ height: 36px;
1014
+ padding: 0 14px;
1015
+ border: none;
1016
+ border-radius: 18px;
1017
+ background: var(--dsw-alias-bg-module-platform);
1018
+ font: inherit;
1019
+ font-size: 14px;
1020
+ line-height: 22px;
1021
+ color: var(--dsw-alias-label-primary);
1022
+ cursor: pointer;
1023
+ }
1024
+ .tn-mode-selector:hover {
1025
+ background: var(--dsw-alias-interactive-bg-hover);
1026
+ }
1027
+ .tn-mode-chevron {
1028
+ flex: none;
1029
+ }
586
1030
  /* Up/down scroll controls at the top and bottom of the rail. Disabled (grey,
587
1031
  no pointer/hover-scroll) when there is nothing to scroll in that
588
1032
  direction. */
@@ -673,6 +1117,12 @@ window.__ModuleLoader__.load({
673
1117
  transform: scaleX(1.25);
674
1118
  background: var(--dsw-alias-label-secondary);
675
1119
  }
1120
+ /* Active (current) turn following the scroll: a persistent brand tint so the
1121
+ reader always knows where they are in the rail, distinct from hover. */
1122
+ .tn-cap-btn.tn-cap-active .tn-cap {
1123
+ background: var(--dsw-alias-state-business-primary);
1124
+ width: 14px;
1125
+ }
676
1126
  /* Custom tooltip bubble: mirrors the DSH tooltip visual (dark plate, white
677
1127
  text, pre-line for multi-line info), fixed-positioned to the LEFT of the
678
1128
  rail so it never falls outside the browser window. */
@@ -791,15 +1241,61 @@ window.__ModuleLoader__.load({
791
1241
  }), "dsh-turn-navigator: copy dictionaries");
792
1242
  const t = ctx.locale.bind(NS);
793
1243
  const api = ctx.get("connection")?.api;
1244
+ applyModeToBody();
1245
+ let journal;
1246
+ let sessionAccess;
1247
+ const resolveHandles = () => {
1248
+ if (journal !== void 0 && sessionAccess !== void 0) return;
1249
+ const sessionNamespace = ctx.get("remote.session");
1250
+ if (journal === void 0 && typeof sessionNamespace?.page === "function") journal = { page: (request, signal) => sessionNamespace.page(request, signal) };
1251
+ if (sessionAccess === void 0) {
1252
+ const sessionsService = ctx.get("sessions");
1253
+ if (sessionsService !== void 0 && typeof sessionsService.binding === "function") sessionAccess = {
1254
+ windowSeq(sessionId) {
1255
+ const entries = sessionsService.binding?.(sessionId)?.eventSource?.getSnapshot?.()?.entries ?? [];
1256
+ return {
1257
+ firstSeq: entries[0]?.event?.seq,
1258
+ lastSeq: entries.at(-1)?.event?.seq
1259
+ };
1260
+ },
1261
+ hasMore(sessionId) {
1262
+ return sessionsService.binding?.(sessionId)?.eventSource?.getSnapshot?.()?.hasMore ?? false;
1263
+ },
1264
+ async loadOlder(sessionId) {
1265
+ const face = sessionsService.binding?.(sessionId)?.session;
1266
+ if (face === void 0 || typeof face.loadOlder !== "function") return false;
1267
+ try {
1268
+ await face.loadOlder();
1269
+ return true;
1270
+ } catch {
1271
+ return false;
1272
+ }
1273
+ }
1274
+ };
1275
+ }
1276
+ };
1277
+ resolveHandles();
1278
+ ctx.slots.inject("settings.general.item", () => ctx.slots.register({
1279
+ name: "settings.general.item",
1280
+ id: "dsh-turn-navigator-mode",
1281
+ order: 30,
1282
+ locale: NS,
1283
+ inject: () => ({ t })
1284
+ }, SettingsNavModeRow));
794
1285
  ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
795
1286
  name: "conversation.session.header.utilities",
796
1287
  id: "dsh-turn-navigator",
797
1288
  order: 20,
798
1289
  locale: NS,
799
- inject: () => ({
800
- t,
801
- api
802
- })
1290
+ inject: () => {
1291
+ resolveHandles();
1292
+ return {
1293
+ t,
1294
+ api,
1295
+ journal,
1296
+ sessionAccess
1297
+ };
1298
+ }
803
1299
  }, TurnNavRail));
804
1300
  }
805
1301
  //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-turn-navigator",
3
- "version": "0.1.1",
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.",
3
+ "version": "0.4.0",
4
+ "description": "DSH Smoothly Turn Nav (DSH STN): full-history piano-key turn rail for DeepSeek Harness (dsh) web conversations — see every turn at a glance, hover to preview, click to jump anywhere, and replace the official (non-disableable) turn rail.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -62,7 +62,11 @@
62
62
  "turn",
63
63
  "navigation",
64
64
  "conversation",
65
- "outline"
65
+ "outline",
66
+ "turn-rail",
67
+ "turn-navigator",
68
+ "smoothly-turn-nav",
69
+ "stn"
66
70
  ],
67
71
  "author": "karoc",
68
72
  "license": "MIT",