dsh-conversation-navigator 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-conversation-navigator contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # DSH 会话导航插件(dsh-conversation-navigator)
2
+
3
+ Codex 风格的 **DeepSeek Harness(DSH)Web 端会话导航面板**:在对话页左侧悬浮展示按轮折叠的对话大纲,点击任意节点平滑跳转,滚动对话时实时高亮当前阅读位置,步次徽标配色与内置"轨迹"视图统一。
4
+
5
+ 纯浏览器插件(无宿主行为)、纯 JavaScript、零构建、零运行时 npm 依赖。
6
+
7
+ ## 功能
8
+
9
+ - **按轮折叠大纲**:默认只显示"第 N 轮 + 你的问题"列表,长对话一目了然
10
+ - **展开/折叠步次**:点击轮次行右侧的 `▸ N` / `▾` 展开或折叠该轮的步次明细(助手回复、工具调用、命令、压缩点等)
11
+ - **点击定位**:点击轮次主体或步次条目,平滑滚动跳转到对话中对应位置(不改变折叠状态)
12
+ - **位置跟踪**:手动滚动对话时,面板自动高亮并跟随当前正在阅读的轮次
13
+ - **回到最新 / 全部折叠**:面板底部两个快捷按钮
14
+ - **轨迹配色**:用户/插话 = 业务蓝、上下文 = 成功绿、助手 = 紫罗兰、工具 = 琥珀、压缩 = 中性灰(全部取自 `dsh-client-ui-trajectory` 的 kindTag 主题 token,自动适配明暗主题)
15
+ - 切换工作区/会话自动跟随并重建大纲
16
+
17
+ ## 安装
18
+
19
+ 本插件是官方规范的**组合包**(`dsh.bundle` manifest + `dsh.client` 声明),纯 JavaScript、无构建步骤,推荐用官方 CLI 安装:
20
+
21
+ ```sh
22
+ # 方式一:npm(发布后,用户无需任何构建授权)
23
+ dsh plugin --profile web add dsh-conversation-navigator
24
+
25
+ # 方式二:GitHub(纯 JS 包无需 prepare/allowBuilds,直接可用)
26
+ dsh plugin --profile web add github:you/dsh-conversation-navigator
27
+
28
+ # 方式三:本地 tarball
29
+ pnpm pack
30
+ dsh plugin --profile web add ./dsh-conversation-navigator-0.1.0.tgz
31
+ ```
32
+
33
+ `dsh plugin` 在 profile 目录内转发给 pnpm,因此需要 **pnpm 在 PATH 上**;安装会自动把本包追加进 profile 的 `dsh.profile.bundles`,其自带的 `cordis.patch.yml` 层负责插入插件行。重启 `dsh web` 后生效,面板默认展开。
34
+
35
+ > 手动方式(不依赖 pnpm):把仓库放进 `<DSH_HOME>\profiles\<profile>\node_modules\dsh-conversation-navigator`,并在 profile 的 `cordis.patch.yml` 顶层数组追加 [`example.patch.yml`](./example.patch.yml) 的内容。
36
+
37
+ ## 更新
38
+
39
+ 修改 `lib/client.js` 后重启 `dsh web` 即可;插件没有持久状态,展开/折叠状态仅存于页面会话内。
40
+
41
+ ## 工作原理
42
+
43
+ - 注册槽位:`conversation.session.header.utilities`(标题栏"☰ 导航"开关)+ `shell.overlay`(浮动面板)
44
+ - 数据来源:会话级标准 props 的 `useSession`(选择 `ConversationSnapshot.chat` 的稳定渲染顺序 `order` + `ChatNodeStore`),按 `node.location` 的 turn 分组
45
+ - 跳转定位:复用 DSH 聊天视图自身的稳定 DOM 锚点 `[data-chat-anchor-key]`(与产品内部 paging/scroll 定位同源),`scrollIntoView` 平滑滚动
46
+ - 位置跟踪:捕获 `[data-conversation-scroll]` 滚动容器的 scroll 事件(节流 120ms),计算视口顶部首个可见节点
47
+ - 样式:自建 `<style>` 注入,颜色全部使用 `--dsw-*` 主题 token;插件卸载时随 fiber 清理
48
+
49
+ ## 兼容性说明
50
+
51
+ - 目标平台:DSH Web 端(`dsh.client.platform: web`),依赖内核 seed 的 `react` 与 `slots` 服务、`dsh-client-runtime` 与 `dsh-client-ui-conversation` 提供的标准能力(已声明在 `dsh.client.inject` 中)
52
+ - **版本敏感点**:`[data-chat-anchor-key]` / `[data-conversation-scroll]` 是当前 DSH 聊天视图的 DOM 锚点约定,DSH 升级后若锚点变化,只需调整 `lib/client.js` 中 `findAnchor` / `computeActiveKey` 两个函数
53
+ - 未声明 `timer` 硬依赖:客户端的 timer 服务存在则用于节流,不存在时自动退化为未节流模式
54
+
55
+ ## 目录结构
56
+
57
+ ```
58
+ lib/
59
+ index.js # 宿主侧空入口(纯浏览器插件)
60
+ client.js # 浏览器端完整实现(window.__ModuleLoader__ 模块格式)
61
+ cordis.patch.yml # 组合包补丁层(插入插件行)
62
+ example.patch.yml # 手动安装时的补丁示例
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,7 @@
1
+ # dsh-conversation-navigator 组合包补丁层
2
+ # 当 profile 的 dsh.profile.bundles 列出本包时,此层会被应用,
3
+ # 向组合中插入一行浏览器插件(dsh.client row)。
4
+
5
+ - insert:
6
+ - id: ui-conversation-navigator
7
+ name: 'dsh-conversation-navigator'
package/lib/client.js ADDED
@@ -0,0 +1,593 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-conversation-navigator",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ const React = require("react");
8
+
9
+ /* ---------- own stylesheet (static packages have no styles builtin) ---------- */
10
+ const CSS = `
11
+ .cnvnav-toggle {
12
+ display: inline-flex; align-items: center; gap: 6px;
13
+ height: 28px; padding: 0 10px; border-radius: 8px;
14
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35));
15
+ background: transparent;
16
+ color: var(--dsw-alias-label-secondary, #666);
17
+ font-size: 12px; line-height: 1; cursor: pointer;
18
+ }
19
+ .cnvnav-toggle:hover {
20
+ background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12));
21
+ color: var(--dsw-alias-label-primary, #222);
22
+ }
23
+ .cnvnav-toggle-on {
24
+ color: var(--dsw-alias-brand-primary, #4f46e5);
25
+ border-color: var(--dsw-alias-brand-primary, #4f46e5);
26
+ }
27
+ .cnvnav-toggle svg { display: block; }
28
+ .cnvnav-panel {
29
+ position: fixed; z-index: 9999; width: 288px;
30
+ display: flex; flex-direction: column;
31
+ pointer-events: auto;
32
+ background: var(--dsw-alias-bg-overlay, #fff);
33
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35));
34
+ border-radius: 12px;
35
+ box-shadow: 0 16px 48px rgba(0,0,0,.22);
36
+ font-family: system-ui, -apple-system, 'Segoe UI', 'Microsoft YaHei', sans-serif;
37
+ font-size: 12px;
38
+ color: var(--dsw-alias-label-primary, #222);
39
+ overflow: hidden;
40
+ }
41
+ .cnvnav-head {
42
+ display: flex; align-items: center; gap: 8px;
43
+ padding: 10px 12px;
44
+ border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35));
45
+ }
46
+ .cnvnav-title { font-weight: 600; font-size: 13px; }
47
+ .cnvnav-count { color: var(--dsw-alias-label-secondary, #888); font-size: 11px; }
48
+ .cnvnav-close {
49
+ margin-left: auto; border: none; background: transparent;
50
+ color: var(--dsw-alias-label-secondary, #888); cursor: pointer;
51
+ font-size: 16px; line-height: 1; padding: 2px 7px; border-radius: 6px;
52
+ }
53
+ .cnvnav-close:hover {
54
+ background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12));
55
+ color: var(--dsw-alias-label-primary, #222);
56
+ }
57
+ .cnvnav-list {
58
+ overflow-y: auto; padding: 6px; min-height: 0;
59
+ display: flex; flex-direction: column; gap: 2px;
60
+ scrollbar-width: thin;
61
+ }
62
+ .cnvnav-group { display: flex; flex-direction: column; gap: 2px; margin-bottom: 2px; }
63
+ .cnvnav-group-row {
64
+ display: flex; align-items: stretch;
65
+ border-radius: 8px; overflow: hidden;
66
+ }
67
+ .cnvnav-group-row:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12)); }
68
+ .cnvnav-group-row-active { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12)); }
69
+ .cnvnav-group-row-active .cnvnav-group-title { color: var(--dsw-alias-brand-primary, #4f46e5); }
70
+ .cnvnav-group-head {
71
+ flex: 1; min-width: 0;
72
+ display: flex; align-items: baseline; gap: 6px; text-align: left;
73
+ border: none; background: transparent; cursor: pointer;
74
+ padding: 5px 0 5px 8px;
75
+ color: var(--dsw-alias-label-primary, #222);
76
+ }
77
+ .cnvnav-group-title { font-weight: 600; white-space: nowrap; }
78
+ .cnvnav-group-sub {
79
+ color: var(--dsw-alias-label-secondary, #888);
80
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
81
+ flex: 1; min-width: 0;
82
+ }
83
+ .cnvnav-chevron {
84
+ flex: none; min-width: 30px;
85
+ display: flex; align-items: center; justify-content: center;
86
+ border: none; background: transparent; cursor: pointer;
87
+ color: var(--dsw-alias-label-secondary, #888);
88
+ font-size: 11px; line-height: 1; padding: 0 8px;
89
+ white-space: nowrap;
90
+ }
91
+ .cnvnav-chevron:hover { color: var(--dsw-alias-brand-primary, #4f46e5); background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12)); }
92
+ .cnvnav-item {
93
+ display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
94
+ border: none; background: transparent; cursor: pointer;
95
+ padding: 5px 8px 5px 22px; border-radius: 8px; position: relative;
96
+ color: var(--dsw-alias-label-primary, #222);
97
+ }
98
+ .cnvnav-item:hover { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12)); }
99
+ .cnvnav-item-active { background: var(--dsw-alias-bg-layer-2, rgba(128,128,128,.12)); }
100
+ .cnvnav-item-active::before {
101
+ content: ''; position: absolute; left: 6px; top: 6px; bottom: 6px;
102
+ width: 3px; border-radius: 2px;
103
+ background: var(--dsw-alias-brand-primary, #4f46e5);
104
+ }
105
+ .cnvnav-badge {
106
+ flex: none; min-width: 36px; box-sizing: border-box;
107
+ display: inline-flex; align-items: center; justify-content: center;
108
+ height: 19px; padding: 0 5px; border-radius: 4px;
109
+ font-size: 10px; font-weight: 650; line-height: 1;
110
+ letter-spacing: .035em; white-space: nowrap;
111
+ }
112
+ .cnvnav-badge-traj-user { color: var(--dsw-alias-state-business-primary, #4f46e5); background: var(--dsw-alias-state-business-tertiary, rgba(79,70,229,.14)); }
113
+ .cnvnav-badge-traj-context { color: color-mix(in srgb, var(--dsw-alias-state-success-primary, #0e9f6e) 68%, var(--dsw-alias-label-secondary, #888)); background: var(--dsw-alias-state-success-tertiary, rgba(14,159,110,.14)); }
114
+ .cnvnav-badge-traj-assistant { color: color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color, #7c3aed) 60%, var(--dsw-alias-state-error-secondary, #b91c1c)); background: color-mix(in srgb, color-mix(in srgb, var(--dsw-alias-brand-primary-new-colorprimary-new-color, #7c3aed) 55%, var(--dsw-alias-state-error-secondary, #b91c1c)) 15%, var(--dsw-alias-bg-layer-1, transparent)); }
115
+ .cnvnav-badge-traj-tool { color: var(--dsw-alias-state-warn-label, #b45309); background: var(--dsw-alias-state-warn-tertiary, rgba(180,83,9,.14)); }
116
+ .cnvnav-badge-traj-compacted { color: var(--dsw-alias-label-secondary, #888); background: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.12)); }
117
+ .cnvnav-badge-other-command { color: #1d4ed8; background: rgba(29,78,216,.13); }
118
+ .cnvnav-badge-other-error { color: var(--dsw-alias-state-error-primary, #dc2626); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #dc2626) 14%, transparent); }
119
+ .cnvnav-badge-other-retry { color: #ea580c; background: rgba(234,88,12,.14); }
120
+ .cnvnav-badge-other-neutral { color: var(--dsw-alias-label-secondary, #888); background: var(--dsw-alias-bg-module-platform, rgba(128,128,128,.12)); }
121
+ .cnvnav-item-preview {
122
+ flex: 1; min-width: 0; display: block;
123
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
124
+ color: var(--dsw-alias-label-secondary, #888);
125
+ }
126
+ .cnvnav-item-time {
127
+ flex: none; color: var(--dsw-alias-label-secondary, #888); font-size: 10px; opacity: .8;
128
+ }
129
+ .cnvnav-empty { padding: 18px 10px; text-align: center; color: var(--dsw-alias-label-secondary, #888); }
130
+ .cnvnav-footer { display: flex; gap: 6px; margin: 0 6px 6px; }
131
+ .cnvnav-bottom {
132
+ flex: 1; padding: 7px; border-radius: 8px;
133
+ border: 1px solid var(--dsw-alias-border-l1, rgba(128,128,128,.35));
134
+ background: var(--dsw-alias-bg-layer-1, rgba(128,128,128,.06));
135
+ color: var(--dsw-alias-label-primary, #222); cursor: pointer; font-size: 12px;
136
+ }
137
+ .cnvnav-bottom:hover { border-color: var(--dsw-alias-brand-primary, #4f46e5); color: var(--dsw-alias-brand-primary, #4f46e5); }
138
+ `;
139
+
140
+ function apply(ctx) {
141
+ const slots = ctx.get("slots");
142
+ if (slots === undefined) return;
143
+ const timer = ctx.get("timer");
144
+
145
+ /* own stylesheet with fiber-scoped cleanup */
146
+ const styleEl = document.createElement("style");
147
+ styleEl.setAttribute("data-dsh-cnvnav", "");
148
+ styleEl.textContent = CSS;
149
+ document.head.appendChild(styleEl);
150
+ ctx.effect(() => () => styleEl.remove());
151
+
152
+ /* ---------- shared store (toggle button + overlay panel) ---------- */
153
+ let state = { open: true, sessionId: null, outline: null, activeKey: null, expanded: {} };
154
+ const listeners = new Set();
155
+ function setState(patch) {
156
+ state = Object.assign({}, state, patch);
157
+ listeners.forEach((fn) => { try { fn(); } catch (err) { console.error(err); } });
158
+ }
159
+ function subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }
160
+ function useStore() {
161
+ const [snap, setSnap] = React.useState(state);
162
+ React.useEffect(() => subscribe(() => setSnap(state)), []);
163
+ return snap;
164
+ }
165
+
166
+ /* ---------- outline derivation from ConversationSnapshot ---------- */
167
+ const KIND_META = {
168
+ "user": { label: "你", cls: "traj-user" },
169
+ "steering": { label: "插话", cls: "traj-user" },
170
+ "context": { label: "上下文", cls: "traj-context" },
171
+ "assistant-step": { label: "助手", cls: "traj-assistant" },
172
+ "tool-call": { label: "工具", cls: "traj-tool" },
173
+ "command": { label: "命令", cls: "other-command" },
174
+ "compaction": { label: "压缩", cls: "traj-compacted" },
175
+ "manual-compaction": { label: "压缩", cls: "traj-compacted" },
176
+ "turn-error": { label: "出错", cls: "other-error" },
177
+ "turn-max-tokens": { label: "截断", cls: "other-error" },
178
+ "model-retry": { label: "重试", cls: "other-retry" },
179
+ "workflow-run": { label: "流程", cls: "other-neutral" },
180
+ "unknown": { label: "其他", cls: "other-neutral" },
181
+ };
182
+ function firstText(blocks) {
183
+ if (!Array.isArray(blocks)) return null;
184
+ for (const b of blocks) {
185
+ if (!b || typeof b !== "object") continue;
186
+ if ((b.type === "text" || b.kind === "text") && typeof b.text === "string" && b.text.trim() !== "") return b.text;
187
+ }
188
+ return null;
189
+ }
190
+ function previewFor(node) {
191
+ const d = node && node.data;
192
+ if (!d || typeof d !== "object") return null;
193
+ switch (node.kind) {
194
+ case "user":
195
+ case "steering":
196
+ case "context":
197
+ return firstText(d.content);
198
+ case "assistant-step": {
199
+ const text = firstText(d.blocks);
200
+ if (text !== null) return text;
201
+ if (Array.isArray(d.blocks) && d.blocks.some((b) => b && b.kind === "tool-call")) return "调用工具";
202
+ return d.status === "running" ? "进行中…" : null;
203
+ }
204
+ case "tool-call": {
205
+ const root = d.root;
206
+ if (!root || typeof root !== "object") return null;
207
+ if (root.isError === true) return "执行失败";
208
+ const name = typeof root.name === "string" ? root.name
209
+ : (root.call && typeof root.call.name === "string" ? root.call.name : null);
210
+ return name;
211
+ }
212
+ case "command":
213
+ return typeof d.name === "string" ? d.name + (typeof d.args === "string" && d.args !== "" ? " " + d.args : "") : null;
214
+ case "compaction":
215
+ return typeof d.summary === "string" ? d.summary : "上下文已压缩";
216
+ case "manual-compaction": {
217
+ const c = d.compaction;
218
+ if (c && typeof c.summary === "string") return c.summary;
219
+ return "手动压缩";
220
+ }
221
+ case "turn-error":
222
+ return typeof d.message === "string" ? d.message : "本轮出错";
223
+ case "turn-max-tokens":
224
+ return "达到输出上限";
225
+ case "model-retry": {
226
+ const n = Array.isArray(d.attempts) ? d.attempts.length : 0;
227
+ return "自动重试" + (n > 0 ? " ×" + n : "…");
228
+ }
229
+ case "unknown":
230
+ return typeof d.type === "string" ? "未知事件 " + d.type : "未知事件";
231
+ default:
232
+ return null;
233
+ }
234
+ }
235
+ function timeOf(node) {
236
+ const d = node && node.data;
237
+ if (!d || typeof d !== "object") return null;
238
+ const t = typeof d.time === "number" ? d.time
239
+ : (d.root && typeof d.root.time === "number" ? d.root.time : null);
240
+ return t;
241
+ }
242
+ function fmtTime(ms) {
243
+ if (typeof ms !== "number" || !isFinite(ms)) return "";
244
+ const d = new Date(ms);
245
+ const h = d.getHours();
246
+ const m = d.getMinutes();
247
+ return (h < 10 ? "0" + h : h) + ":" + (m < 10 ? "0" + m : m);
248
+ }
249
+ function turnOf(node) {
250
+ const loc = node && node.location;
251
+ if (!loc) return null;
252
+ if (loc.kind === "step" || loc.kind === "turn") return loc.turn.turn;
253
+ return null;
254
+ }
255
+ function truncate(text, max) {
256
+ if (typeof text !== "string") return "";
257
+ if (text.length <= max) return text;
258
+ return text.slice(0, max) + "…";
259
+ }
260
+ function deriveOutline(snap) {
261
+ const empty = { groups: [], count: 0, byKey: {} };
262
+ if (snap === null || snap === undefined) return empty;
263
+ const order = snap.chat && snap.chat.order;
264
+ const nodes = snap.chat && snap.chat.nodes;
265
+ if (!Array.isArray(order) || nodes === undefined) return empty;
266
+ const groups = [];
267
+ const byKey = {};
268
+ let current = null;
269
+ let count = 0;
270
+ for (const key of order) {
271
+ const node = nodes.get(key);
272
+ if (node === undefined) continue;
273
+ const meta = KIND_META[node.kind] || KIND_META.unknown;
274
+ const item = {
275
+ key: node.key,
276
+ kind: node.kind,
277
+ label: meta.label,
278
+ cls: meta.cls,
279
+ preview: previewFor(node),
280
+ time: fmtTime(timeOf(node)),
281
+ };
282
+ const turn = turnOf(node);
283
+ if (turn === null) {
284
+ groups.push({ turn: null, items: [item], gid: "x" + groups.length });
285
+ count += 1;
286
+ current = null;
287
+ continue;
288
+ }
289
+ if (current === null || current.turn !== turn) {
290
+ current = { turn: turn, items: [], gid: "t" + turn };
291
+ groups.push(current);
292
+ }
293
+ current.items.push(item);
294
+ count += 1;
295
+ }
296
+ for (const g of groups) {
297
+ const first = g.items[0];
298
+ if (g.turn === null) {
299
+ g.title = first.label;
300
+ g.subtitle = truncate(first.preview || "", 60);
301
+ g.headKey = first.key;
302
+ g.headIsUser = false;
303
+ } else {
304
+ g.title = "第 " + g.turn + " 轮";
305
+ const head = first.kind === "user" ? first : null;
306
+ g.headKey = head ? head.key : first.key;
307
+ g.headIsUser = head !== null;
308
+ g.subtitle = truncate((head ? head.preview : first.preview) || "", 60);
309
+ }
310
+ for (const it of g.items) byKey[it.key] = g.gid;
311
+ }
312
+ return { groups: groups, count: count, byKey: byKey };
313
+ }
314
+ let cacheKey = null;
315
+ let cacheValue = null;
316
+ function selectOutline(snap) {
317
+ if (snap !== cacheKey) {
318
+ cacheKey = snap;
319
+ cacheValue = deriveOutline(snap);
320
+ }
321
+ return cacheValue;
322
+ }
323
+
324
+ /* ---------- DOM helpers: jump + position tracking ---------- */
325
+ function findAnchor(key) {
326
+ const rows = document.querySelectorAll("[data-chat-anchor-key]");
327
+ for (const row of rows) {
328
+ if (row.getAttribute("data-chat-anchor-key") === key) return row;
329
+ }
330
+ return null;
331
+ }
332
+ let lastActive = null;
333
+ function jumpTo(key) {
334
+ try {
335
+ const el = findAnchor(key);
336
+ if (el === null) return false;
337
+ el.scrollIntoView({ behavior: "smooth", block: "start" });
338
+ lastActive = key;
339
+ setState({ activeKey: key });
340
+ return true;
341
+ } catch (err) {
342
+ console.error(err);
343
+ return false;
344
+ }
345
+ }
346
+ function computeActiveKey() {
347
+ try {
348
+ const scrollport = document.querySelector("[data-conversation-scroll]");
349
+ if (scrollport === null) return null;
350
+ const vp = scrollport.getBoundingClientRect();
351
+ const rows = document.querySelectorAll("[data-chat-anchor-key]");
352
+ let found = null;
353
+ for (const row of rows) {
354
+ const rect = row.getBoundingClientRect();
355
+ if (rect.bottom > vp.top) { found = row; break; }
356
+ }
357
+ if (found === null && rows.length > 0) found = rows[rows.length - 1];
358
+ return found === null ? null : found.getAttribute("data-chat-anchor-key");
359
+ } catch (err) {
360
+ console.error(err);
361
+ return null;
362
+ }
363
+ }
364
+
365
+ let panelEl = null;
366
+ let listEl = null;
367
+ const itemEls = {};
368
+ const groupEls = {};
369
+
370
+ /* ---------- header toggle button ---------- */
371
+ function ToggleIcon() {
372
+ return React.createElement("svg", {
373
+ viewBox: "0 0 16 16", width: 14, height: 14, fill: "none",
374
+ stroke: "currentColor", strokeWidth: 1.4, "aria-hidden": true,
375
+ },
376
+ React.createElement("circle", { cx: 3, cy: 3.5, r: 1.1, fill: "currentColor", stroke: "none" }),
377
+ React.createElement("path", { d: "M6.2 3.5h7.8M6.2 8h7.8M6.2 12.5h7.8", strokeLinecap: "round" }),
378
+ React.createElement("circle", { cx: 3, cy: 8, r: 1.1, fill: "currentColor", stroke: "none" }),
379
+ React.createElement("circle", { cx: 3, cy: 12.5, r: 1.1, fill: "currentColor", stroke: "none" }),
380
+ );
381
+ }
382
+ function ToggleButton(props) {
383
+ const snap = useStore();
384
+ const outline = props.useSession(selectOutline);
385
+ React.useEffect(() => {
386
+ setState({ sessionId: props.sessionId, outline: outline });
387
+ }, [outline, props.sessionId]);
388
+ React.useEffect(() => {
389
+ if (state.sessionId !== null && state.sessionId !== props.sessionId) {
390
+ lastActive = null;
391
+ setState({ activeKey: null, expanded: {} });
392
+ }
393
+ }, [props.sessionId]);
394
+ return React.createElement("button", {
395
+ type: "button",
396
+ className: "cnvnav-toggle" + (snap.open ? " cnvnav-toggle-on" : ""),
397
+ "aria-pressed": snap.open,
398
+ title: snap.open ? "收起会话导航" : "打开会话导航",
399
+ onClick: () => setState({ open: !snap.open }),
400
+ }, React.createElement(ToggleIcon), "导航");
401
+ }
402
+
403
+ /* ---------- floating navigator panel ---------- */
404
+ function Panel() {
405
+ const snap = useStore();
406
+
407
+ React.useEffect(() => {
408
+ if (!snap.open) return;
409
+ function place() {
410
+ const panel = panelEl;
411
+ if (panel === null) return;
412
+ const vw = document.documentElement.clientWidth || 1000;
413
+ const vh = document.documentElement.clientHeight || 800;
414
+ const sp = document.querySelector("[data-conversation-scroll]");
415
+ const rect = sp !== null ? sp.getBoundingClientRect() : { left: 300, top: 60, height: vh - 90 };
416
+ const width = 288;
417
+ let left = rect.left - width - 12;
418
+ if (left < 12) left = rect.left + 12;
419
+ if (left + width > vw - 8) left = Math.max(8, vw - width - 8);
420
+ panel.style.left = Math.round(left) + "px";
421
+ panel.style.top = Math.max(8, Math.round(rect.top + 8)) + "px";
422
+ panel.style.maxHeight = Math.round(Math.min(rect.height - 24, vh - 40)) + "px";
423
+ }
424
+ place();
425
+ window.addEventListener("resize", place);
426
+ return () => window.removeEventListener("resize", place);
427
+ }, [snap.open]);
428
+
429
+ React.useEffect(() => {
430
+ if (!snap.open) return;
431
+ const run = () => {
432
+ const key = computeActiveKey();
433
+ if (key !== lastActive) {
434
+ lastActive = key;
435
+ setState({ activeKey: key });
436
+ }
437
+ };
438
+ const update = timer !== undefined ? timer.throttle(run, 120) : run;
439
+ document.addEventListener("scroll", update, true);
440
+ window.addEventListener("resize", update);
441
+ run();
442
+ return () => {
443
+ document.removeEventListener("scroll", update, true);
444
+ window.removeEventListener("resize", update);
445
+ if (timer !== undefined && typeof update.dispose === "function") update.dispose();
446
+ };
447
+ }, [snap.open]);
448
+
449
+ React.useEffect(() => {
450
+ if (!snap.open || snap.activeKey === null) return;
451
+ let target = itemEls[snap.activeKey];
452
+ if (target === undefined || target === null) {
453
+ const gid = snap.outline !== null ? snap.outline.byKey[snap.activeKey] : undefined;
454
+ if (gid !== undefined) target = groupEls[gid];
455
+ }
456
+ const list = listEl;
457
+ if (target !== undefined && target !== null && list !== null) {
458
+ const lr = list.getBoundingClientRect();
459
+ const er = target.getBoundingClientRect();
460
+ const rel = er.top - lr.top + list.scrollTop;
461
+ if (rel < list.scrollTop || rel + er.height > list.scrollTop + list.clientHeight) {
462
+ list.scrollTop = Math.max(0, rel - list.clientHeight / 2);
463
+ }
464
+ }
465
+ }, [snap.open, snap.activeKey]);
466
+
467
+ if (!snap.open) return null;
468
+
469
+ const groups = snap.outline !== null ? snap.outline.groups : [];
470
+ const count = snap.outline !== null ? snap.outline.count : 0;
471
+
472
+ const groupNodes = groups.map((g) => {
473
+ const expanded = snap.expanded[g.gid] === true;
474
+ const isTurnGroup = g.turn !== null;
475
+ const stepItems = isTurnGroup ? (g.headIsUser ? g.items.slice(1) : g.items) : [];
476
+ const hasSteps = stepItems.length > 0;
477
+ const groupActive = g.items.some((it) => it.key === snap.activeKey);
478
+ return React.createElement("div", { key: g.gid, className: "cnvnav-group" },
479
+ React.createElement("div", {
480
+ ref: (el) => { groupEls[g.gid] = el; },
481
+ className: "cnvnav-group-row" + (groupActive ? " cnvnav-group-row-active" : ""),
482
+ },
483
+ React.createElement("button", {
484
+ type: "button",
485
+ className: "cnvnav-group-head",
486
+ title: "跳转到此位置",
487
+ onClick: () => jumpTo(g.headKey),
488
+ },
489
+ React.createElement("span", { className: "cnvnav-group-title" }, g.title),
490
+ g.subtitle !== ""
491
+ ? React.createElement("span", { className: "cnvnav-group-sub" }, g.subtitle)
492
+ : null,
493
+ ),
494
+ hasSteps
495
+ ? React.createElement("button", {
496
+ type: "button",
497
+ className: "cnvnav-chevron",
498
+ title: expanded ? "折叠步次" : "展开步次",
499
+ "aria-expanded": expanded,
500
+ onClick: (e) => {
501
+ e.stopPropagation();
502
+ setState({ expanded: Object.assign({}, state.expanded, { [g.gid]: !expanded }) });
503
+ },
504
+ },
505
+ expanded ? "▾" : "▸ " + stepItems.length,
506
+ )
507
+ : null,
508
+ ),
509
+ expanded && hasSteps
510
+ ? stepItems.map((item) =>
511
+ React.createElement("button", {
512
+ key: item.key,
513
+ type: "button",
514
+ ref: (el) => { itemEls[item.key] = el; },
515
+ className: "cnvnav-item" + (item.key === snap.activeKey ? " cnvnav-item-active" : ""),
516
+ onClick: () => jumpTo(item.key),
517
+ },
518
+ React.createElement("span", { className: "cnvnav-badge cnvnav-badge-" + item.cls }, item.label),
519
+ item.preview !== null && item.preview !== ""
520
+ ? React.createElement("span", { className: "cnvnav-item-preview" }, item.preview)
521
+ : null,
522
+ item.time !== ""
523
+ ? React.createElement("span", { className: "cnvnav-item-time" }, item.time)
524
+ : null,
525
+ ),
526
+ )
527
+ : null,
528
+ );
529
+ });
530
+
531
+ return React.createElement("div", {
532
+ ref: (el) => { panelEl = el; },
533
+ className: "cnvnav-panel",
534
+ role: "complementary",
535
+ "aria-label": "会话导航",
536
+ },
537
+ React.createElement("div", { className: "cnvnav-head" },
538
+ React.createElement("span", { className: "cnvnav-title" }, "会话导航"),
539
+ React.createElement("span", { className: "cnvnav-count" }, String(count) + " 条"),
540
+ React.createElement("button", {
541
+ type: "button",
542
+ className: "cnvnav-close",
543
+ title: "收起",
544
+ onClick: () => setState({ open: false }),
545
+ }, "×"),
546
+ ),
547
+ React.createElement("div", {
548
+ ref: (el) => { listEl = el; },
549
+ className: "cnvnav-list",
550
+ },
551
+ groups.length === 0
552
+ ? React.createElement("div", { className: "cnvnav-empty" }, "当前会话暂无对话节点")
553
+ : groupNodes,
554
+ ),
555
+ React.createElement("div", { className: "cnvnav-footer" },
556
+ React.createElement("button", {
557
+ type: "button",
558
+ className: "cnvnav-bottom",
559
+ onClick: () => {
560
+ const gs = snap.outline !== null ? snap.outline.groups : [];
561
+ if (gs.length === 0) return;
562
+ const lastGroup = gs[gs.length - 1];
563
+ const lastItem = lastGroup.items[lastGroup.items.length - 1];
564
+ if (lastItem !== undefined) jumpTo(lastItem.key);
565
+ },
566
+ }, "↓ 回到最新"),
567
+ React.createElement("button", {
568
+ type: "button",
569
+ className: "cnvnav-bottom",
570
+ title: "折叠所有已展开的轮次",
571
+ onClick: () => setState({ expanded: {} }),
572
+ }, "▼ 全部折叠"),
573
+ ),
574
+ );
575
+ }
576
+
577
+ /* ---------- register ---------- */
578
+ slots.inject("conversation.session.header.utilities", () => slots.register(
579
+ { name: "conversation.session.header.utilities", id: "conversation-navigator-toggle", order: 20, label: () => "会话导航" },
580
+ (props) => React.createElement(ToggleButton, props),
581
+ ));
582
+ slots.inject("shell.overlay", () => slots.register(
583
+ { name: "shell.overlay", id: "conversation-navigator-panel", order: 10, label: () => "会话导航" },
584
+ () => React.createElement(Panel),
585
+ ));
586
+ }
587
+
588
+ const inject = ["slots"];
589
+ exports.apply = apply;
590
+ exports.inject = inject;
591
+ return module.exports;
592
+ }
593
+ });
package/lib/index.js ADDED
@@ -0,0 +1,4 @@
1
+ /** Host loader entry for the browser-only conversation navigator plugin. */
2
+ /** Provides no host-side behavior. */
3
+ function apply() {}
4
+ export { apply };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "dsh-conversation-navigator",
3
+ "version": "0.1.0",
4
+ "description": "Codex 风格的 DSH(DeepSeek Harness)会话导航插件:按轮折叠的对话大纲面板,支持节点点击跳转、滚动位置高亮与轨迹视图同款配色。",
5
+ "keywords": [
6
+ "dsh",
7
+ "deepseek-harness",
8
+ "cordis",
9
+ "cordis-plugin",
10
+ "conversation",
11
+ "navigator",
12
+ "outline",
13
+ "session",
14
+ "会话导航"
15
+ ],
16
+ "license": "MIT",
17
+ "type": "module",
18
+ "main": "lib/index.js",
19
+ "exports": {
20
+ ".": "./lib/index.js",
21
+ "./client": "./lib/client.js",
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "lib",
26
+ "cordis.patch.yml"
27
+ ],
28
+ "engines": {
29
+ "node": ">=18"
30
+ },
31
+ "dsh": {
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "platform": "web",
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-runtime",
39
+ "@deepseek-ai/dsh-client-ui-conversation"
40
+ ]
41
+ }
42
+ }
43
+ }