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 +32 -0
- package/README.md +66 -21
- package/README.zh.md +67 -22
- package/docs/turn-nav-rail.png +0 -0
- package/lib/client.js +532 -36
- package/package.json +7 -3
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
|
-
#
|
|
1
|
+
# DSH Smoothly Turn Nav (DSH STN)
|
|
2
2
|
|
|
3
3
|
**English · [简体中文](README.zh.md)**
|
|
4
4
|
|
|
5
|
-
|
|
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
|

|
|
8
10
|
|
|
9
11
|
## Why
|
|
10
12
|
|
|
11
|
-
In the default DSH web UI,
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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
|
|
34
|
-
3. Hover a capsule
|
|
35
|
-
4. Click a capsule to jump to that turn's start.
|
|
36
|
-
5.
|
|
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 **
|
|
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
|
|
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
|
-
- **
|
|
49
|
-
- **
|
|
50
|
-
- **
|
|
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
|
|
56
|
-
-
|
|
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
|
-
#
|
|
1
|
+
# DSH Smoothly Turn Nav(DSH STN)
|
|
2
2
|
|
|
3
3
|
**[English](README.md) · 简体中文**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
**整场会话,一眼纵览。**
|
|
6
|
+
|
|
7
|
+
DSH Smoothly Turn Nav(**DSH STN**)是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(dsh)的外部插件,在每条会话右侧放一条**钢琴键式轮次胶囊条**——竖向一列小胶囊,一轮一个。它是整场会话的迷你地图:**全部历史轮次一目了然**(不只是当前已加载的窗口),悬停预览、点击跳转到任意轮次起点、滚动跟随高亮。它还可以**取代官方内置轮次胶囊条**(官方没有自己的关闭开关)。
|
|
6
8
|
|
|
7
9
|

|
|
8
10
|
|
|
9
11
|
## 为什么需要
|
|
10
12
|
|
|
11
|
-
DSH Web UI
|
|
12
|
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
76
|
+
插件注册**两个加法 slot**——**不修改 DSH 源码**:
|
|
41
77
|
|
|
42
78
|
| Slot | 作用域 | 职责 |
|
|
43
79
|
|------|--------|------|
|
|
44
|
-
| `conversation.session.header.utilities` | session |
|
|
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
|
-
-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
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
|
-
-
|
|
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
|
|
package/docs/turn-nav-rail.png
CHANGED
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
35
|
-
|
|
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 =
|
|
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
|
-
|
|
113
|
-
|
|
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)
|
|
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
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
}, [
|
|
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 =
|
|
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
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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:
|
|
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
|
-
|
|
801
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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",
|