dsh-turn-outline 0.2.7 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -5,7 +5,8 @@
5
5
  [![License: MIT](https://img.shields.io/github/license/Andor-Z/dsh-turn-outline)](LICENSE)
6
6
  [![GitHub release](https://img.shields.io/github/v/release/Andor-Z/dsh-turn-outline)](https://github.com/Andor-Z/dsh-turn-outline/releases)
7
7
  [![dsh-plugin](https://img.shields.io/badge/GitHub-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
8
- [![better-sidebar](https://img.shields.io/badge/better--sidebar-%5E0.17.0-6e56cf)](https://github.com/omdsh-dev/DSH-better-sidebar)
8
+ [![better-sidebar](https://img.shields.io/badge/better--sidebar-%3E%3D0.17.0-6e56cf)](https://github.com/omdsh-dev/DSH-better-sidebar)
9
+ [![dsh](https://img.shields.io/badge/DSH-0.2.0--rc.x_primary-0064ff)](https://github.com/omdsh-dev/DSH-better-sidebar)
9
10
 
10
11
  [中文](README.md)
11
12
 
@@ -24,7 +25,7 @@
24
25
  **What it solves**:
25
26
 
26
27
  - **Fold the session into user turns** — the conversation is collapsed into a structured "input → tool steps → output" trail, so long sessions become scannable at a glance; multi-level folding inside a turn plus tool-type filter chips let you focus on what matters.
27
- - **Structured jump-back** — click any turn / tool step to auto-load history and scroll to the matching row in the chat view / trajectory view. Navigation goes through the client `sessions` service (`loadOlder` + `anchorSeq` + `data-chat-anchor-key`) — no dependence on DOM text or hashed class names.
28
+ - **Structured jump-back** — click any turn / tool step to auto-load history and scroll to the matching row in the chat view / trajectory view. On 0.2.0 navigation goes through the client `uiConversation` conversation view registry (`anchorSeq` node matching + `loadOlder` paging); on 0.1.x it falls back to the legacy session snapshot — no dependence on DOM text or hashed class names.
28
29
  - **Zero AI dependency** — pure event folding, no LLM calls: zero tokens, instant open, no failure states.
29
30
  - **Read-only & non-invasive** — never touches the DSH core: one read-only host route + one client tab; registers cleanly and unloads without residue.
30
31
  - **Incremental performance** — refreshes are driven by session subscriptions (no polling); `TrailFolder` only re-folds open turns (O(delta)); seq-keyed rows reconcile in place instead of remounting.
@@ -46,7 +47,7 @@ npx @deepseek-ai/dsh plugin --profile desktop add dsh-turn-outline
46
47
 
47
48
  > Using the Web profile? Swap `--profile desktop` for `--profile web`.
48
49
  >
49
- > ℹ️ **Version-line note**: this plugin declares the peer range `dsh-better-sidebar@^0.17.0 || ^0.18.0`, and both lines are co-tested — **better-sidebar 0.18.0 + DSH 0.1.2-rc.1** (validated since 0.2.7) and **better-sidebar 0.17.1 + DSH 0.1.1-rc.x (stable)**. Note that better-sidebar 0.18.0 supports only DSH 0.1.2-rc.1+: on an older stable host (0.1.0-rc.8 ~ 0.1.1-rc.2), pin `dsh-better-sidebar@0.17.1`.
50
+ > ℹ️ **Version-line note**: **since 0.3.0 the primary line is DSH 0.2.0-rc.x** (tested against the DSH Desktop 2.0.17 bundled runtime + better-sidebar 0.24.1; jump-back rides the `uiConversation` conversation view registry and the host live path uses `snapshotEvents()`). The 0.1.x lines stay fallback-compatible — **better-sidebar 0.18.0 + DSH 0.1.2-rc.1** and **better-sidebar 0.17.1 + DSH 0.1.1-rc.x (stable)**: on older hosts the plugin automatically uses the legacy snapshot jump and the `.events` live path, with no configuration. Note that better-sidebar 0.18.0 supports only DSH 0.1.2-rc.1+: on an older stable host (0.1.0-rc.8 ~ 0.1.1-rc.2), pin `dsh-better-sidebar@0.17.1`.
50
51
 
51
52
  ### Option 2: install from source (development / offline)
52
53
 
@@ -79,20 +80,24 @@ npx @deepseek-ai/dsh plugin --profile desktop remove dsh-turn-outline
79
80
  ```
80
81
  host (Node) inject: ['webServer','sessionQuery','sessions']
81
82
  POST /turn-outline/api/events { sessionId, afterSeq }
82
- → live sessions: borrow the frozen snapshot of the live store (O(1) probe + O(delta) projection, zero deep clones)
83
+ → live sessions: borrow the frozen snapshot of the live store (snapshotEvents() on DSH
84
+ 0.2.0, the .events property on 0.1.x; O(1) probe + O(delta) projection, zero deep clones)
83
85
  → cold sessions: sessionQuery persisted log + lastSeq cache (LRU + byte cap)
84
86
  → paged responses (PAGE_SIZE events per page, hasMore semantics) + field trimming (no full tool-result payloads)
85
87
  → 200 { events, hasMore } / 400 / 403 / 404 / 405 / 413
86
88
 
87
- client (browser) inject: ['sessions','betterSidebar'] (betterSidebar is an optional peer)
88
- → registerTab({ id:'turn-outline', single:true })
89
- → subscription-driven refresh (no polling) + incremental folding (TrailFolder: only re-folds open turns)
89
+ client (browser) inject: ['sessions','uiConversation','betterSidebar'] (the latter two are optional peers)
90
+ → registerTab({ id:'turn-outline', single:true, description })
91
+ → jump-back: on 0.2.0 the ctx.uiConversation conversation view registry (chat/trajectory
92
+ targets); on 0.1.x the legacy session snapshot's chat.nodes / views fields
93
+ → subscription-driven refresh (session.subscribe + the 0.2.0 eventSource; no polling)
94
+ + incremental folding (TrailFolder: only re-folds open turns)
90
95
  + seq-keyed rows (append without remount) + per-turn React.memo + three-level folding + tool filter chips
91
96
  ```
92
97
 
93
98
  ## Performance design (introduced in v0.2.1)
94
99
 
95
- - **No more deep-cloning the whole log on the host**: the old `listEvents` probe and `readSession` read each `structuredClone` every event (~3 full deep clones per request for live sessions). Live sessions now borrow the store's frozen `events` snapshot directly (the cached, immutable array is reused): O(1) probe, O(delta) incremental reads, fully synchronous, zero copies.
100
+ - **No more deep-cloning the whole log on the host**: the old `listEvents` probe and `readSession` read each `structuredClone` every event (~3 full deep clones per request for live sessions). Live sessions now borrow the store's frozen event snapshot directly (`snapshotEvents()` on 0.2.0 / the `events` property on 0.1.x — the cached, immutable array is reused): O(1) probe, O(delta) incremental reads, fully synchronous, zero copies.
96
101
  - **Paged + trimmed responses**: at most `PAGE_SIZE` events per page (`hasMore` pulls the rest; first paint renders progressively); events project only the fields folding needs (text capped at `TEXT_CAP`, arguments at `ARGS_CAP`, `tool/result` keeps only the error field — the largest transfer/memory cost is gone).
97
102
  - **Incremental client folding**: `TrailFolder.push()` consumes each event exactly once; `snapshot()` reuses the TrailTurn objects of already-sealed turns (stable identity → `React.memo` re-renders only open turns), turning each refresh from O(n) full refolds into O(delta); duplicate/re-sent pages are dropped by the seq watermark.
98
103
  - **Rows keyed by seq**: appended steps reconcile in place in the DOM instead of shifting indexes and remounting whole chunks.
@@ -107,17 +112,18 @@ client (browser) inject: ['sessions','betterSidebar'] (betterSidebar is an optio
107
112
 
108
113
  | Item | Value |
109
114
  |---|---|
110
- | Platform | DSH 0.1.1-rc.x (stable line; web/desktop profile) |
111
- | Dependencies | dsh-better-sidebar (optional peer; inert when absent), react (injected by the DSH client seed) |
115
+ | Platform (primary) | DSH 0.2.0-rc.x (tested against the DSH Desktop 2.0.17 bundled runtime; web/desktop profile) |
116
+ | Platform (fallback-compatible) | DSH 0.1.2-rc.1 / 0.1.1-rc.x (stable line) |
117
+ | Dependencies | dsh-better-sidebar (optional peer, `>=0.17.0`; inert when absent), react (injected by the DSH client seed) |
112
118
  | Build | TypeScript + esbuild, no runtime npm dependencies |
113
- | Tests | vitest, 80 cases (fold / folder / jump / tab / host) |
119
+ | Tests | vitest, 100 cases (fold / folder / jump / tab / host) |
114
120
 
115
121
  ## Development
116
122
 
117
123
  ```sh
118
124
  pnpm install # if pnpm 10+ blocks build scripts, allow esbuild as prompted (see pnpm-workspace.yaml)
119
125
  pnpm typecheck # tsc --noEmit
120
- pnpm test # vitest run (80 cases)
126
+ pnpm test # vitest run (100 cases)
121
127
  pnpm build # esbuild → lib/index.js + lib/client.js (includes bundle-contract self check)
122
128
  pnpm pack # produces dsh-turn-outline-<version>.tgz (prepack rebuilds automatically)
123
129
  ```
package/README.md CHANGED
@@ -5,7 +5,8 @@
5
5
  [![License: MIT](https://img.shields.io/github/license/Andor-Z/dsh-turn-outline)](LICENSE)
6
6
  [![GitHub release](https://img.shields.io/github/v/release/Andor-Z/dsh-turn-outline)](https://github.com/Andor-Z/dsh-turn-outline/releases)
7
7
  [![dsh-plugin](https://img.shields.io/badge/GitHub-dsh--plugin-blue)](https://github.com/topics/dsh-plugin)
8
- [![better-sidebar](https://img.shields.io/badge/better--sidebar-%5E0.17.0-6e56cf)](https://github.com/omdsh-dev/DSH-better-sidebar)
8
+ [![better-sidebar](https://img.shields.io/badge/better--sidebar-%3E%3D0.17.0-6e56cf)](https://github.com/omdsh-dev/DSH-better-sidebar)
9
+ [![dsh](https://img.shields.io/badge/DSH-0.2.0--rc.x_主线-0064ff)](https://github.com/omdsh-dev/DSH-better-sidebar)
9
10
 
10
11
  [English](README.en.md)
11
12
 
@@ -24,7 +25,7 @@
24
25
  **解决什么问题**:
25
26
 
26
27
  - **按用户轮次折叠轨迹** —— 会话被折叠成「输入 → 工具步骤 → 输出」的结构化轨迹,长会话一目了然;轮次内支持多级折叠与工具类型筛选 chips,只看你想看的部分。
27
- - **一键跳回原位** —— 点击任意轮次 / 工具步骤,自动加载历史并滚动到聊天视图 / 轨迹视图的对应行;走 client `sessions` 服务(`loadOlder` + `anchorSeq` + `data-chat-anchor-key`),不依赖 DOM 文本与哈希类名。
28
+ - **一键跳回原位** —— 点击任意轮次 / 工具步骤,自动加载历史并滚动到聊天视图 / 轨迹视图的对应行;0.2.0 走 client `uiConversation` 会话视图注册表(`anchorSeq` 节点匹配 + `loadOlder` 翻页),0.1.x 回退旧 session 快照,不依赖 DOM 文本与哈希类名。
28
29
  - **零 AI 依赖** —— 纯事件折叠,不调用 LLM:零 token、秒开、无失败态。
29
30
  - **只读零侵入** —— 不改动 DSH 核心:host 一个只读路由 + client 一个 tab,注册即用、卸载干净。
30
31
  - **增量性能** —— 会话订阅驱动刷新(无定时轮询);`TrailFolder` 只重折打开中的轮次(O(delta));seq-keyed 行追加不重挂。
@@ -46,7 +47,7 @@ npx @deepseek-ai/dsh plugin --profile desktop add dsh-turn-outline
46
47
 
47
48
  > 使用 Web profile 时把 `--profile desktop` 换成 `--profile web`。
48
49
  >
49
- > ℹ️ **版本线说明**:本插件 peer 声明 `dsh-better-sidebar@^0.17.0 || ^0.18.0`,双线均已联调——**better-sidebar 0.18.0 + DSH 0.1.2-rc.1**(0.2.7 起验证)与 **better-sidebar 0.17.1 + DSH 0.1.1-rc.x(stable)**。注意 better-sidebar 0.18.0 仅支持 DSH 0.1.2-rc.1+:旧 stable 宿主(0.1.0-rc.8 ~ 0.1.1-rc.2)请固定安装 `dsh-better-sidebar@0.17.1`。
50
+ > ℹ️ **版本线说明**:**0.3.0 起以 DSH 0.2.0-rc.x 为主线**(DSH Desktop 2.0.17 内置运行时 + better-sidebar 0.24.1 实测联调;跳回走 `uiConversation` 会话视图注册表,host live 路径用 `snapshotEvents()`)。0.1.x 线继续回退兼容——**better-sidebar 0.18.0 + DSH 0.1.2-rc.1** 与 **better-sidebar 0.17.1 + DSH 0.1.1-rc.x(stable)**:旧宿主上插件自动走旧快照跳转与 `.events` live 路径,无需任何配置。注意 better-sidebar 0.18.0 仅支持 DSH 0.1.2-rc.1+:更旧的 stable 宿主(0.1.0-rc.8 ~ 0.1.1-rc.2)请固定安装 `dsh-better-sidebar@0.17.1`。
50
51
 
51
52
  ### 方式二:源码本地安装(开发 / 离线)
52
53
 
@@ -79,20 +80,24 @@ npx @deepseek-ai/dsh plugin --profile desktop remove dsh-turn-outline
79
80
  ```
80
81
  host(Node) inject: ['webServer','sessionQuery','sessions']
81
82
  POST /turn-outline/api/events { sessionId, afterSeq }
82
- → 活跃会话:直接借用 live store 冻结快照(O(1) 探测 + O(delta) 投影,零深拷贝)
83
+ → 活跃会话:借用 live store 冻结快照(0.2.0 经 snapshotEvents(),0.1.x 读 .events;
84
+ O(1) 探测 + O(delta) 投影,零深拷贝)
83
85
  → 冷会话:sessionQuery 持久化日志 + lastSeq 缓存(LRU + 字节上限)
84
86
  → 响应逐页(每页 PAGE_SIZE 事件,hasMore 语义)+ 字段裁剪(不发 tool 结果全文)
85
87
  → 200 { events, hasMore } / 400 / 403 / 404 / 405 / 413
86
88
 
87
- client(浏览器) inject: ['sessions','betterSidebar'](betterSidebar 可选 peer)
88
- → registerTab({ id:'turn-outline', single:true })
89
- → 会话订阅驱动刷新(无定时轮询)+ 增量折叠(TrailFolder:只重折打开中的轮次)
90
- + seq-keyed 行(追加不重挂)+ 按轮次 React.memo + 三级折叠 + 工具筛选 chips
89
+ client(浏览器) inject: ['sessions','uiConversation','betterSidebar'](后两者可选 peer)
90
+ → registerTab({ id:'turn-outline', single:true, description })
91
+ → 跳回:0.2.0 走 ctx.uiConversation 会话视图注册表(chat/trajectory target);
92
+ 0.1.x 回退 session snapshot 的 chat.nodes / views 字段
93
+ → 会话订阅驱动刷新(session.subscribe + 0.2.0 eventSource,无定时轮询)
94
+ → 增量折叠(TrailFolder:只重折打开中的轮次)+ seq-keyed 行(追加不重挂)
95
+ + 按轮次 React.memo + 三级折叠 + 工具筛选 chips
91
96
  ```
92
97
 
93
98
  ## 性能设计(v0.2.1 引入)
94
99
 
95
- - **host 不再对整份日志做深拷贝**:旧的 `listEvents` 探测与 `readSession` 读取会对每个事件 `structuredClone`(活跃会话约 3 次全量深拷贝/请求)。现在活跃会话直接借用 store 的冻结 `events` 快照(复用不变数组),探测 O(1)、取增量 O(delta),全程同步零拷贝。
100
+ - **host 不再对整份日志做深拷贝**:旧的 `listEvents` 探测与 `readSession` 读取会对每个事件 `structuredClone`(活跃会话约 3 次全量深拷贝/请求)。现在活跃会话直接借用 store 的冻结事件快照(0.2.0 的 `snapshotEvents()` / 0.1.x 的 `events` 属性,复用不变数组),探测 O(1)、取增量 O(delta),全程同步零拷贝。
96
101
  - **响应逐页 + 裁剪**:每页最多 `PAGE_SIZE` 事件(`hasMore` 续拉,首载渐进渲染);事件只投影折叠所需字段(文本截断到 `TEXT_CAP`、arguments 到 `ARGS_CAP`、`tool/result` 只留 error 字段——最大的传输与内存占用被去掉)。
97
102
  - **client 增量折叠**:`TrailFolder.push()` 每事件只消费一次,`snapshot()` 复用已封口轮次的 TrailTurn 对象(身份稳定 → `React.memo` 只重渲染打开中的轮次),每轮刷新从 O(n) 全量重折降为 O(delta);重复/重发的分页被 seq 水位线丢弃。
98
103
  - **行 key 用 seq**:新步骤追加时行在其 DOM 原位 reconcile,不再索引平移导致整段卸载重挂。
@@ -107,17 +112,18 @@ client(浏览器) inject: ['sessions','betterSidebar'](betterSidebar 可
107
112
 
108
113
  | 项 | 值 |
109
114
  |---|---|
110
- | 平台 | DSH 0.1.1-rc.x(stable 线;web/desktop profile) |
111
- | 依赖 | dsh-better-sidebar(可选 peer;未装则 inert)、react(DSH client 种子注入) |
115
+ | 平台(主线) | DSH 0.2.0-rc.x(DSH Desktop 2.0.17 内置运行时实测;web/desktop profile) |
116
+ | 平台(回退兼容) | DSH 0.1.2-rc.1 / 0.1.1-rc.x(stable 线) |
117
+ | 依赖 | dsh-better-sidebar(可选 peer,`>=0.17.0`;未装则 inert)、react(DSH client 种子注入) |
112
118
  | 构建 | TypeScript + esbuild,无运行时 npm 依赖 |
113
- | 测试 | vitest 80 用例(fold / folder / jump / tab / host) |
119
+ | 测试 | vitest 100 用例(fold / folder / jump / tab / host) |
114
120
 
115
121
  ## 开发
116
122
 
117
123
  ```sh
118
124
  pnpm install # pnpm 10+ 拦截构建脚本时按提示允许 esbuild(见 pnpm-workspace.yaml)
119
125
  pnpm typecheck # tsc --noEmit
120
- pnpm test # vitest run(80 用例)
126
+ pnpm test # vitest run(100 用例)
121
127
  pnpm build # esbuild → lib/index.js + lib/client.js(含 bundle 契约自检)
122
128
  pnpm pack # 产出 dsh-turn-outline-<version>.tgz(prepack 自动重新构建)
123
129
  ```
package/lib/client.js CHANGED
@@ -1223,18 +1223,60 @@ function useTrail(sessionId, visible, fetchEvents, sessionsService) {
1223
1223
  if (sessionId.length === 0 || !visible) return void 0;
1224
1224
  void pull();
1225
1225
  const binding = sessionsService?.binding(sessionId);
1226
+ const disposers = [];
1226
1227
  const session = binding !== void 0 && binding.session !== void 0 ? binding.session : void 0;
1227
- if (session === void 0 || typeof session.subscribe !== "function") return void 0;
1228
- return session.subscribe(() => {
1229
- schedulePull();
1230
- });
1228
+ if (session !== void 0 && typeof session.subscribe === "function") {
1229
+ disposers.push(session.subscribe(() => {
1230
+ schedulePull();
1231
+ }));
1232
+ }
1233
+ const eventSource = binding !== void 0 ? binding.eventSource : void 0;
1234
+ if (eventSource !== void 0 && typeof eventSource.subscribe === "function") {
1235
+ try {
1236
+ disposers.push(eventSource.subscribe(() => {
1237
+ schedulePull();
1238
+ }));
1239
+ } catch {
1240
+ }
1241
+ }
1242
+ if (disposers.length === 0) return void 0;
1243
+ return () => {
1244
+ for (const dispose of disposers) dispose();
1245
+ };
1231
1246
  }, [sessionId, visible, pull, schedulePull, sessionsService]);
1232
1247
  return { turns, stats, status, error, refresh };
1233
1248
  }
1234
1249
 
1235
1250
  // src/client/trail-jump.ts
1236
- function hasSeqInWindow(snapshot, seq) {
1237
- const store = snapshot.chat?.nodes;
1251
+ function targetSnapshot(conversation, name) {
1252
+ try {
1253
+ const target = conversation.target(name);
1254
+ return target !== void 0 ? target.getSnapshot() : void 0;
1255
+ } catch {
1256
+ return void 0;
1257
+ }
1258
+ }
1259
+ function viewStateForJump(session, conversation) {
1260
+ const snapshot = session.getSnapshot();
1261
+ let chatNodes;
1262
+ let trajectoryView;
1263
+ if (conversation !== void 0) {
1264
+ const chat = targetSnapshot(conversation, "chat");
1265
+ if (chat !== null && typeof chat === "object" && chat.nodes !== void 0 && chat.nodes !== null) {
1266
+ chatNodes = chat.nodes;
1267
+ }
1268
+ const trajectory = targetSnapshot(conversation, "trajectory");
1269
+ if (trajectory !== null && typeof trajectory === "object") trajectoryView = trajectory;
1270
+ }
1271
+ if (chatNodes === void 0) chatNodes = snapshot.chat?.nodes;
1272
+ if (trajectoryView === void 0 && snapshot.views !== void 0 && typeof snapshot.views.get === "function") {
1273
+ const legacy = snapshot.views.get("trajectory");
1274
+ if (legacy !== null && typeof legacy === "object") trajectoryView = legacy;
1275
+ }
1276
+ return { hasMore: snapshot.hasMore === true, chatNodes, trajectoryView };
1277
+ }
1278
+ function hasSeqInWindow(view, seq) {
1279
+ const store = view.chatNodes;
1238
1280
  if (store === void 0 || typeof store.values !== "function") return false;
1239
1281
  let min = Number.POSITIVE_INFINITY;
1240
1282
  let max = Number.NEGATIVE_INFINITY;
@@ -1245,8 +1287,8 @@ function hasSeqInWindow(snapshot, seq) {
1245
1287
  }
1246
1288
  return min <= seq && seq <= max;
1247
1289
  }
1248
- function findNodeForSeq(snapshot, seq) {
1249
- const store = snapshot.chat?.nodes;
1290
+ function findNodeForSeq(view, seq) {
1291
+ const store = view.chatNodes;
1250
1292
  if (store === void 0 || typeof store.values !== "function") return null;
1251
1293
  let exact = null;
1252
1294
  let next = null;
@@ -1267,12 +1309,9 @@ function findNodeForSeq(snapshot, seq) {
1267
1309
  function isTrajectoryViewActive(document2) {
1268
1310
  return document2.querySelector("[data-trajectory-scroll]") !== null;
1269
1311
  }
1270
- function trajectoryRecordIdFor(item, snapshot) {
1271
- const views = snapshot.views;
1272
- if (views === void 0 || typeof views.get !== "function") return null;
1273
- const trajectory = views.get("trajectory");
1274
- if (trajectory === null || typeof trajectory !== "object") return null;
1275
- const view = trajectory;
1312
+ function trajectoryRecordIdsFor(item, trajectoryView) {
1313
+ if (trajectoryView === null || typeof trajectoryView !== "object") return [];
1314
+ const view = trajectoryView;
1276
1315
  const nodes = Array.isArray(view.eventNodes) ? view.eventNodes : [];
1277
1316
  const requests = Array.isArray(view.requests) ? view.requests : [];
1278
1317
  const nodeWithKind = (kinds, seq) => {
@@ -1281,20 +1320,25 @@ function trajectoryRecordIdFor(item, snapshot) {
1281
1320
  };
1282
1321
  if (item.kind === "user" || item.kind === "context" || item.kind === "input") {
1283
1322
  const target = nodeWithKind(["user", "steering", "context"], item.seq);
1284
- if (target === void 0 || typeof target.seq !== "number") return null;
1323
+ if (target === void 0 || typeof target.seq !== "number") return [];
1285
1324
  const rowKind = target.kind === "steering" ? "user" : target.kind;
1286
- return `${rowKind}\0seq\0${target.seq}`;
1325
+ return [`${rowKind}\0seq\0${target.seq}`];
1287
1326
  }
1288
1327
  if (item.kind === "assistant" || item.kind === "output") {
1289
1328
  const target = nodeWithKind(["assistant"], item.seq);
1290
- if (target === void 0 || typeof target.turn !== "number" || typeof target.step !== "number") return null;
1291
- return `assistant\0${target.turn}\0${target.step}`;
1329
+ if (target === void 0 || typeof target.seq !== "number") return [];
1330
+ const ids = [];
1331
+ if (typeof target.turn === "number" && typeof target.step === "number") {
1332
+ ids.push(`assistant\0${target.turn}\0${target.step}`);
1333
+ }
1334
+ ids.push(`assistant\0seq\0${target.seq}`);
1335
+ return ids;
1292
1336
  }
1293
1337
  if (item.kind === "tool" || item.kind === "error") {
1294
- if (typeof item.callId === "string" && item.callId.length > 0) return `tool\0call\0${item.callId}`;
1338
+ if (typeof item.callId === "string" && item.callId.length > 0) return [`tool\0call\0${item.callId}`];
1295
1339
  const target = nodeWithKind(["tool-result"], item.seq);
1296
- if (target === void 0 || typeof target.callId !== "string") return null;
1297
- return `tool\0call\0${target.callId}`;
1340
+ if (target === void 0 || typeof target.callId !== "string") return [];
1341
+ return [`tool\0call\0${target.callId}`];
1298
1342
  }
1299
1343
  if (item.kind === "compaction") {
1300
1344
  const compactions = [];
@@ -1306,18 +1350,21 @@ function trajectoryRecordIdFor(item, snapshot) {
1306
1350
  }
1307
1351
  }
1308
1352
  const target = compactions.find((request) => request.resultSeq === item.seq) ?? compactions.filter((request) => (request.resultSeq ?? -1) >= item.seq).sort((a, b) => (a.resultSeq ?? -1) - (b.resultSeq ?? -1))[0];
1309
- if (target === void 0) return null;
1310
- return `compacted\0seq\0${target.startSeq}`;
1353
+ if (target === void 0) return [];
1354
+ return [`compacted\0seq\0${target.startSeq}`];
1311
1355
  }
1312
- return null;
1313
- }
1314
- function findTrajectoryRow(document2, key) {
1315
- const encoded = encodeURIComponent(key);
1316
- const rows = document2.querySelectorAll("tr[data-trajectory-row-key]");
1317
- for (let index = 0; index < rows.length; index += 1) {
1318
- const row = rows[index];
1319
- const value = row.dataset !== void 0 ? row.dataset.trajectoryRowKey : void 0;
1320
- if (value === encoded || value === key) return row;
1356
+ return [];
1357
+ }
1358
+ function findTrajectoryRowByKeys(document2, keys) {
1359
+ if (keys.length === 0) return null;
1360
+ for (const key of keys) {
1361
+ const encoded = encodeURIComponent(key);
1362
+ const rows = document2.querySelectorAll("tr[data-trajectory-row-key]");
1363
+ for (let index = 0; index < rows.length; index += 1) {
1364
+ const row = rows[index];
1365
+ const value = row.dataset !== void 0 ? row.dataset.trajectoryRowKey : void 0;
1366
+ if (value === encoded || value === key) return row;
1367
+ }
1321
1368
  }
1322
1369
  return null;
1323
1370
  }
@@ -1353,30 +1400,70 @@ function waitForNextFrame() {
1353
1400
  }
1354
1401
  });
1355
1402
  }
1403
+ function findChatRow(document2, key) {
1404
+ const exact = document2.querySelector(`[data-chat-anchor-key="${cssEscape(key)}"]`);
1405
+ if (exact !== null) return exact;
1406
+ const rows = document2.querySelectorAll("[data-chat-node-key],[data-chat-anchor-key]");
1407
+ for (let index = 0; index < rows.length; index += 1) {
1408
+ const row = rows[index];
1409
+ if (row.dataset !== void 0 && (row.dataset.chatNodeKey === key || row.dataset.chatAnchorKey === key)) return row;
1410
+ }
1411
+ return null;
1412
+ }
1413
+ function isHiddenInTree(row) {
1414
+ let el = row;
1415
+ while (el !== null && el !== void 0) {
1416
+ if (typeof el.hasAttribute === "function" && el.hasAttribute("hidden")) return true;
1417
+ el = el.parentElement;
1418
+ }
1419
+ return false;
1420
+ }
1421
+ function dispatchBeforematchUp(row) {
1422
+ let el = row;
1423
+ while (el !== null && el !== void 0) {
1424
+ if (typeof el.hasAttribute === "function" && el.hasAttribute("hidden") && typeof el.dispatchEvent === "function" && typeof Event === "function") {
1425
+ try {
1426
+ el.dispatchEvent(new Event("beforematch"));
1427
+ } catch {
1428
+ }
1429
+ }
1430
+ el = el.parentElement;
1431
+ }
1432
+ }
1433
+ async function revealRowUntilVisible(row, frames = 10) {
1434
+ if (!isHiddenInTree(row)) return true;
1435
+ for (let index = 0; index < frames; index += 1) {
1436
+ dispatchBeforematchUp(row);
1437
+ await waitForNextFrame();
1438
+ if (!isHiddenInTree(row)) return true;
1439
+ }
1440
+ return !isHiddenInTree(row);
1441
+ }
1356
1442
  async function jumpToItem(session, item, options) {
1357
1443
  const guard = Number.isFinite(Number(options.guard)) && (options.guard ?? 0) > 0 ? Math.min(500, Math.max(1, Math.floor(options.guard ?? 200))) : 200;
1358
1444
  const document2 = options.document;
1359
1445
  try {
1360
- let snapshot = session.getSnapshot();
1446
+ let view = viewStateForJump(session, options.conversation);
1361
1447
  const trajectoryActive = isTrajectoryViewActive(document2);
1362
1448
  if (trajectoryActive) {
1363
- let key = trajectoryRecordIdFor(item, snapshot);
1364
- let row2 = key === null ? null : findTrajectoryRow(document2, key);
1449
+ let keys = trajectoryRecordIdsFor(item, view.trajectoryView);
1450
+ let row2 = findTrajectoryRowByKeys(document2, keys);
1365
1451
  let pages2 = 0;
1366
- if (row2 === null && snapshot.hasMore === true) {
1367
- while (snapshot.hasMore === true && pages2 < guard) {
1452
+ if (row2 === null && view.hasMore) {
1453
+ while (view.hasMore && pages2 < guard) {
1368
1454
  await session.loadOlder();
1369
- snapshot = session.getSnapshot();
1455
+ view = viewStateForJump(session, options.conversation);
1370
1456
  pages2 += 1;
1371
1457
  options.onProgress?.(pages2);
1372
1458
  }
1373
- key = trajectoryRecordIdFor(item, snapshot);
1374
- row2 = key === null ? null : findTrajectoryRow(document2, key);
1459
+ keys = trajectoryRecordIdsFor(item, view.trajectoryView);
1460
+ row2 = findTrajectoryRowByKeys(document2, keys);
1375
1461
  }
1376
1462
  if (row2 === null) {
1377
1463
  expandCollapsedSummaries(document2);
1378
1464
  await waitForNextFrame();
1379
- row2 = key === null ? null : findTrajectoryRow(document2, key);
1465
+ keys = trajectoryRecordIdsFor(item, view.trajectoryView);
1466
+ row2 = findTrajectoryRowByKeys(document2, keys);
1380
1467
  }
1381
1468
  if (row2 === null) {
1382
1469
  row2 = findTrajectoryRowBySeq(document2, item);
@@ -1386,23 +1473,36 @@ async function jumpToItem(session, item, options) {
1386
1473
  return { ok: true, mode: "trajectory" };
1387
1474
  }
1388
1475
  let pages = 0;
1389
- while (snapshot.hasMore === true && !hasSeqInWindow(snapshot, item.seq) && pages < guard) {
1476
+ if (!hasSeqInWindow(view, item.seq) && typeof session.loadThrough === "function") {
1477
+ await session.loadThrough(item.seq);
1478
+ await waitForNextFrame();
1479
+ await waitForNextFrame();
1480
+ view = viewStateForJump(session, options.conversation);
1481
+ }
1482
+ while (view.hasMore && !hasSeqInWindow(view, item.seq) && pages < guard) {
1390
1483
  await session.loadOlder();
1391
- snapshot = session.getSnapshot();
1484
+ view = viewStateForJump(session, options.conversation);
1392
1485
  pages += 1;
1393
1486
  options.onProgress?.(pages);
1394
1487
  }
1395
- const node = findNodeForSeq(snapshot, item.seq);
1488
+ let node = findNodeForSeq(view, item.seq);
1489
+ let nodeRetries = 0;
1490
+ while (node === null && nodeRetries < 6) {
1491
+ await waitForNextFrame();
1492
+ view = viewStateForJump(session, options.conversation);
1493
+ node = findNodeForSeq(view, item.seq);
1494
+ nodeRetries += 1;
1495
+ }
1396
1496
  if (node === null) return { ok: false, error: "seq-not-rendered" };
1397
- const selector = `[data-chat-anchor-key="${cssEscape(node.key)}"]`;
1398
- let row = document2.querySelector(selector);
1497
+ let row = findChatRow(document2, node.key);
1399
1498
  let retries = 0;
1400
1499
  while (row === null && retries < 6) {
1401
1500
  await waitForNextFrame();
1402
- row = document2.querySelector(selector);
1501
+ row = findChatRow(document2, node.key);
1403
1502
  retries += 1;
1404
1503
  }
1405
1504
  if (row === null) return { ok: false, error: "row-not-in-chat-view" };
1505
+ if (!await revealRowUntilVisible(row)) return { ok: false, error: "row-hidden" };
1406
1506
  row.scrollIntoView({ behavior: "smooth", block: "center" });
1407
1507
  if (row.classList !== void 0 && typeof row.classList.add === "function") {
1408
1508
  row.classList.add("ttl-flash");
@@ -1423,6 +1523,17 @@ function sessionsOf(ctx) {
1423
1523
  const service = ctx.get("sessions");
1424
1524
  return service !== void 0 && service !== null && typeof service.binding === "function" ? service : void 0;
1425
1525
  }
1526
+ function conversationOf(ctx, sessionId) {
1527
+ if (ctx === void 0 || typeof ctx.get !== "function") return void 0;
1528
+ const service = ctx.get("uiConversation");
1529
+ if (service === void 0 || service === null || typeof service.binding !== "function") return void 0;
1530
+ try {
1531
+ const targets = service.binding(sessionId);
1532
+ return targets !== void 0 && targets !== null && typeof targets.target === "function" ? targets : void 0;
1533
+ } catch {
1534
+ return void 0;
1535
+ }
1536
+ }
1426
1537
  function turnByIdentity(turn) {
1427
1538
  return turn.turn !== null ? `turn:${turn.turn}` : `input:${turn.anchorSeq}`;
1428
1539
  }
@@ -1701,6 +1812,10 @@ function TrailTab(props) {
1701
1812
  if (typeof face.loadOlder !== "function" || typeof face.getSnapshot !== "function") return null;
1702
1813
  return session;
1703
1814
  }, [sessionsService, sessionId]);
1815
+ const conversationForJump = React2.useMemo(
1816
+ () => conversationOf(props.ctx, sessionId),
1817
+ [props.ctx, sessionId]
1818
+ );
1704
1819
  const doJump = React2.useCallback(async (target, turn) => {
1705
1820
  if (jumpingRef.current) return;
1706
1821
  setActiveTurnId(turnByIdentity(turn));
@@ -1714,7 +1829,8 @@ function TrailTab(props) {
1714
1829
  setJumpMessage(null);
1715
1830
  const outcome = await jumpToItem(sessionForJump, target, {
1716
1831
  document: window.document,
1717
- onProgress: (pages) => setJumpPages(pages)
1832
+ onProgress: (pages) => setJumpPages(pages),
1833
+ ...conversationForJump !== void 0 ? { conversation: conversationForJump } : {}
1718
1834
  });
1719
1835
  jumpingRef.current = false;
1720
1836
  setJumping(null);
@@ -1723,7 +1839,7 @@ function TrailTab(props) {
1723
1839
  } else {
1724
1840
  setJumpMessage(t(lang, "jumpFailed"));
1725
1841
  }
1726
- }, [sessionForJump, lang]);
1842
+ }, [sessionForJump, conversationForJump, lang]);
1727
1843
  const stepMatches = (step) => {
1728
1844
  if (filter === "all") return true;
1729
1845
  if (filter === "failed") return step.kind === "error";
@@ -1849,7 +1965,11 @@ function routeIcon(size) {
1849
1965
  children
1850
1966
  );
1851
1967
  }
1852
- var inject = ["sessions", "betterSidebar"];
1968
+ var inject = ["sessions", "uiConversation", "betterSidebar"];
1969
+ function tabDescription() {
1970
+ if (typeof navigator === "undefined") return "Turn outline";
1971
+ return navigator.language.toLowerCase().startsWith("zh") ? "\u6309\u7528\u6237\u8F6E\u6B21\u6298\u53E0\u4F1A\u8BDD\u8F68\u8FF9\uFF0C\u4E00\u952E\u5B9A\u4F4D\u56DE\u5BF9\u8BDD\u539F\u4F4D" : "Fold the session into per-turn steps and jump back to any of them";
1972
+ }
1853
1973
  function apply(ctx) {
1854
1974
  const style = document.createElement("style");
1855
1975
  style.dataset.plugin = "dsh-turn-outline";
@@ -1863,6 +1983,7 @@ function apply(ctx) {
1863
1983
  ctx.effect(() => betterSidebar.registerTab({
1864
1984
  id: "turn-outline",
1865
1985
  title: () => typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("zh") ? "\u8F6E\u6B21\u8F68\u8FF9" : "Turn Outline",
1986
+ description: tabDescription,
1866
1987
  icon: (size) => routeIcon(size),
1867
1988
  order: 46,
1868
1989
  single: true,