@lament_z/dsh-client-ui-chat-timeline 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.
@@ -0,0 +1,10 @@
1
+ # Pairing record for the plugin README (docs/i18n.md contract).
2
+ source: README.md
3
+ target: README.zh.md
4
+ sections:
5
+ - title
6
+ - intro
7
+ - requirements
8
+ - install
9
+ - notes
10
+ - license
package/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # dsh-client-ui-chat-timeline
2
+
3
+ A DSH web GUI plugin that adds a left-edge question navigator rail to the conversation: one tick per human question, a hover ripple that lengthens nearby ticks, a preview card per turn (user message plus assistant reply excerpt), click to jump, and a scroll-synced highlight of the current turn.
4
+
5
+ The interaction design replicates the ZCode desktop client's TurnNavigator (reverse-engineered from its 3.10.1 renderer bundle; see the workspace spec `.scratch/chat-timeline/spec.md`): 10px tick pitch, ripple `scaleX 2.6/1.7/1.25` at opacity `1/.86/.72/.58` over 150ms, 320px preview cards after a 120ms delay (80ms close), hidden below 2 questions or a 864px conversation width, `prefers-reduced-motion` turns jumps instant, full `aria` labelling.
6
+
7
+ ## Requirements
8
+
9
+ - DSH `>=0.1.1-rc.1` (built and tested against `0.1.1-rc.2`).
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ dsh plugin --profile web add link:<this directory>
15
+ ```
16
+
17
+ Then restart `dsh web` and reload the page. The rail appears beside any conversation with at least two of your questions once the window is wide enough.
18
+
19
+ ## Notes
20
+
21
+ - Pure browser plugin: the host half is an empty cordis plugin.
22
+ - Data comes from the public client-runtime contracts (`ctx.sessions` + `ConversationSnapshot`); jump and scroll-sync probe the chat DOM through stable `data-*` hooks and degrade to a display-only rail when detection fails.
23
+ - Colors follow the page color scheme (`Canvas`/`CanvasText`), no theme token coupling.
24
+
25
+ ## License
26
+
27
+ MIT
package/README.zh.md ADDED
@@ -0,0 +1,27 @@
1
+ # dsh-client-ui-chat-timeline
2
+
3
+ DSH Web GUI 插件:在会话区左缘加一条"问题导航时间线"——每个刻度对应一条你的提问,悬停时邻近刻度涟漪式变长,弹出该轮预览卡(用户消息 + 助手回复摘要),点击跳转,滚动时高亮当前回合。
4
+
5
+ 交互设计复刻自 ZCode 桌面客户端的 TurnNavigator(从其 3.10.1 渲染层 bundle 逆向;见工作区 spec `.scratch/chat-timeline/spec.md`):10px 刻度间距、涟漪 `scaleX 2.6/1.7/1.25`、不透明度 `1/.86/.72/.58`、150ms 过渡、预览卡 320px(120ms 开 / 80ms 关)、少于 2 条提问或会话区窄于 864px 时隐藏、`prefers-reduced-motion` 时跳转改为瞬时、完整 `aria` 标注。
6
+
7
+ ## 要求
8
+
9
+ - DSH `>=0.1.1-rc.1`(构建与测试基于 `0.1.1-rc.2`)。
10
+
11
+ ## 安装
12
+
13
+ ```sh
14
+ dsh plugin --profile web add link:<本目录>
15
+ ```
16
+
17
+ 然后重启 `dsh web` 并刷新页面。会话中至少有两条你的提问且窗口足够宽时,轨道出现在会话区左缘。
18
+
19
+ ## 说明
20
+
21
+ - 纯浏览器插件:host 半区为空 cordis 插件。
22
+ - 数据全部走 client-runtime 公开契约(`ctx.sessions` + `ConversationSnapshot`);跳转与滚动同步通过稳定的 `data-*` 钩子探测聊天 DOM,探测失败时降级为纯展示轨。
23
+ - 颜色跟随页面配色(`Canvas`/`CanvasText`),不耦合主题 token。
24
+
25
+ ## 许可
26
+
27
+ MIT
@@ -0,0 +1,7 @@
1
+ # bundle patch for the chat-timeline plugin: inserts its plugin row into the web
2
+ # profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
3
+ # manifest field); install with
4
+ # `dsh plugin --profile web add link:<repo>/plugin`.
5
+ - insert:
6
+ - id: ui-chat-timeline
7
+ name: '@lament_z/dsh-client-ui-chat-timeline'
package/lib/client.js ADDED
@@ -0,0 +1,767 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@lament_z/dsh-client-ui-chat-timeline",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let react_dom = require("react-dom");
9
+ let react_jsx_runtime = require("react/jsx-runtime");
10
+ /** Human node kinds that anchor a tick. */
11
+ const HUMAN_KINDS = /* @__PURE__ */ new Set(["user", "steering"]);
12
+ /** Extract concatenated text from a ContentBlock-like list.
13
+ * dsh-llm content blocks switch on `type`; assistant blocks use `kind` — accept both. */
14
+ function blocksText(blocks) {
15
+ if (!Array.isArray(blocks)) return "";
16
+ const parts = [];
17
+ for (const block of blocks) if (block !== null && typeof block === "object") {
18
+ const record = block;
19
+ if (record.kind === "text" || record.type === "text") {
20
+ if (typeof record.text === "string" && record.text.length > 0) parts.push(record.text);
21
+ }
22
+ }
23
+ return parts.join("\n\n");
24
+ }
25
+ /** Collapse each paragraph's whitespace and drop empties (ZCode u5e parity). */
26
+ function collapseParagraphs(text, maxParagraphs) {
27
+ return text.trim().split(/\n\s*\n/u).map((paragraph) => paragraph.replace(/\s+/gu, " ").trim()).filter(Boolean).slice(0, Math.max(1, maxParagraphs));
28
+ }
29
+ /** Truncate to the budget with an ellipsis, never below the floor (ZCode d5e parity). */
30
+ function truncatePreview(text, maxChars, floor = 8) {
31
+ const budget = Math.max(floor, maxChars);
32
+ if (text.length <= budget) return text;
33
+ return `${text.slice(0, budget - 3).trimEnd()}...`;
34
+ }
35
+ /** Build one preview string from raw texts under the shared budget. */
36
+ function buildPreview(texts, fallback) {
37
+ const paragraphs = collapseParagraphs(texts.join("\n\n"), 2);
38
+ if (paragraphs.length === 0) return fallback;
39
+ return truncatePreview(paragraphs.join("\n"), 220);
40
+ }
41
+ /**
42
+ * Derive the tick directory from the snapshot's ordered node list.
43
+ * @param nodes - conversation snapshot nodes in seq order.
44
+ * @param running - whether the session currently has a running turn.
45
+ * @param labels - localized preview fallbacks.
46
+ * @returns one item per human input, in order.
47
+ */
48
+ function buildTimelineItems(nodes, running, labels) {
49
+ const drafts = [];
50
+ for (const node of nodes) {
51
+ if (HUMAN_KINDS.has(node.kind)) {
52
+ const text = blocksText(node.content);
53
+ drafts.push({
54
+ key: `n${node.seq}`,
55
+ seq: node.seq,
56
+ time: typeof node.time === "number" ? node.time : 0,
57
+ userTexts: text === "" ? [] : [text],
58
+ hasUserText: text !== "",
59
+ assistantTexts: []
60
+ });
61
+ continue;
62
+ }
63
+ if (node.kind !== "assistant" || drafts.length === 0) continue;
64
+ const text = blocksText(node.blocks);
65
+ if (text !== "") drafts[drafts.length - 1].assistantTexts.push(text);
66
+ }
67
+ const last = drafts.length - 1;
68
+ return drafts.map((draft, index) => {
69
+ const isLive = running && index === last;
70
+ const assistantTexts = draft.assistantTexts;
71
+ const assistantKind = assistantTexts.length > 0 ? "text" : isLive ? "running" : "empty";
72
+ return {
73
+ key: draft.key,
74
+ seq: draft.seq,
75
+ time: draft.time,
76
+ userPreview: buildPreview(draft.userTexts, labels.userFallback),
77
+ userFallback: !draft.hasUserText,
78
+ assistantPreview: buildPreview(assistantTexts, isLive ? labels.assistantRunning : labels.assistantEmpty),
79
+ assistantKind,
80
+ running: isLive
81
+ };
82
+ });
83
+ }
84
+ //#endregion
85
+ //#region src/client/dom.ts
86
+ /**
87
+ * Find the chat scroll container with a candidate chain.
88
+ * 1. the explicit stable hook `[data-conversation-scroll]`;
89
+ * 2. the closest scrollable ancestor of the flow column `[data-chat-flow]`;
90
+ * 3. the largest scrollable element that contains human rows.
91
+ */
92
+ function findChatContainer(doc) {
93
+ const direct = doc.querySelector("[data-conversation-scroll]");
94
+ if (direct !== null) return direct;
95
+ const flow = doc.querySelector("[data-chat-flow]");
96
+ const ancestor = flow !== null ? scrollableAncestor(flow) : null;
97
+ if (ancestor !== null) return ancestor;
98
+ let best = null;
99
+ let bestHeight = 0;
100
+ for (const element of Array.from(doc.querySelectorAll("*"))) {
101
+ if (!isScrollable(element)) continue;
102
+ if (element.querySelector("[data-chat-flow-kind=\"user\"]") === null) continue;
103
+ const height = element.clientHeight;
104
+ if (height > bestHeight) {
105
+ best = element;
106
+ bestHeight = height;
107
+ }
108
+ }
109
+ return best;
110
+ }
111
+ function isScrollable(element) {
112
+ const style = element.ownerDocument.defaultView?.getComputedStyle(element);
113
+ if (style === void 0) return false;
114
+ if (!/(auto|scroll)/.test(style.overflowY)) return false;
115
+ return element.scrollHeight > element.clientHeight + 40 && element.clientHeight > 200;
116
+ }
117
+ function scrollableAncestor(element) {
118
+ let node = element;
119
+ while (node !== null) {
120
+ if (isScrollable(node)) return node;
121
+ node = node.parentElement;
122
+ }
123
+ return null;
124
+ }
125
+ /** Human-turn rows inside the container (falling back to a document-wide query). */
126
+ function findUserRows(container) {
127
+ const scope = container ?? document;
128
+ return Array.from(scope.querySelectorAll("[data-chat-flow-kind=\"user\"]"));
129
+ }
130
+ /**
131
+ * The active index: the last human row whose top edge sits above the
132
+ * container's reading line (40px below the top edge), matching the ZCode
133
+ * "unit at viewport top" rule; clamps to the last row at scroll bottom.
134
+ */
135
+ function computeActiveIndex(container, rows) {
136
+ if (rows.length === 0) return -1;
137
+ const top = container.getBoundingClientRect().top + 40;
138
+ let active = 0;
139
+ for (let index = 0; index < rows.length; index += 1) if (rows[index].getBoundingClientRect().top <= top) active = index;
140
+ else break;
141
+ if (container.scrollHeight - container.scrollTop - container.clientHeight <= 4) return rows.length - 1;
142
+ return active;
143
+ }
144
+ /**
145
+ * Scroll the given human turn into view. Tries a smooth scroll first; DSH's
146
+ * windowed conversation list programs scrollTop on scroll events, which can
147
+ * cancel a smooth animation on the first frame — when nothing has moved after
148
+ * a short grace period the jump falls back to an instant scroll.
149
+ * @returns false when the container or row is unavailable.
150
+ */
151
+ function jumpTo(index, behavior) {
152
+ const container = findChatContainer(document);
153
+ if (container === null) return false;
154
+ const row = findUserRows(container)[index];
155
+ if (row === void 0) return false;
156
+ const delta = row.getBoundingClientRect().top - container.getBoundingClientRect().top - 12;
157
+ const target = container.scrollTop + delta;
158
+ if (behavior === "auto") {
159
+ container.scrollTo({
160
+ top: target,
161
+ behavior: "auto"
162
+ });
163
+ return true;
164
+ }
165
+ const startedAt = container.scrollTop;
166
+ container.scrollTo({
167
+ top: target,
168
+ behavior: "smooth"
169
+ });
170
+ window.setTimeout(() => {
171
+ if (Math.abs(container.scrollTop - startedAt) < 4) container.scrollTo({
172
+ top: target,
173
+ behavior: "auto"
174
+ });
175
+ }, 700);
176
+ return true;
177
+ }
178
+ /** Create the probe against a document (injectable for tests). */
179
+ function probeChatDom(doc) {
180
+ return {
181
+ getContainer: () => findChatContainer(doc),
182
+ getUserRows: () => findUserRows(findChatContainer(doc)),
183
+ activeIndex: () => {
184
+ const container = findChatContainer(doc);
185
+ if (container === null) return -1;
186
+ return computeActiveIndex(container, findUserRows(container));
187
+ },
188
+ jumpTo: (index, behavior) => jumpTo(index, behavior)
189
+ };
190
+ }
191
+ //#endregion
192
+ //#region src/client/styles.ts
193
+ /**
194
+ * Stylesheet for the chat-timeline rail, injected once as a
195
+ * `<style data-plugin-css="chat-timeline">` tag. All classes carry the
196
+ * `dsh-tl-` prefix; colors use the CSS system colors Canvas/CanvasText so the
197
+ * rail follows the app's color-scheme without reaching into DSH theme tokens.
198
+ * Visual constants replicate the ZCode TurnNavigator (spec section 2.2).
199
+ */
200
+ const TIMELINE_STYLES = `
201
+ .dsh-tl-nav {
202
+ position: fixed;
203
+ z-index: 10;
204
+ width: 48px;
205
+ pointer-events: none;
206
+ opacity: 1;
207
+ transition: opacity 150ms ease-out;
208
+ }
209
+ .dsh-tl-nav[data-visible="false"] {
210
+ opacity: 0;
211
+ visibility: hidden;
212
+ }
213
+ .dsh-tl-scroll {
214
+ position: absolute;
215
+ left: 12px;
216
+ top: 50%;
217
+ transform: translateY(-50%);
218
+ width: 36px;
219
+ max-height: calc(100% - 96px);
220
+ overflow-x: hidden;
221
+ overflow-y: auto;
222
+ padding-block: 4px;
223
+ pointer-events: auto;
224
+ scrollbar-width: none;
225
+ color: CanvasText;
226
+ }
227
+ .dsh-tl-scroll::-webkit-scrollbar {
228
+ display: none;
229
+ }
230
+ .dsh-tl-track {
231
+ position: relative;
232
+ width: 36px;
233
+ }
234
+ .dsh-tl-slot {
235
+ position: absolute;
236
+ left: 0;
237
+ top: 0;
238
+ height: 10px;
239
+ width: 36px;
240
+ padding: 0;
241
+ border: 0;
242
+ background: transparent;
243
+ display: flex;
244
+ align-items: center;
245
+ justify-content: flex-start;
246
+ border-radius: 2px;
247
+ cursor: pointer;
248
+ }
249
+ .dsh-tl-slot:focus-visible {
250
+ outline: 2px solid CanvasText;
251
+ outline-offset: 2px;
252
+ }
253
+ .dsh-tl-tick {
254
+ display: block;
255
+ height: 2px;
256
+ width: 12px;
257
+ border-radius: 999px;
258
+ background: currentColor;
259
+ transform-origin: left center;
260
+ transition: height 150ms ease-out, opacity 150ms ease-out, transform 150ms ease-out, background-color 150ms ease-out;
261
+ }
262
+ @media (prefers-reduced-motion: reduce) {
263
+ .dsh-tl-nav,
264
+ .dsh-tl-tick {
265
+ transition: none;
266
+ }
267
+ }
268
+ .dsh-tl-tip {
269
+ position: fixed;
270
+ z-index: 60;
271
+ width: 320px;
272
+ max-width: calc(100vw - 2rem);
273
+ padding: 12px;
274
+ border-radius: 10px;
275
+ background: Canvas;
276
+ color: CanvasText;
277
+ border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);
278
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
279
+ pointer-events: none;
280
+ }
281
+ .dsh-tl-tip-user {
282
+ margin: 0;
283
+ display: -webkit-box;
284
+ -webkit-box-orient: vertical;
285
+ -webkit-line-clamp: 2;
286
+ overflow: hidden;
287
+ white-space: pre-line;
288
+ font-size: 13px;
289
+ line-height: 20px;
290
+ font-weight: 500;
291
+ }
292
+ .dsh-tl-tip-assistant {
293
+ margin: 8px 0 0;
294
+ display: -webkit-box;
295
+ -webkit-box-orient: vertical;
296
+ -webkit-line-clamp: 3;
297
+ overflow: hidden;
298
+ white-space: pre-line;
299
+ font-size: 13px;
300
+ line-height: 20px;
301
+ opacity: 0.8;
302
+ }
303
+ .dsh-tl-tip-assistant[data-kind="running"],
304
+ .dsh-tl-tip-assistant[data-kind="empty"] {
305
+ opacity: 0.55;
306
+ }
307
+ `;
308
+ /** Inject the stylesheet once per document. */
309
+ function ensureStyles(doc) {
310
+ const tagId = "chat-timeline";
311
+ if (doc.querySelector("style[data-plugin-css=\"chat-timeline\"]") !== null) return;
312
+ const tag = doc.createElement("style");
313
+ tag.dataset.plugin = "@lament_z/dsh-client-ui-chat-timeline";
314
+ tag.dataset.pluginCss = tagId;
315
+ tag.textContent = TIMELINE_STYLES;
316
+ doc.head.appendChild(tag);
317
+ }
318
+ //#endregion
319
+ //#region src/client/rail.tsx
320
+ /**
321
+ * TimelineRail — the shell.overlay entry: a left-edge tick rail over the
322
+ * conversation, one tick per human question. Visual and interaction constants
323
+ * replicate the ZCode TurnNavigator (see .scratch/chat-timeline/spec.md):
324
+ * hover ripple scaleX 2.6/1.7/1.25 with opacity 1/.86/.72/.58 over 150ms,
325
+ * 320px preview cards after a 120ms delay, >=2 questions to render, >=864px
326
+ * container width, prefers-reduced-motion fallback, full aria labelling.
327
+ */
328
+ /** Slot height per tick (10px pitch, ZCode parity). */
329
+ const ITEM_PITCH = 10;
330
+ /** Container width below which the rail hides. ZCode uses 864px, but DSH's
331
+ * three-pane layout leaves the conversation narrower — the rail overlays the
332
+ * left edge without taking layout space, so only truly cramped panes hide. */
333
+ const MIN_CONTAINER_WIDTH = 560;
334
+ /** Preview-card hover delays (ms). */
335
+ const TIP_OPEN_DELAY = 120;
336
+ const TIP_CLOSE_DELAY = 80;
337
+ /** Ripple visual per distance from the hovered tick (ZCode _5e parity).
338
+ * The ripple is interaction-only: at rest every tick is idle (scaleX 1),
339
+ * and the current position is signalled by color/opacity, not length. */
340
+ function ripple(distance) {
341
+ if (distance === 0) return {
342
+ opacity: 1,
343
+ scaleX: 2.6,
344
+ tone: "peak"
345
+ };
346
+ if (distance === 1) return {
347
+ opacity: .86,
348
+ scaleX: 1.7,
349
+ tone: "near"
350
+ };
351
+ if (distance === 2) return {
352
+ opacity: .72,
353
+ scaleX: 1.25,
354
+ tone: "mid"
355
+ };
356
+ return {
357
+ opacity: .58,
358
+ scaleX: 1,
359
+ tone: "idle"
360
+ };
361
+ }
362
+ /** Tick colors: foreground for the hovered peak and the current turn at rest,
363
+ * the subtlest text tone otherwise (ZCode bg-foreground / -subtlest parity
364
+ * through system colors). */
365
+ const TICK_COLOR_FOREGROUND = "CanvasText";
366
+ const TICK_COLOR_SUBTLE = "color-mix(in srgb, CanvasText 42%, transparent)";
367
+ function useReducedMotion() {
368
+ const [reduced, setReduced] = (0, react.useState)(false);
369
+ (0, react.useEffect)(() => {
370
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
371
+ const query = window.matchMedia("(prefers-reduced-motion: reduce)");
372
+ const update = () => setReduced(query.matches);
373
+ update();
374
+ query.addEventListener("change", update);
375
+ return () => query.removeEventListener("change", update);
376
+ }, []);
377
+ return reduced;
378
+ }
379
+ /**
380
+ * Render the question navigator rail.
381
+ * @param props - composed slot props.
382
+ * @returns the rail, or null when it should not render.
383
+ */
384
+ function TimelineRail({ source, t }) {
385
+ const state = (0, react.useSyncExternalStore)(source.subscribe, source.getSnapshot);
386
+ const reducedMotion = useReducedMotion();
387
+ const probe = (0, react.useMemo)(() => typeof document === "undefined" ? null : probeChatDom(document), []);
388
+ const [containerVersion, setContainerVersion] = (0, react.useState)(0);
389
+ const [rect, setRect] = (0, react.useState)(null);
390
+ const [activeIndex, setActiveIndex] = (0, react.useState)(-1);
391
+ const [hoverIndex, setHoverIndex] = (0, react.useState)(void 0);
392
+ const [tipIndex, setTipIndex] = (0, react.useState)(void 0);
393
+ const timers = (0, react.useRef)({
394
+ open: void 0,
395
+ close: void 0
396
+ });
397
+ const frame = (0, react.useRef)(0);
398
+ const labels = (0, react.useMemo)(() => ({
399
+ userFallback: t("preview.userFallback"),
400
+ assistantEmpty: t("preview.assistantEmpty"),
401
+ assistantRunning: t("preview.assistantRunning")
402
+ }), [t]);
403
+ const snapshot = state.snapshot;
404
+ const items = (0, react.useMemo)(() => snapshot === null ? [] : buildTimelineItems(snapshot.nodes, snapshot.running, labels), [snapshot, labels]);
405
+ (0, react.useEffect)(() => {
406
+ if (typeof document === "undefined") return;
407
+ ensureStyles(document);
408
+ }, []);
409
+ (0, react.useEffect)(() => {
410
+ if (probe === null) return;
411
+ let attempts = 0;
412
+ let timer;
413
+ const tick = () => {
414
+ if (probe.getContainer() !== null || attempts >= 10) {
415
+ setContainerVersion((version) => version + 1);
416
+ return;
417
+ }
418
+ attempts += 1;
419
+ timer = setTimeout(tick, 300);
420
+ };
421
+ tick();
422
+ return () => {
423
+ if (timer !== void 0) clearTimeout(timer);
424
+ };
425
+ }, [probe, state.sessionId]);
426
+ (0, react.useEffect)(() => {
427
+ if (probe === null || containerVersion === 0) return;
428
+ const container = probe.getContainer();
429
+ if (container === null) {
430
+ setRect(null);
431
+ return;
432
+ }
433
+ const sync = () => {
434
+ if (frame.current !== 0) return;
435
+ frame.current = requestAnimationFrame(() => {
436
+ frame.current = 0;
437
+ const box = container.getBoundingClientRect();
438
+ setRect({
439
+ left: box.left,
440
+ top: box.top,
441
+ height: box.height,
442
+ width: box.width
443
+ });
444
+ setActiveIndex(probe.activeIndex());
445
+ });
446
+ };
447
+ sync();
448
+ container.addEventListener("scroll", sync, { passive: true });
449
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(sync);
450
+ observer?.observe(container);
451
+ window.addEventListener("resize", sync);
452
+ return () => {
453
+ container.removeEventListener("scroll", sync);
454
+ observer?.disconnect();
455
+ window.removeEventListener("resize", sync);
456
+ if (frame.current !== 0) cancelAnimationFrame(frame.current);
457
+ frame.current = 0;
458
+ };
459
+ }, [probe, containerVersion]);
460
+ const visible = snapshot !== null && items.length >= 2 && rect !== null && rect.width >= MIN_CONTAINER_WIDTH;
461
+ const focusIndex = hoverIndex;
462
+ const scheduleTip = (0, react.useCallback)((index) => {
463
+ if (timers.current.open !== void 0) window.clearTimeout(timers.current.open);
464
+ if (timers.current.close !== void 0) window.clearTimeout(timers.current.close);
465
+ if (index === void 0) {
466
+ timers.current.close = window.setTimeout(() => setTipIndex(void 0), TIP_CLOSE_DELAY);
467
+ return;
468
+ }
469
+ timers.current.open = window.setTimeout(() => setTipIndex(index), TIP_OPEN_DELAY);
470
+ }, []);
471
+ (0, react.useEffect)(() => () => {
472
+ if (timers.current.open !== void 0) window.clearTimeout(timers.current.open);
473
+ if (timers.current.close !== void 0) window.clearTimeout(timers.current.close);
474
+ }, []);
475
+ const jump = (0, react.useCallback)((index) => {
476
+ probe?.jumpTo(index, reducedMotion ? "auto" : "smooth");
477
+ }, [probe, reducedMotion]);
478
+ if (!visible || probe === null || rect === null) return null;
479
+ const trackHeight = items.length * ITEM_PITCH;
480
+ const tip = tipIndex !== void 0 ? items[tipIndex] : void 0;
481
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
482
+ "aria-label": t("nav.label"),
483
+ className: "dsh-tl-nav",
484
+ "data-visible": visible ? "true" : "false",
485
+ "data-testid": "dsh-chat-timeline",
486
+ "data-item-count": items.length,
487
+ style: {
488
+ left: rect.left,
489
+ top: rect.top,
490
+ height: rect.height
491
+ },
492
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
493
+ className: "dsh-tl-scroll",
494
+ onPointerLeave: () => {
495
+ setHoverIndex(void 0);
496
+ scheduleTip(void 0);
497
+ },
498
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
499
+ className: "dsh-tl-track",
500
+ style: { height: `${trackHeight}px` },
501
+ children: items.map((item, index) => {
502
+ const isActive = index === activeIndex;
503
+ const look = ripple(focusIndex === void 0 ? 3 : Math.abs(index - focusIndex));
504
+ const activeAtRest = focusIndex === void 0 && isActive;
505
+ const foreground = look.tone === "peak" || activeAtRest;
506
+ const opacity = activeAtRest ? .9 : item.running ? Math.max(look.opacity, .72) : look.opacity;
507
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
508
+ className: "dsh-tl-slot",
509
+ style: { transform: `translateY(${index * ITEM_PITCH}px)` },
510
+ onMouseEnter: () => {
511
+ setHoverIndex(index);
512
+ scheduleTip(index);
513
+ },
514
+ onFocus: () => {
515
+ setHoverIndex(index);
516
+ scheduleTip(index);
517
+ },
518
+ onBlur: () => {
519
+ setHoverIndex(void 0);
520
+ scheduleTip(void 0);
521
+ },
522
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
523
+ type: "button",
524
+ "aria-current": isActive ? "location" : void 0,
525
+ "aria-label": t("nav.jumpToQuery", { index: String(index + 1) }),
526
+ "aria-posinset": index + 1,
527
+ "aria-setsize": items.length,
528
+ "data-testid": "dsh-chat-timeline-item",
529
+ "data-item-index": index,
530
+ "data-active": isActive ? "true" : "false",
531
+ "data-running": item.running ? "true" : "false",
532
+ onClick: () => jump(index),
533
+ className: "dsh-tl-slot-inner",
534
+ style: {
535
+ display: "flex",
536
+ alignItems: "center",
537
+ justifyContent: "flex-start",
538
+ width: "100%",
539
+ height: "100%",
540
+ padding: 0,
541
+ border: 0,
542
+ background: "transparent",
543
+ cursor: "pointer"
544
+ },
545
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
546
+ className: "dsh-tl-tick",
547
+ style: {
548
+ opacity,
549
+ transform: `scaleX(${look.scaleX})`,
550
+ backgroundColor: foreground ? TICK_COLOR_FOREGROUND : TICK_COLOR_SUBTLE
551
+ }
552
+ })
553
+ })
554
+ }, item.key);
555
+ })
556
+ })
557
+ })
558
+ }), tip !== void 0 && tipIndex !== void 0 ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TipCard, {
559
+ item: tip,
560
+ index: tipIndex,
561
+ rect,
562
+ t
563
+ }), document.body) : null] });
564
+ }
565
+ function TipCard({ item, index, rect, t }) {
566
+ const box = document.querySelector("[data-testid=\"dsh-chat-timeline-item\"][data-item-index=\"" + String(index) + "\"]")?.getBoundingClientRect();
567
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
568
+ className: "dsh-tl-tip",
569
+ "data-testid": "dsh-chat-timeline-tip",
570
+ style: {
571
+ left: box === void 0 ? rect.left + 56 : Math.min(box.right + 8, window.innerWidth - 336),
572
+ top: box === void 0 ? rect.top + 100 : Math.max(8, Math.min(box.top - 4, window.innerHeight - 160))
573
+ },
574
+ role: "tooltip",
575
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
576
+ className: "dsh-tl-tip-user",
577
+ children: item.userPreview
578
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
579
+ className: "dsh-tl-tip-assistant",
580
+ "data-kind": item.assistantKind,
581
+ children: item.assistantPreview
582
+ })]
583
+ });
584
+ }
585
+ //#endregion
586
+ //#region src/client/source.ts
587
+ const EMPTY_STATE = {
588
+ sessionId: void 0,
589
+ snapshot: null
590
+ };
591
+ /**
592
+ * Create the source. Subscribes to the sessions list, rebinds to the current
593
+ * session's snapshot feed on selection change, and republishes a stable state
594
+ * object whenever the underlying snapshot changes.
595
+ * @param sessions - the sessions service face (pass ctx.sessions).
596
+ * @returns the source face.
597
+ */
598
+ function createTimelineSource(sessions) {
599
+ let state = EMPTY_STATE;
600
+ const listeners = /* @__PURE__ */ new Set();
601
+ let unbindSession = null;
602
+ let boundSessionId;
603
+ let retryTimer;
604
+ let retryAttempts = 0;
605
+ const emit = () => {
606
+ for (const listener of listeners) listener();
607
+ };
608
+ const rebind = () => {
609
+ if (retryTimer !== void 0) {
610
+ clearTimeout(retryTimer);
611
+ retryTimer = void 0;
612
+ }
613
+ let current;
614
+ try {
615
+ current = sessions.list.getSnapshot()?.current;
616
+ } catch {
617
+ current = void 0;
618
+ }
619
+ if (current === boundSessionId && retryAttempts === 0) return;
620
+ boundSessionId = current;
621
+ unbindSession?.();
622
+ unbindSession = null;
623
+ if (current === void 0) {
624
+ state = EMPTY_STATE;
625
+ emit();
626
+ return;
627
+ }
628
+ let session;
629
+ try {
630
+ session = sessions.binding(current)?.session;
631
+ } catch {
632
+ session = void 0;
633
+ }
634
+ if (!session) {
635
+ state = {
636
+ sessionId: current,
637
+ snapshot: null
638
+ };
639
+ if (retryAttempts < 10) {
640
+ retryAttempts += 1;
641
+ retryTimer = setTimeout(rebind, 500 * retryAttempts);
642
+ }
643
+ emit();
644
+ return;
645
+ }
646
+ retryAttempts = 0;
647
+ const push = () => {
648
+ try {
649
+ const snap = session.getSnapshot();
650
+ state = {
651
+ sessionId: current,
652
+ snapshot: {
653
+ sessionId: current,
654
+ nodes: Array.isArray(snap?.nodes) ? snap.nodes : [],
655
+ running: Boolean(snap?.running)
656
+ }
657
+ };
658
+ } catch {
659
+ state = {
660
+ sessionId: current,
661
+ snapshot: null
662
+ };
663
+ }
664
+ emit();
665
+ };
666
+ push();
667
+ try {
668
+ session.subscribe(push);
669
+ unbindSession = () => {};
670
+ } catch {
671
+ unbindSession = null;
672
+ }
673
+ };
674
+ return {
675
+ subscribe(listener) {
676
+ const first = listeners.size === 0;
677
+ listeners.add(listener);
678
+ if (first) {
679
+ try {
680
+ sessions.list.subscribe(rebind);
681
+ } catch {}
682
+ rebind();
683
+ }
684
+ return () => {
685
+ listeners.delete(listener);
686
+ };
687
+ },
688
+ getSnapshot() {
689
+ return state;
690
+ }
691
+ };
692
+ }
693
+ /** Build the source from the plugin client context. */
694
+ function timelineSourceFromContext(ctx) {
695
+ return createTimelineSource(ctx.sessions);
696
+ }
697
+ //#endregion
698
+ //#region src/client/locales.ts
699
+ /**
700
+ * Locale dictionaries for the chat-timeline plugin. `zh` is the key-set source
701
+ * of truth; `en` keeps a full key-for-key mirror. Registered through
702
+ * ctx.locale.register(NS, { zh, en }).
703
+ */
704
+ /** Simplified Chinese dictionary (key-set source of truth). */
705
+ const zh = {
706
+ "nav.label": "对话问题导航",
707
+ "nav.jumpToQuery": "跳转到第 {index} 条问题",
708
+ "preview.userFallback": "用户输入",
709
+ "preview.assistantEmpty": "暂无助手正文",
710
+ "preview.assistantRunning": "助手仍在工作"
711
+ };
712
+ /** English dictionary, key-for-key complete against zh. */
713
+ const en = {
714
+ "nav.label": "Conversation question navigator",
715
+ "nav.jumpToQuery": "Jump to question {index}",
716
+ "preview.userFallback": "User input",
717
+ "preview.assistantEmpty": "No assistant text yet",
718
+ "preview.assistantRunning": "Assistant is still working"
719
+ };
720
+ //#endregion
721
+ //#region src/client/index.ts
722
+ /** Dictionary namespace owned by this plugin. */
723
+ const NS = "chat-timeline";
724
+ /** Unique occupant id inside the shared shell.overlay list slot. */
725
+ const ENTRY_ID = "chat-timeline";
726
+ /** Services required by this plugin. */
727
+ const inject = [
728
+ "slots",
729
+ "locale",
730
+ "sessions"
731
+ ];
732
+ /**
733
+ * Register the timeline surface.
734
+ * @param ctx - client root context.
735
+ */
736
+ function apply(ctx) {
737
+ ctx.effect(() => {
738
+ try {
739
+ return ctx.locale.register(NS, {
740
+ zh,
741
+ en
742
+ });
743
+ } catch {
744
+ return () => {};
745
+ }
746
+ }, "chat-timeline: dictionaries");
747
+ ctx.slots.inject("shell.overlay", () => {
748
+ try {
749
+ return ctx.slots.register({
750
+ name: "shell.overlay",
751
+ id: ENTRY_ID,
752
+ locale: NS,
753
+ inject: () => ({ source: timelineSourceFromContext(ctx) })
754
+ }, TimelineRail);
755
+ } catch {
756
+ return () => {};
757
+ }
758
+ });
759
+ }
760
+ //#endregion
761
+ exports.apply = apply;
762
+ exports.inject = inject;
763
+ return module.exports;
764
+ }
765
+ });
766
+
767
+ //# sourceMappingURL=client.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"client.js","names":[],"sources":["../src/client/directory.ts","../src/client/dom.ts","../src/client/styles.ts","../src/client/rail.tsx","../src/client/source.ts","../src/client/locales.ts","../src/client/index.ts"],"sourcesContent":["/**\n * Timeline directory derivation — the pure core of the plugin.\n *\n * Turns the conversation snapshot's ordered node list into one tick per human\n * input (`user` / `steering`), each carrying a trimmed user preview plus the\n * assistant reply preview in one of three states (text / running / empty).\n * Preview budgeting replicates the ZCode TurnNavigator: at most two\n * paragraphs, whitespace-collapsed, truncated to 220 characters.\n *\n * No DOM, no React, no dsh runtime imports — everything here is plain data so\n * vitest can cover it without fixtures beyond plain objects.\n */\n\n/** Minimal structural shape of a conversation node this module needs. */\nexport interface TimelineNodeLike {\n kind: string\n seq: number\n /** Unix epoch ms when present. */\n time?: number\n /** User/steering content blocks (each `{ kind, text? }`-like). */\n content?: readonly unknown[]\n /** Assistant content blocks (each `{ kind, text? }`-like). */\n blocks?: readonly unknown[]\n}\n\n/** One tick in the timeline directory. */\nexport interface TimelineItem {\n /** Stable React key (`n<seq>`). */\n readonly key: string\n /** Seq of the anchoring human node. */\n readonly seq: number\n /** Anchor time (epoch ms); 0 when unknown. */\n readonly time: number\n /** Trimmed human input preview. */\n readonly userPreview: string\n /** True when the human node carried no extractable text. */\n readonly userFallback: boolean\n /** Trimmed assistant reply preview. */\n readonly assistantPreview: string\n /** Which assistant preview state applies. */\n readonly assistantKind: 'text' | 'running' | 'empty'\n /** True for the last tick while the session is still running. */\n readonly running: boolean\n}\n\n/** Copy labels the derivation needs (kept out of the pure math for testability). */\nexport interface TimelineLabels {\n readonly userFallback: string\n readonly assistantEmpty: string\n readonly assistantRunning: string\n}\n\n/** Preview budget: at most two paragraphs and 220 characters (ZCode parity). */\nexport const MAX_PREVIEW_CHARS = 220\nexport const MAX_PREVIEW_PARAGRAPHS = 2\n\n/** Human node kinds that anchor a tick. */\nconst HUMAN_KINDS = new Set(['user', 'steering'])\n\n/** Extract concatenated text from a ContentBlock-like list.\n * dsh-llm content blocks switch on `type`; assistant blocks use `kind` — accept both. */\nexport function blocksText(blocks: readonly unknown[] | undefined): string {\n if (!Array.isArray(blocks)) return ''\n const parts: string[] = []\n for (const block of blocks) {\n if (block !== null && typeof block === 'object') {\n const record = block as { kind?: unknown; type?: unknown; text?: unknown }\n if (record.kind === 'text' || record.type === 'text') {\n if (typeof record.text === 'string' && record.text.length > 0) parts.push(record.text)\n }\n }\n }\n return parts.join('\\n\\n')\n}\n\n/** Collapse each paragraph's whitespace and drop empties (ZCode u5e parity). */\nexport function collapseParagraphs(text: string, maxParagraphs: number): string[] {\n return text\n .trim()\n .split(/\\n\\s*\\n/u)\n .map((paragraph) => paragraph.replace(/\\s+/gu, ' ').trim())\n .filter(Boolean)\n .slice(0, Math.max(1, maxParagraphs))\n}\n\n/** Truncate to the budget with an ellipsis, never below the floor (ZCode d5e parity). */\nexport function truncatePreview(text: string, maxChars: number, floor = 8): string {\n const budget = Math.max(floor, maxChars)\n if (text.length <= budget) return text\n return `${text.slice(0, budget - 3).trimEnd()}...`\n}\n\n/** Build one preview string from raw texts under the shared budget. */\nexport function buildPreview(texts: readonly string[], fallback: string): string {\n const paragraphs = collapseParagraphs(texts.join('\\n\\n'), MAX_PREVIEW_PARAGRAPHS)\n if (paragraphs.length === 0) return fallback\n return truncatePreview(paragraphs.join('\\n'), MAX_PREVIEW_CHARS)\n}\n\n/**\n * Derive the tick directory from the snapshot's ordered node list.\n * @param nodes - conversation snapshot nodes in seq order.\n * @param running - whether the session currently has a running turn.\n * @param labels - localized preview fallbacks.\n * @returns one item per human input, in order.\n */\nexport function buildTimelineItems(\n nodes: readonly TimelineNodeLike[],\n running: boolean,\n labels: TimelineLabels,\n): TimelineItem[] {\n interface Draft {\n key: string\n seq: number\n time: number\n userTexts: string[]\n hasUserText: boolean\n assistantTexts: string[]\n }\n const drafts: Draft[] = []\n for (const node of nodes) {\n if (HUMAN_KINDS.has(node.kind)) {\n const text = blocksText(node.content)\n drafts.push({\n key: `n${node.seq}`,\n seq: node.seq,\n time: typeof node.time === 'number' ? node.time : 0,\n userTexts: text === '' ? [] : [text],\n hasUserText: text !== '',\n assistantTexts: [],\n })\n continue\n }\n if (node.kind !== 'assistant' || drafts.length === 0) continue\n const text = blocksText(node.blocks)\n if (text !== '') drafts[drafts.length - 1].assistantTexts.push(text)\n }\n const last = drafts.length - 1\n return drafts.map((draft, index) => {\n const isLive = running && index === last\n const assistantTexts = draft.assistantTexts\n const assistantKind: TimelineItem['assistantKind'] = assistantTexts.length > 0\n ? 'text'\n : isLive\n ? 'running'\n : 'empty'\n return {\n key: draft.key,\n seq: draft.seq,\n time: draft.time,\n userPreview: buildPreview(draft.userTexts, labels.userFallback),\n userFallback: !draft.hasUserText,\n assistantPreview: buildPreview(assistantTexts, isLive ? labels.assistantRunning : labels.assistantEmpty),\n assistantKind,\n running: isLive,\n }\n })\n}\n","/**\n * Chat DOM probe — the only module allowed to know DSH's internal chat DOM.\n *\n * Contract measured on dsh 0.1.1-rc.2 (see .scratch/chat-timeline/issues/03):\n * the scroll container carries `data-conversation-scroll`; each flow row is a\n * `[data-chat-flow-kind]` element whose `user` kind marks a human turn, in the\n * same seq order as the snapshot's user nodes. Every access is defensive:\n * when detection fails the probe degrades to a no-op rail (no jump, no\n * highlight) instead of throwing.\n */\n\n/** Read-only view of the chat DOM the rail interacts with. */\nexport interface ChatDomProbe {\n /** The chat scroll container, or null when not found. */\n getContainer(): HTMLElement | null\n /** Human-turn row elements in document order (user kind only). */\n getUserRows(): HTMLElement[]\n /** Index of the human turn nearest the viewport top, or -1. */\n activeIndex(): number\n /** Scroll the given human turn into view; false when unavailable. */\n jumpTo(index: number, behavior: ScrollBehavior): boolean\n}\n\n/**\n * Find the chat scroll container with a candidate chain.\n * 1. the explicit stable hook `[data-conversation-scroll]`;\n * 2. the closest scrollable ancestor of the flow column `[data-chat-flow]`;\n * 3. the largest scrollable element that contains human rows.\n */\nexport function findChatContainer(doc: Document): HTMLElement | null {\n const direct = doc.querySelector<HTMLElement>('[data-conversation-scroll]')\n if (direct !== null) return direct\n const flow = doc.querySelector<HTMLElement>('[data-chat-flow]')\n const ancestor = flow !== null ? scrollableAncestor(flow) : null\n if (ancestor !== null) return ancestor\n let best: HTMLElement | null = null\n let bestHeight = 0\n for (const element of Array.from(doc.querySelectorAll<HTMLElement>('*'))) {\n if (!isScrollable(element)) continue\n if (element.querySelector('[data-chat-flow-kind=\"user\"]') === null) continue\n const height = element.clientHeight\n if (height > bestHeight) {\n best = element\n bestHeight = height\n }\n }\n return best\n}\n\nfunction isScrollable(element: HTMLElement): boolean {\n const style = element.ownerDocument.defaultView?.getComputedStyle(element)\n if (style === undefined) return false\n if (!/(auto|scroll)/.test(style.overflowY)) return false\n return element.scrollHeight > element.clientHeight + 40 && element.clientHeight > 200\n}\n\nfunction scrollableAncestor(element: HTMLElement): HTMLElement | null {\n let node: HTMLElement | null = element\n while (node !== null) {\n if (isScrollable(node)) return node\n node = node.parentElement\n }\n return null\n}\n\n/** Human-turn rows inside the container (falling back to a document-wide query). */\nexport function findUserRows(container: HTMLElement | null): HTMLElement[] {\n const scope: ParentNode = container ?? document\n return Array.from(scope.querySelectorAll<HTMLElement>('[data-chat-flow-kind=\"user\"]'))\n}\n\n/**\n * The active index: the last human row whose top edge sits above the\n * container's reading line (40px below the top edge), matching the ZCode\n * \"unit at viewport top\" rule; clamps to the last row at scroll bottom.\n */\nexport function computeActiveIndex(container: HTMLElement, rows: readonly HTMLElement[]): number {\n if (rows.length === 0) return -1\n const top = container.getBoundingClientRect().top + 40\n let active = 0\n for (let index = 0; index < rows.length; index += 1) {\n if (rows[index].getBoundingClientRect().top <= top) active = index\n else break\n }\n const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight\n if (distanceToBottom <= 4) return rows.length - 1\n return active\n}\n\n/**\n * Scroll the given human turn into view. Tries a smooth scroll first; DSH's\n * windowed conversation list programs scrollTop on scroll events, which can\n * cancel a smooth animation on the first frame — when nothing has moved after\n * a short grace period the jump falls back to an instant scroll.\n * @returns false when the container or row is unavailable.\n */\nfunction jumpTo(index: number, behavior: ScrollBehavior): boolean {\n const container = findChatContainer(document)\n if (container === null) return false\n const row = findUserRows(container)[index]\n if (row === undefined) return false\n const delta = row.getBoundingClientRect().top - container.getBoundingClientRect().top - 12\n const target = container.scrollTop + delta\n if (behavior === 'auto') {\n container.scrollTo({ top: target, behavior: 'auto' })\n return true\n }\n const startedAt = container.scrollTop\n container.scrollTo({ top: target, behavior: 'smooth' })\n window.setTimeout(() => {\n // Still within a few pixels of the start after the grace period: the\n // smooth animation was cancelled — land instantly instead.\n if (Math.abs(container.scrollTop - startedAt) < 4) {\n container.scrollTo({ top: target, behavior: 'auto' })\n }\n }, 700)\n return true\n}\n\n/** Create the probe against a document (injectable for tests). */\nexport function probeChatDom(doc: Document): ChatDomProbe {\n return {\n getContainer: () => findChatContainer(doc),\n getUserRows: () => findUserRows(findChatContainer(doc)),\n activeIndex: () => {\n const container = findChatContainer(doc)\n if (container === null) return -1\n return computeActiveIndex(container, findUserRows(container))\n },\n jumpTo: (index: number, behavior: ScrollBehavior) => jumpTo(index, behavior),\n }\n}\n","/**\n * Stylesheet for the chat-timeline rail, injected once as a\n * `<style data-plugin-css=\"chat-timeline\">` tag. All classes carry the\n * `dsh-tl-` prefix; colors use the CSS system colors Canvas/CanvasText so the\n * rail follows the app's color-scheme without reaching into DSH theme tokens.\n * Visual constants replicate the ZCode TurnNavigator (spec section 2.2).\n */\nexport const TIMELINE_STYLES = `\n.dsh-tl-nav {\n position: fixed;\n z-index: 10;\n width: 48px;\n pointer-events: none;\n opacity: 1;\n transition: opacity 150ms ease-out;\n}\n.dsh-tl-nav[data-visible=\"false\"] {\n opacity: 0;\n visibility: hidden;\n}\n.dsh-tl-scroll {\n position: absolute;\n left: 12px;\n top: 50%;\n transform: translateY(-50%);\n width: 36px;\n max-height: calc(100% - 96px);\n overflow-x: hidden;\n overflow-y: auto;\n padding-block: 4px;\n pointer-events: auto;\n scrollbar-width: none;\n color: CanvasText;\n}\n.dsh-tl-scroll::-webkit-scrollbar {\n display: none;\n}\n.dsh-tl-track {\n position: relative;\n width: 36px;\n}\n.dsh-tl-slot {\n position: absolute;\n left: 0;\n top: 0;\n height: 10px;\n width: 36px;\n padding: 0;\n border: 0;\n background: transparent;\n display: flex;\n align-items: center;\n justify-content: flex-start;\n border-radius: 2px;\n cursor: pointer;\n}\n.dsh-tl-slot:focus-visible {\n outline: 2px solid CanvasText;\n outline-offset: 2px;\n}\n.dsh-tl-tick {\n display: block;\n height: 2px;\n width: 12px;\n border-radius: 999px;\n background: currentColor;\n transform-origin: left center;\n transition: height 150ms ease-out, opacity 150ms ease-out, transform 150ms ease-out, background-color 150ms ease-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .dsh-tl-nav,\n .dsh-tl-tick {\n transition: none;\n }\n}\n.dsh-tl-tip {\n position: fixed;\n z-index: 60;\n width: 320px;\n max-width: calc(100vw - 2rem);\n padding: 12px;\n border-radius: 10px;\n background: Canvas;\n color: CanvasText;\n border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n pointer-events: none;\n}\n.dsh-tl-tip-user {\n margin: 0;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: pre-line;\n font-size: 13px;\n line-height: 20px;\n font-weight: 500;\n}\n.dsh-tl-tip-assistant {\n margin: 8px 0 0;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 3;\n overflow: hidden;\n white-space: pre-line;\n font-size: 13px;\n line-height: 20px;\n opacity: 0.8;\n}\n.dsh-tl-tip-assistant[data-kind=\"running\"],\n.dsh-tl-tip-assistant[data-kind=\"empty\"] {\n opacity: 0.55;\n}\n`\n\n/** Inject the stylesheet once per document. */\nexport function ensureStyles(doc: Document): void {\n const tagId = 'chat-timeline'\n if (doc.querySelector('style[data-plugin-css=\"chat-timeline\"]') !== null) return\n const tag = doc.createElement('style')\n tag.dataset.plugin = '@lament_z/dsh-client-ui-chat-timeline'\n tag.dataset.pluginCss = tagId\n tag.textContent = TIMELINE_STYLES\n doc.head.appendChild(tag)\n}\n","/**\n * TimelineRail — the shell.overlay entry: a left-edge tick rail over the\n * conversation, one tick per human question. Visual and interaction constants\n * replicate the ZCode TurnNavigator (see .scratch/chat-timeline/spec.md):\n * hover ripple scaleX 2.6/1.7/1.25 with opacity 1/.86/.72/.58 over 150ms,\n * 320px preview cards after a 120ms delay, >=2 questions to render, >=864px\n * container width, prefers-reduced-motion fallback, full aria labelling.\n */\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'\nimport { buildTimelineItems, type TimelineItem, type TimelineLabels } from './directory.ts'\nimport type { TimelineKey } from './locales.ts'\nimport { probeChatDom, type ChatDomProbe } from './dom.ts'\nimport { ensureStyles } from './styles.ts'\nimport type { TimelineSource } from './source.ts'\n\n/** Component props: the locale seat plus the injected session source. */\nexport type TimelineRailProps = PropsLocale<'chat-timeline'> & {\n /** The current-session snapshot source (built from ctx.sessions). */\n source: TimelineSource\n}\n\n/** Slot height per tick (10px pitch, ZCode parity). */\nconst ITEM_PITCH = 10\n/** Container width below which the rail hides. ZCode uses 864px, but DSH's\n * three-pane layout leaves the conversation narrower — the rail overlays the\n * left edge without taking layout space, so only truly cramped panes hide. */\nconst MIN_CONTAINER_WIDTH = 560\n/** Preview-card hover delays (ms). */\nconst TIP_OPEN_DELAY = 120\nconst TIP_CLOSE_DELAY = 80\n\ninterface Rect {\n left: number\n top: number\n height: number\n width: number\n}\n\n/** Ripple visual per distance from the hovered tick (ZCode _5e parity).\n * The ripple is interaction-only: at rest every tick is idle (scaleX 1),\n * and the current position is signalled by color/opacity, not length. */\nfunction ripple(distance: number): { opacity: number; scaleX: number; tone: 'peak' | 'near' | 'mid' | 'idle' } {\n if (distance === 0) return { opacity: 1, scaleX: 2.6, tone: 'peak' }\n if (distance === 1) return { opacity: 0.86, scaleX: 1.7, tone: 'near' }\n if (distance === 2) return { opacity: 0.72, scaleX: 1.25, tone: 'mid' }\n return { opacity: 0.58, scaleX: 1, tone: 'idle' }\n}\n\n/** Tick colors: foreground for the hovered peak and the current turn at rest,\n * the subtlest text tone otherwise (ZCode bg-foreground / -subtlest parity\n * through system colors). */\nconst TICK_COLOR_FOREGROUND = 'CanvasText'\nconst TICK_COLOR_SUBTLE = 'color-mix(in srgb, CanvasText 42%, transparent)'\n\nfunction useReducedMotion(): boolean {\n const [reduced, setReduced] = useState(false)\n useEffect(() => {\n if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return\n const query = window.matchMedia('(prefers-reduced-motion: reduce)')\n const update = () => setReduced(query.matches)\n update()\n query.addEventListener('change', update)\n return () => query.removeEventListener('change', update)\n }, [])\n return reduced\n}\n\n/**\n * Render the question navigator rail.\n * @param props - composed slot props.\n * @returns the rail, or null when it should not render.\n */\nexport function TimelineRail({ source, t }: TimelineRailProps) {\n const state = useSyncExternalStore(source.subscribe, source.getSnapshot)\n const reducedMotion = useReducedMotion()\n const probe = useMemo<ChatDomProbe | null>(() => (typeof document === 'undefined' ? null : probeChatDom(document)), [])\n const [containerVersion, setContainerVersion] = useState(0)\n const [rect, setRect] = useState<Rect | null>(null)\n const [activeIndex, setActiveIndex] = useState(-1)\n const [hoverIndex, setHoverIndex] = useState<number | undefined>(undefined)\n const [tipIndex, setTipIndex] = useState<number | undefined>(undefined)\n const timers = useRef<{ open: number | undefined; close: number | undefined }>({ open: undefined, close: undefined })\n const frame = useRef(0)\n\n const labels: TimelineLabels = useMemo(\n () => ({\n userFallback: t('preview.userFallback'),\n assistantEmpty: t('preview.assistantEmpty'),\n assistantRunning: t('preview.assistantRunning'),\n }),\n [t],\n )\n\n const snapshot = state.snapshot\n const items = useMemo<TimelineItem[]>(\n () => (snapshot === null ? [] : buildTimelineItems(snapshot.nodes as never[], snapshot.running, labels)),\n [snapshot, labels],\n )\n\n useEffect(() => {\n if (typeof document === 'undefined') return\n ensureStyles(document)\n }, [])\n\n // Find the chat container: re-probe when the session changes and briefly\n // afterwards (the conversation mounts a beat later than the selection).\n useEffect(() => {\n if (probe === null) return\n let attempts = 0\n let timer: ReturnType<typeof setTimeout> | undefined\n const tick = () => {\n const container = probe.getContainer()\n if (container !== null || attempts >= 10) {\n setContainerVersion((version) => version + 1)\n return\n }\n attempts += 1\n timer = setTimeout(tick, 300)\n }\n tick()\n return () => {\n if (timer !== undefined) clearTimeout(timer)\n }\n }, [probe, state.sessionId])\n\n // Attach scroll/size watchers to the container once found.\n useEffect(() => {\n if (probe === null || containerVersion === 0) return\n const container = probe.getContainer()\n if (container === null) {\n setRect(null)\n return\n }\n const sync = () => {\n if (frame.current !== 0) return\n frame.current = requestAnimationFrame(() => {\n frame.current = 0\n const box = container.getBoundingClientRect()\n setRect({ left: box.left, top: box.top, height: box.height, width: box.width })\n setActiveIndex(probe.activeIndex())\n })\n }\n sync()\n container.addEventListener('scroll', sync, { passive: true })\n const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(sync)\n observer?.observe(container)\n window.addEventListener('resize', sync)\n return () => {\n container.removeEventListener('scroll', sync)\n observer?.disconnect()\n window.removeEventListener('resize', sync)\n if (frame.current !== 0) cancelAnimationFrame(frame.current)\n frame.current = 0\n }\n }, [probe, containerVersion])\n\n const visible = snapshot !== null && items.length >= 2 && rect !== null && rect.width >= MIN_CONTAINER_WIDTH\n // The ripple follows the pointer only (ZCode v5e parity): at rest every tick\n // is equal length and the current turn stands out by color/opacity alone.\n const focusIndex = hoverIndex\n\n const scheduleTip = useCallback((index: number | undefined) => {\n if (timers.current.open !== undefined) window.clearTimeout(timers.current.open)\n if (timers.current.close !== undefined) window.clearTimeout(timers.current.close)\n if (index === undefined) {\n timers.current.close = window.setTimeout(() => setTipIndex(undefined), TIP_CLOSE_DELAY)\n return\n }\n timers.current.open = window.setTimeout(() => setTipIndex(index), TIP_OPEN_DELAY)\n }, [])\n\n useEffect(() => () => {\n if (timers.current.open !== undefined) window.clearTimeout(timers.current.open)\n if (timers.current.close !== undefined) window.clearTimeout(timers.current.close)\n }, [])\n\n const jump = useCallback((index: number) => {\n probe?.jumpTo(index, reducedMotion ? 'auto' : 'smooth')\n }, [probe, reducedMotion])\n\n if (!visible || probe === null || rect === null) return null\n\n const trackHeight = items.length * ITEM_PITCH\n const tip = tipIndex !== undefined ? items[tipIndex] : undefined\n\n return (\n <>\n <nav\n aria-label={t('nav.label')}\n className=\"dsh-tl-nav\"\n data-visible={visible ? 'true' : 'false'}\n data-testid=\"dsh-chat-timeline\"\n data-item-count={items.length}\n style={{ left: rect.left, top: rect.top, height: rect.height }}\n >\n <div\n className=\"dsh-tl-scroll\"\n onPointerLeave={() => {\n setHoverIndex(undefined)\n scheduleTip(undefined)\n }}\n >\n <div className=\"dsh-tl-track\" style={{ height: `${trackHeight}px` }}>\n {items.map((item, index) => {\n const isActive = index === activeIndex\n const distance = focusIndex === undefined ? 3 : Math.abs(index - focusIndex)\n const look = ripple(distance)\n const activeAtRest = focusIndex === undefined && isActive\n const foreground = look.tone === 'peak' || activeAtRest\n const opacity = activeAtRest\n ? 0.9\n : item.running\n ? Math.max(look.opacity, 0.72)\n : look.opacity\n return (\n <div\n key={item.key}\n className=\"dsh-tl-slot\"\n style={{ transform: `translateY(${index * ITEM_PITCH}px)` }}\n onMouseEnter={() => {\n setHoverIndex(index)\n scheduleTip(index)\n }}\n onFocus={() => {\n setHoverIndex(index)\n scheduleTip(index)\n }}\n onBlur={() => {\n setHoverIndex(undefined)\n scheduleTip(undefined)\n }}\n >\n <button\n type=\"button\"\n aria-current={isActive ? 'location' : undefined}\n aria-label={t('nav.jumpToQuery', { index: String(index + 1) })}\n aria-posinset={index + 1}\n aria-setsize={items.length}\n data-testid=\"dsh-chat-timeline-item\"\n data-item-index={index}\n data-active={isActive ? 'true' : 'false'}\n data-running={item.running ? 'true' : 'false'}\n onClick={() => jump(index)}\n className=\"dsh-tl-slot-inner\"\n style={{\n display: 'flex',\n alignItems: 'center',\n justifyContent: 'flex-start',\n width: '100%',\n height: '100%',\n padding: 0,\n border: 0,\n background: 'transparent',\n cursor: 'pointer',\n }}\n >\n <span\n className=\"dsh-tl-tick\"\n style={{\n opacity,\n transform: `scaleX(${look.scaleX})`,\n backgroundColor: foreground ? TICK_COLOR_FOREGROUND : TICK_COLOR_SUBTLE,\n }}\n />\n </button>\n </div>\n )\n })}\n </div>\n </div>\n </nav>\n {tip !== undefined && tipIndex !== undefined\n ? createPortal(\n <TipCard item={tip} index={tipIndex} rect={rect} t={t} />,\n document.body,\n )\n : null}\n </>\n )\n}\n\nfunction TipCard({ item, index, rect, t }: {\n item: TimelineItem\n index: number\n rect: Rect\n t: (key: TimelineKey, params?: Record<string, string | number>) => string\n}) {\n const slot = document.querySelector<HTMLElement>('[data-testid=\"dsh-chat-timeline-item\"][data-item-index=\"' + String(index) + '\"]')\n const box = slot?.getBoundingClientRect()\n const left = box === undefined ? rect.left + 56 : Math.min(box.right + 8, window.innerWidth - 336)\n const top = box === undefined ? rect.top + 100 : Math.max(8, Math.min(box.top - 4, window.innerHeight - 160))\n return (\n <div\n className=\"dsh-tl-tip\"\n data-testid=\"dsh-chat-timeline-tip\"\n style={{ left, top }}\n role=\"tooltip\"\n >\n <p className=\"dsh-tl-tip-user\">{item.userPreview}</p>\n <p className=\"dsh-tl-tip-assistant\" data-kind={item.assistantKind}>{item.assistantPreview}</p>\n </div>\n )\n}\n","/**\n * Timeline source: follows the current session through the cordis services\n * face (ctx.sessions) and exposes a useSyncExternalStore-compatible read\n * face over its ConversationSnapshot. All dsh-client-runtime usage here is\n * type-only; at runtime everything is reached through the ctx services, so\n * the client bundle stays free of cross-package value imports.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\n\n/** The slice of ConversationSnapshot the timeline consumes. */\nexport interface TimelineSnapshot {\n readonly sessionId: string\n /** Ordered conversation nodes (the snapshot's legacy compatibility field). */\n readonly nodes: readonly unknown[]\n /** Whether the session currently has a running turn. */\n readonly running: boolean\n}\n\n/** State handed to React: null snapshot means \"no readable session right now\". */\nexport interface TimelineState {\n readonly sessionId: string | undefined\n readonly snapshot: TimelineSnapshot | null\n}\n\n/** useSyncExternalStore-compatible source face. */\nexport interface TimelineSource {\n subscribe(listener: () => void): () => void\n getSnapshot(): TimelineState\n}\n\ninterface ObservableLike<T> {\n getSnapshot(): T\n subscribe(listener: () => void): () => void\n}\n\n/** Structural face of ctx.sessions this module needs (kept narrow for tests). */\nexport interface SessionsLike {\n readonly list: ObservableLike<{ current?: string }>\n binding(id: string): { session: ObservableLike<{ nodes?: readonly unknown[]; running?: boolean }> } | undefined\n}\n\nconst EMPTY_STATE: TimelineState = { sessionId: undefined, snapshot: null }\n\n/**\n * Create the source. Subscribes to the sessions list, rebinds to the current\n * session's snapshot feed on selection change, and republishes a stable state\n * object whenever the underlying snapshot changes.\n * @param sessions - the sessions service face (pass ctx.sessions).\n * @returns the source face.\n */\nexport function createTimelineSource(sessions: SessionsLike): TimelineSource {\n let state: TimelineState = EMPTY_STATE\n const listeners = new Set<() => void>()\n let unbindSession: (() => void) | null = null\n let boundSessionId: string | undefined\n let retryTimer: ReturnType<typeof setTimeout> | undefined\n let retryAttempts = 0\n\n const emit = () => {\n for (const listener of listeners) listener()\n }\n\n const rebind = () => {\n if (retryTimer !== undefined) {\n clearTimeout(retryTimer)\n retryTimer = undefined\n }\n let current: string | undefined\n try {\n current = sessions.list.getSnapshot()?.current\n } catch {\n current = undefined\n }\n if (current === boundSessionId && retryAttempts === 0) return\n boundSessionId = current\n unbindSession?.()\n unbindSession = null\n if (current === undefined) {\n state = EMPTY_STATE\n emit()\n return\n }\n let session: ObservableLike<{ nodes?: readonly unknown[]; running?: boolean }> | undefined\n try {\n session = sessions.binding(current)?.session\n } catch {\n session = undefined\n }\n if (!session) {\n // Listed but not yet scoped (cold boot): keep an empty state and retry\n // with backoff so the rail appears once the binding lands.\n state = { sessionId: current, snapshot: null }\n if (retryAttempts < 10) {\n retryAttempts += 1\n retryTimer = setTimeout(rebind, 500 * retryAttempts)\n }\n emit()\n return\n }\n retryAttempts = 0\n const push = () => {\n try {\n const snap = session.getSnapshot()\n state = {\n sessionId: current,\n snapshot: {\n sessionId: current,\n nodes: Array.isArray(snap?.nodes) ? snap.nodes : [],\n running: Boolean(snap?.running),\n },\n }\n } catch {\n state = { sessionId: current, snapshot: null }\n }\n emit()\n }\n push()\n try {\n session.subscribe(push)\n unbindSession = () => {\n // Observable faces expose no unsubscribe disposer contract here; the\n // subscription rides the plugin fiber and is torn down with it.\n }\n } catch {\n unbindSession = null\n }\n }\n\n return {\n subscribe(listener: () => void): () => void {\n const first = listeners.size === 0\n listeners.add(listener)\n if (first) {\n try {\n sessions.list.subscribe(rebind)\n } catch {\n // Service unavailable: the rail stays hidden, nothing throws.\n }\n rebind()\n }\n return () => {\n listeners.delete(listener)\n }\n },\n getSnapshot(): TimelineState {\n return state\n },\n }\n}\n\n/** Build the source from the plugin client context. */\nexport function timelineSourceFromContext(ctx: ClientContext): TimelineSource {\n return createTimelineSource(ctx.sessions as unknown as SessionsLike)\n}\n","/**\n * Locale dictionaries for the chat-timeline plugin. `zh` is the key-set source\n * of truth; `en` keeps a full key-for-key mirror. Registered through\n * ctx.locale.register(NS, { zh, en }).\n */\n\n/** Simplified Chinese dictionary (key-set source of truth). */\nexport const zh = {\n 'nav.label': '对话问题导航',\n 'nav.jumpToQuery': '跳转到第 {index} 条问题',\n 'preview.userFallback': '用户输入',\n 'preview.assistantEmpty': '暂无助手正文',\n 'preview.assistantRunning': '助手仍在工作',\n}\n\n/** The chat-timeline namespace key union. */\nexport type TimelineKey = keyof typeof zh\n\n/** English dictionary, key-for-key complete against zh. */\nexport const en: Record<TimelineKey, string> = {\n 'nav.label': 'Conversation question navigator',\n 'nav.jumpToQuery': 'Jump to question {index}',\n 'preview.userFallback': 'User input',\n 'preview.assistantEmpty': 'No assistant text yet',\n 'preview.assistantRunning': 'Assistant is still working',\n}\n","/**\n * Chat-timeline plugin — browser half. Registers the `chat-timeline` locale\n * dictionaries and a `shell.overlay` entry that renders the question\n * navigator rail beside the conversation. Export discipline: the /client\n * surface carries only what cordis loading needs plus types.\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\n// Type-only: pulls the locale plugin's Context merge (ctx.locale) and its\n// LocaleNamespaceMap merge table.\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls the ui-layout SlotMap merge (the 'shell.overlay' hole).\nimport type {} from '@deepseek-ai/dsh-client-ui-layout/client'\nimport { TimelineRail } from './rail.tsx'\nimport { timelineSourceFromContext } from './source.ts'\nimport { en, zh, type TimelineKey } from './locales.ts'\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** Chat-timeline surface copy. */\n 'chat-timeline': TimelineKey\n }\n}\n\n/** Dictionary namespace owned by this plugin. */\nconst NS = 'chat-timeline'\n\n/** Unique occupant id inside the shared shell.overlay list slot. */\nconst ENTRY_ID = 'chat-timeline'\n\n/** Services required by this plugin. */\nexport const inject = ['slots', 'locale', 'sessions']\n\n/**\n * Register the timeline surface.\n * @param ctx - client root context.\n */\nexport function apply(ctx: ClientContext): void {\n ctx.effect(() => {\n try {\n return ctx.locale.register(NS, { zh, en })\n } catch {\n return () => {}\n }\n }, 'chat-timeline: dictionaries')\n\n // The rail floats over the conversation via the frame-wide additive seat.\n // Registration is declaration-aware via slots.inject.\n ctx.slots.inject('shell.overlay', () => {\n try {\n return ctx.slots.register({\n name: 'shell.overlay',\n id: ENTRY_ID,\n locale: NS,\n inject: () => ({ source: timelineSourceFromContext(ctx) }),\n }, TimelineRail)\n } catch {\n return () => {}\n }\n })\n}\n\nexport type { TimelineRailProps } from './rail.tsx'\nexport type { TimelineKey } from './locales.ts'\nexport type { TimelineSource, TimelineSnapshot, TimelineState } from './source.ts'\n"],"mappings":"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"}
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ //#region src/index.ts
2
+ /** Apply the host half. */
3
+ function apply(ctx) {}
4
+ //#endregion
5
+ export { apply };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Timeline directory derivation — the pure core of the plugin.
3
+ *
4
+ * Turns the conversation snapshot's ordered node list into one tick per human
5
+ * input (`user` / `steering`), each carrying a trimmed user preview plus the
6
+ * assistant reply preview in one of three states (text / running / empty).
7
+ * Preview budgeting replicates the ZCode TurnNavigator: at most two
8
+ * paragraphs, whitespace-collapsed, truncated to 220 characters.
9
+ *
10
+ * No DOM, no React, no dsh runtime imports — everything here is plain data so
11
+ * vitest can cover it without fixtures beyond plain objects.
12
+ */
13
+ /** Minimal structural shape of a conversation node this module needs. */
14
+ export interface TimelineNodeLike {
15
+ kind: string;
16
+ seq: number;
17
+ /** Unix epoch ms when present. */
18
+ time?: number;
19
+ /** User/steering content blocks (each `{ kind, text? }`-like). */
20
+ content?: readonly unknown[];
21
+ /** Assistant content blocks (each `{ kind, text? }`-like). */
22
+ blocks?: readonly unknown[];
23
+ }
24
+ /** One tick in the timeline directory. */
25
+ export interface TimelineItem {
26
+ /** Stable React key (`n<seq>`). */
27
+ readonly key: string;
28
+ /** Seq of the anchoring human node. */
29
+ readonly seq: number;
30
+ /** Anchor time (epoch ms); 0 when unknown. */
31
+ readonly time: number;
32
+ /** Trimmed human input preview. */
33
+ readonly userPreview: string;
34
+ /** True when the human node carried no extractable text. */
35
+ readonly userFallback: boolean;
36
+ /** Trimmed assistant reply preview. */
37
+ readonly assistantPreview: string;
38
+ /** Which assistant preview state applies. */
39
+ readonly assistantKind: 'text' | 'running' | 'empty';
40
+ /** True for the last tick while the session is still running. */
41
+ readonly running: boolean;
42
+ }
43
+ /** Copy labels the derivation needs (kept out of the pure math for testability). */
44
+ export interface TimelineLabels {
45
+ readonly userFallback: string;
46
+ readonly assistantEmpty: string;
47
+ readonly assistantRunning: string;
48
+ }
49
+ /** Preview budget: at most two paragraphs and 220 characters (ZCode parity). */
50
+ export declare const MAX_PREVIEW_CHARS = 220;
51
+ export declare const MAX_PREVIEW_PARAGRAPHS = 2;
52
+ /** Extract concatenated text from a ContentBlock-like list.
53
+ * dsh-llm content blocks switch on `type`; assistant blocks use `kind` — accept both. */
54
+ export declare function blocksText(blocks: readonly unknown[] | undefined): string;
55
+ /** Collapse each paragraph's whitespace and drop empties (ZCode u5e parity). */
56
+ export declare function collapseParagraphs(text: string, maxParagraphs: number): string[];
57
+ /** Truncate to the budget with an ellipsis, never below the floor (ZCode d5e parity). */
58
+ export declare function truncatePreview(text: string, maxChars: number, floor?: number): string;
59
+ /** Build one preview string from raw texts under the shared budget. */
60
+ export declare function buildPreview(texts: readonly string[], fallback: string): string;
61
+ /**
62
+ * Derive the tick directory from the snapshot's ordered node list.
63
+ * @param nodes - conversation snapshot nodes in seq order.
64
+ * @param running - whether the session currently has a running turn.
65
+ * @param labels - localized preview fallbacks.
66
+ * @returns one item per human input, in order.
67
+ */
68
+ export declare function buildTimelineItems(nodes: readonly TimelineNodeLike[], running: boolean, labels: TimelineLabels): TimelineItem[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Chat DOM probe — the only module allowed to know DSH's internal chat DOM.
3
+ *
4
+ * Contract measured on dsh 0.1.1-rc.2 (see .scratch/chat-timeline/issues/03):
5
+ * the scroll container carries `data-conversation-scroll`; each flow row is a
6
+ * `[data-chat-flow-kind]` element whose `user` kind marks a human turn, in the
7
+ * same seq order as the snapshot's user nodes. Every access is defensive:
8
+ * when detection fails the probe degrades to a no-op rail (no jump, no
9
+ * highlight) instead of throwing.
10
+ */
11
+ /** Read-only view of the chat DOM the rail interacts with. */
12
+ export interface ChatDomProbe {
13
+ /** The chat scroll container, or null when not found. */
14
+ getContainer(): HTMLElement | null;
15
+ /** Human-turn row elements in document order (user kind only). */
16
+ getUserRows(): HTMLElement[];
17
+ /** Index of the human turn nearest the viewport top, or -1. */
18
+ activeIndex(): number;
19
+ /** Scroll the given human turn into view; false when unavailable. */
20
+ jumpTo(index: number, behavior: ScrollBehavior): boolean;
21
+ }
22
+ /**
23
+ * Find the chat scroll container with a candidate chain.
24
+ * 1. the explicit stable hook `[data-conversation-scroll]`;
25
+ * 2. the closest scrollable ancestor of the flow column `[data-chat-flow]`;
26
+ * 3. the largest scrollable element that contains human rows.
27
+ */
28
+ export declare function findChatContainer(doc: Document): HTMLElement | null;
29
+ /** Human-turn rows inside the container (falling back to a document-wide query). */
30
+ export declare function findUserRows(container: HTMLElement | null): HTMLElement[];
31
+ /**
32
+ * The active index: the last human row whose top edge sits above the
33
+ * container's reading line (40px below the top edge), matching the ZCode
34
+ * "unit at viewport top" rule; clamps to the last row at scroll bottom.
35
+ */
36
+ export declare function computeActiveIndex(container: HTMLElement, rows: readonly HTMLElement[]): number;
37
+ /** Create the probe against a document (injectable for tests). */
38
+ export declare function probeChatDom(doc: Document): ChatDomProbe;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Chat-timeline plugin — browser half. Registers the `chat-timeline` locale
3
+ * dictionaries and a `shell.overlay` entry that renders the question
4
+ * navigator rail beside the conversation. Export discipline: the /client
5
+ * surface carries only what cordis loading needs plus types.
6
+ */
7
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
8
+ import { type TimelineKey } from './locales.ts';
9
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
10
+ interface LocaleNamespaceMap {
11
+ /** Chat-timeline surface copy. */
12
+ 'chat-timeline': TimelineKey;
13
+ }
14
+ }
15
+ /** Services required by this plugin. */
16
+ export declare const inject: string[];
17
+ /**
18
+ * Register the timeline surface.
19
+ * @param ctx - client root context.
20
+ */
21
+ export declare function apply(ctx: ClientContext): void;
22
+ export type { TimelineRailProps } from './rail.tsx';
23
+ export type { TimelineKey } from './locales.ts';
24
+ export type { TimelineSource, TimelineSnapshot, TimelineState } from './source.ts';
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Locale dictionaries for the chat-timeline plugin. `zh` is the key-set source
3
+ * of truth; `en` keeps a full key-for-key mirror. Registered through
4
+ * ctx.locale.register(NS, { zh, en }).
5
+ */
6
+ /** Simplified Chinese dictionary (key-set source of truth). */
7
+ export declare const zh: {
8
+ 'nav.label': string;
9
+ 'nav.jumpToQuery': string;
10
+ 'preview.userFallback': string;
11
+ 'preview.assistantEmpty': string;
12
+ 'preview.assistantRunning': string;
13
+ };
14
+ /** The chat-timeline namespace key union. */
15
+ export type TimelineKey = keyof typeof zh;
16
+ /** English dictionary, key-for-key complete against zh. */
17
+ export declare const en: Record<TimelineKey, string>;
@@ -0,0 +1,13 @@
1
+ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots';
2
+ import type { TimelineSource } from './source.ts';
3
+ /** Component props: the locale seat plus the injected session source. */
4
+ export type TimelineRailProps = PropsLocale<'chat-timeline'> & {
5
+ /** The current-session snapshot source (built from ctx.sessions). */
6
+ source: TimelineSource;
7
+ };
8
+ /**
9
+ * Render the question navigator rail.
10
+ * @param props - composed slot props.
11
+ * @returns the rail, or null when it should not render.
12
+ */
13
+ export declare function TimelineRail({ source, t }: TimelineRailProps): import("react").JSX.Element | null;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Timeline source: follows the current session through the cordis services
3
+ * face (ctx.sessions) and exposes a useSyncExternalStore-compatible read
4
+ * face over its ConversationSnapshot. All dsh-client-runtime usage here is
5
+ * type-only; at runtime everything is reached through the ctx services, so
6
+ * the client bundle stays free of cross-package value imports.
7
+ */
8
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
9
+ /** The slice of ConversationSnapshot the timeline consumes. */
10
+ export interface TimelineSnapshot {
11
+ readonly sessionId: string;
12
+ /** Ordered conversation nodes (the snapshot's legacy compatibility field). */
13
+ readonly nodes: readonly unknown[];
14
+ /** Whether the session currently has a running turn. */
15
+ readonly running: boolean;
16
+ }
17
+ /** State handed to React: null snapshot means "no readable session right now". */
18
+ export interface TimelineState {
19
+ readonly sessionId: string | undefined;
20
+ readonly snapshot: TimelineSnapshot | null;
21
+ }
22
+ /** useSyncExternalStore-compatible source face. */
23
+ export interface TimelineSource {
24
+ subscribe(listener: () => void): () => void;
25
+ getSnapshot(): TimelineState;
26
+ }
27
+ interface ObservableLike<T> {
28
+ getSnapshot(): T;
29
+ subscribe(listener: () => void): () => void;
30
+ }
31
+ /** Structural face of ctx.sessions this module needs (kept narrow for tests). */
32
+ export interface SessionsLike {
33
+ readonly list: ObservableLike<{
34
+ current?: string;
35
+ }>;
36
+ binding(id: string): {
37
+ session: ObservableLike<{
38
+ nodes?: readonly unknown[];
39
+ running?: boolean;
40
+ }>;
41
+ } | undefined;
42
+ }
43
+ /**
44
+ * Create the source. Subscribes to the sessions list, rebinds to the current
45
+ * session's snapshot feed on selection change, and republishes a stable state
46
+ * object whenever the underlying snapshot changes.
47
+ * @param sessions - the sessions service face (pass ctx.sessions).
48
+ * @returns the source face.
49
+ */
50
+ export declare function createTimelineSource(sessions: SessionsLike): TimelineSource;
51
+ /** Build the source from the plugin client context. */
52
+ export declare function timelineSourceFromContext(ctx: ClientContext): TimelineSource;
53
+ export {};
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Stylesheet for the chat-timeline rail, injected once as a
3
+ * `<style data-plugin-css="chat-timeline">` tag. All classes carry the
4
+ * `dsh-tl-` prefix; colors use the CSS system colors Canvas/CanvasText so the
5
+ * rail follows the app's color-scheme without reaching into DSH theme tokens.
6
+ * Visual constants replicate the ZCode TurnNavigator (spec section 2.2).
7
+ */
8
+ export declare const TIMELINE_STYLES = "\n.dsh-tl-nav {\n position: fixed;\n z-index: 10;\n width: 48px;\n pointer-events: none;\n opacity: 1;\n transition: opacity 150ms ease-out;\n}\n.dsh-tl-nav[data-visible=\"false\"] {\n opacity: 0;\n visibility: hidden;\n}\n.dsh-tl-scroll {\n position: absolute;\n left: 12px;\n top: 50%;\n transform: translateY(-50%);\n width: 36px;\n max-height: calc(100% - 96px);\n overflow-x: hidden;\n overflow-y: auto;\n padding-block: 4px;\n pointer-events: auto;\n scrollbar-width: none;\n color: CanvasText;\n}\n.dsh-tl-scroll::-webkit-scrollbar {\n display: none;\n}\n.dsh-tl-track {\n position: relative;\n width: 36px;\n}\n.dsh-tl-slot {\n position: absolute;\n left: 0;\n top: 0;\n height: 10px;\n width: 36px;\n padding: 0;\n border: 0;\n background: transparent;\n display: flex;\n align-items: center;\n justify-content: flex-start;\n border-radius: 2px;\n cursor: pointer;\n}\n.dsh-tl-slot:focus-visible {\n outline: 2px solid CanvasText;\n outline-offset: 2px;\n}\n.dsh-tl-tick {\n display: block;\n height: 2px;\n width: 12px;\n border-radius: 999px;\n background: currentColor;\n transform-origin: left center;\n transition: height 150ms ease-out, opacity 150ms ease-out, transform 150ms ease-out, background-color 150ms ease-out;\n}\n@media (prefers-reduced-motion: reduce) {\n .dsh-tl-nav,\n .dsh-tl-tick {\n transition: none;\n }\n}\n.dsh-tl-tip {\n position: fixed;\n z-index: 60;\n width: 320px;\n max-width: calc(100vw - 2rem);\n padding: 12px;\n border-radius: 10px;\n background: Canvas;\n color: CanvasText;\n border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);\n box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);\n pointer-events: none;\n}\n.dsh-tl-tip-user {\n margin: 0;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 2;\n overflow: hidden;\n white-space: pre-line;\n font-size: 13px;\n line-height: 20px;\n font-weight: 500;\n}\n.dsh-tl-tip-assistant {\n margin: 8px 0 0;\n display: -webkit-box;\n -webkit-box-orient: vertical;\n -webkit-line-clamp: 3;\n overflow: hidden;\n white-space: pre-line;\n font-size: 13px;\n line-height: 20px;\n opacity: 0.8;\n}\n.dsh-tl-tip-assistant[data-kind=\"running\"],\n.dsh-tl-tip-assistant[data-kind=\"empty\"] {\n opacity: 0.55;\n}\n";
9
+ /** Inject the stylesheet once per document. */
10
+ export declare function ensureStyles(doc: Document): void;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Host loader entry for the chat-timeline plugin — runs in the DSH host
3
+ * process. Pure browser plugin: the host half has no behavior beyond
4
+ * existing as the cordis bundle the profile composition imports; all UI
5
+ * logic lives in the browser half (src/client/index.ts).
6
+ */
7
+ import type { Context } from '@deepseek-ai/cordis';
8
+ /** Apply the host half. */
9
+ export declare function apply(ctx: Context): void;
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@lament_z/dsh-client-ui-chat-timeline",
3
+ "displayName": "DSH Chat Timeline",
4
+ "description": "DSH Web 对话时间线插件:在左侧提供仿 ZCode TurnNavigator 的提问导航条,每个用户问题一个刻度,支持悬停波纹反馈、轮次预览卡片、点击跳转定位与滚动同步高亮,快速回溯长对话。",
5
+ "version": "0.1.0",
6
+ "type": "module",
7
+ "main": "lib/index.js",
8
+ "types": "lib/types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./lib/types/index.d.ts",
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": {
15
+ "types": "./lib/types/client/index.d.ts",
16
+ "default": "./lib/client.js"
17
+ },
18
+ "./src/*": "./src/*",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "dsh": {
22
+ "engines": {
23
+ "dsh": ">=0.1.1-rc.1"
24
+ },
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-runtime"
31
+ ],
32
+ "platform": "web"
33
+ }
34
+ },
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json && tsdown",
37
+ "watch": "tsdown --watch",
38
+ "test": "vitest run",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "peerDependencies": {
42
+ "react": "^18.2.0"
43
+ },
44
+ "devDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1",
46
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
47
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
48
+ "@deepseek-ai/dsh-client-ui-layout": "0.1.1-rc.2",
49
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2",
50
+ "@types/react": "~18.3.1",
51
+ "@types/react-dom": "^18.3.5",
52
+ "jsdom": "29.1.1",
53
+ "react": "^18.3.1",
54
+ "react-dom": "^18.3.1",
55
+ "tsdown": "0.22.2",
56
+ "typescript": "~5.7.2",
57
+ "vitest": "^4.1.8"
58
+ },
59
+ "files": [
60
+ "lib",
61
+ "cordis.patch.yml",
62
+ "README.md",
63
+ "README.zh.md",
64
+ "README.i18n.yaml"
65
+ ],
66
+ "publishConfig": {
67
+ "access": "public",
68
+ "registry": "https://registry.npmjs.org/"
69
+ },
70
+ "repository": {
71
+ "type": "git",
72
+ "url": "git+https://github.com/lament-z/dsh-client-ui-chat-timeline.git"
73
+ },
74
+ "bugs": {
75
+ "url": "https://github.com/lament-z/dsh-client-ui-chat-timeline/issues"
76
+ },
77
+ "homepage": "https://github.com/lament-z/dsh-client-ui-chat-timeline",
78
+ "license": "MIT"
79
+ }