@lament_z/dsh-client-ui-chat-timeline 0.1.0 → 0.2.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/lib/client.js CHANGED
@@ -7,87 +7,12 @@ window.__ModuleLoader__.load({
7
7
  let react = require("react");
8
8
  let react_dom = require("react-dom");
9
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
10
  //#region src/client/dom.ts
86
11
  /**
87
12
  * Find the chat scroll container with a candidate chain.
88
13
  * 1. the explicit stable hook `[data-conversation-scroll]`;
89
14
  * 2. the closest scrollable ancestor of the flow column `[data-chat-flow]`;
90
- * 3. the largest scrollable element that contains human rows.
15
+ * 3. the largest scrollable element that contains keyed flow items.
91
16
  */
92
17
  function findChatContainer(doc) {
93
18
  const direct = doc.querySelector("[data-conversation-scroll]");
@@ -99,7 +24,7 @@ window.__ModuleLoader__.load({
99
24
  let bestHeight = 0;
100
25
  for (const element of Array.from(doc.querySelectorAll("*"))) {
101
26
  if (!isScrollable(element)) continue;
102
- if (element.querySelector("[data-chat-flow-kind=\"user\"]") === null) continue;
27
+ if (element.querySelector("[data-chat-turn]") === null) continue;
103
28
  const height = element.clientHeight;
104
29
  if (height > bestHeight) {
105
30
  best = element;
@@ -122,70 +47,117 @@ window.__ModuleLoader__.load({
122
47
  }
123
48
  return null;
124
49
  }
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\"]"));
50
+ /**
51
+ * The rendered row of `turn` (native anchor attribute `data-chat-turn`),
52
+ * skipping hidden rows like the native `anchorElement`. Scoped to the chat
53
+ * container when it is already located (native scopes to its own list), so a
54
+ * stray second mount cannot hijack the anchor. Null when the turn's events
55
+ * are not in the loaded window yet.
56
+ */
57
+ function findTurnRow(doc, turn) {
58
+ if (turn === void 0 || !Number.isSafeInteger(turn) || turn < 0) return null;
59
+ return (findChatContainer(doc) ?? doc).querySelector(`[data-chat-turn="${turn}"]:not([hidden])`);
60
+ }
61
+ /** Native `scrollerOf`: the conversation scrollport owning the row. */
62
+ function scrollerOfRow(doc, row) {
63
+ return row.closest("[data-conversation-scroll]") ?? findChatContainer(doc);
64
+ }
65
+ /** Row position in scrollport coordinates (native `flowTop`). */
66
+ function flowTop(row, scroller) {
67
+ return row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
129
68
  }
130
69
  /**
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.
70
+ * Native `landOnRow`: instant scroll so the row's top sits 24px below the
71
+ * scrollport top. Returns false when the scrollport cannot be resolved.
134
72
  */
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;
73
+ function landOnRow(doc, row) {
74
+ const el = scrollerOfRow(doc, row);
75
+ if (el === null) return false;
76
+ el.scrollTop += flowTop(row, el) - 24;
77
+ return true;
143
78
  }
144
79
  /**
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.
80
+ * Native `turnAtLine`: the turn owning the row at a scrollport line.
81
+ * Hit-tests the line first (elementsFromPoint at the container's horizontal
82
+ * center) and falls back to one row scan when layout cannot answer (jsdom,
83
+ * pre-paint). Returns null when no loaded row covers the line.
150
84
  */
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;
85
+ function turnAtLine(doc, container, line) {
86
+ const content = container.getBoundingClientRect();
87
+ if (typeof doc.elementsFromPoint === "function" && content.width > 0) for (const element of doc.elementsFromPoint(content.left + content.width / 2, line)) {
88
+ const row = element instanceof HTMLElement ? element.closest("[data-chat-turn]") : null;
89
+ const turn = Number(row?.dataset.chatTurn);
90
+ if (row !== null && container.contains(row) && Number.isSafeInteger(turn)) return turn;
164
91
  }
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;
92
+ let found = null;
93
+ for (const row of Array.from(container.querySelectorAll("[data-chat-turn]"))) {
94
+ if (row.getBoundingClientRect().top > line) break;
95
+ const turn = Number(row.dataset.chatTurn);
96
+ if (Number.isSafeInteger(turn)) found = turn;
97
+ }
98
+ return found;
99
+ }
100
+ /**
101
+ * The active index (native `syncActiveTurn`): within 25px of the scroll
102
+ * bottom the last tick wins; otherwise the reading line sits at
103
+ * `top + min(96, height * 0.2)` and the active tick is the last one whose
104
+ * turn does not exceed the turn read at that line. When the line covers no
105
+ * loaded row, earlier ticks outside the window count as above the viewport
106
+ * (the window head's predecessor is active, matching 0.1.1 parity).
107
+ */
108
+ function computeActiveIndex(container, doc, turns) {
109
+ if (turns.length === 0) return -1;
110
+ if (container.scrollHeight - container.scrollTop - container.clientHeight <= 25) return turns.length - 1;
111
+ const firstDefined = turns.findIndex((turn) => turn !== void 0);
112
+ if (firstDefined === -1) return -1;
113
+ const readTurn = turnAtLine(doc, container, container.getBoundingClientRect().top + Math.min(96, container.clientHeight * .2));
114
+ let active = -1;
115
+ if (readTurn !== null) for (let index = 0; index < turns.length; index += 1) {
116
+ const turn = turns[index];
117
+ if (turn === void 0 || turn > readTurn) continue;
118
+ active = index;
119
+ }
120
+ if (active !== -1) return active;
121
+ return firstDefined > 0 ? firstDefined - 1 : 0;
122
+ }
123
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
124
+ /** Landing poll budget after `loadThrough` (native settles via its effects; 5s here). */
125
+ const LAND_POLL_ATTEMPTS = 20;
126
+ const LAND_POLL_INTERVAL_MS = 250;
127
+ /**
128
+ * 跳到第 index 个刻度的完整落点流程(原生 navigateToTurn 的插件侧等价物):
129
+ * 1. 按 `data-chat-turn` 找行并原生落位(瞬时、行顶距视口顶 24px);
130
+ * 2. 行未挂载时先 `loadThrough(turn/start 的 seq)` 把事件窗口翻页到位;
131
+ * 3. 轮询等行渲染后落位。任何一步成功即返回 true。
132
+ */
133
+ async function jumpToTurn(doc, index, turns, seqs, loadThrough) {
134
+ const turn = turns[index];
135
+ if (turn === void 0) return false;
136
+ if (landOnRowByTurn(doc, turn)) return true;
137
+ const seq = seqs[index];
138
+ if (seq !== void 0 && loadThrough !== void 0) try {
139
+ await loadThrough(seq);
140
+ } catch {}
141
+ for (let attempt = 0; attempt < LAND_POLL_ATTEMPTS; attempt += 1) {
142
+ await sleep(LAND_POLL_INTERVAL_MS);
143
+ if (landOnRowByTurn(doc, turn)) return true;
144
+ }
145
+ return false;
146
+ }
147
+ function landOnRowByTurn(doc, turn) {
148
+ const row = findTurnRow(doc, turn);
149
+ if (row === null) return false;
150
+ return landOnRow(doc, row);
177
151
  }
178
152
  /** Create the probe against a document (injectable for tests). */
179
153
  function probeChatDom(doc) {
180
154
  return {
181
155
  getContainer: () => findChatContainer(doc),
182
- getUserRows: () => findUserRows(findChatContainer(doc)),
183
- activeIndex: () => {
156
+ activeIndex: (turns) => {
184
157
  const container = findChatContainer(doc);
185
158
  if (container === null) return -1;
186
- return computeActiveIndex(container, findUserRows(container));
187
- },
188
- jumpTo: (index, behavior) => jumpTo(index, behavior)
159
+ return computeActiveIndex(container, doc, turns);
160
+ }
189
161
  };
190
162
  }
191
163
  //#endregion
@@ -202,6 +174,17 @@ window.__ModuleLoader__.load({
202
174
  position: fixed;
203
175
  z-index: 10;
204
176
  width: 48px;
177
+ /* 水平:会话列左沿,由 rail 写入 --dsh-tl-left。这是唯一保留的测量值——
178
+ 列是 grid 轨道,右侧栏打开时会被挤压,不贴窗口右沿,无法由窗口宽度推导。 */
179
+ left: var(--dsh-tl-left, 0px);
180
+ /* 垂直:原生 TurnNavigator 的 band 模型(eGxaPq_frame 的 --turn-rail-band)。
181
+ 会话可视带 = 视口高 − 输入框高;其中点在视口坐标下为
182
+ 100dvh − (会话可视高 + 输入框高) / 2
183
+ 两个变量由宿主写在滚动容器上,本 seat 在滚动容器内可直接继承,故整个垂直
184
+ 几何是纯 CSS:不做测量,也不依赖原生 nav 是否存在于 DOM。 */
185
+ --dsh-tl-band: calc(var(--dsh-conversation-viewport-height, 100dvh) - var(--dsh-composer-height, 152px));
186
+ top: calc(100dvh - (var(--dsh-conversation-viewport-height, 100dvh) + var(--dsh-composer-height, 152px)) / 2);
187
+ transform: translateY(-50%);
205
188
  pointer-events: none;
206
189
  opacity: 1;
207
190
  transition: opacity 150ms ease-out;
@@ -211,12 +194,11 @@ window.__ModuleLoader__.load({
211
194
  visibility: hidden;
212
195
  }
213
196
  .dsh-tl-scroll {
214
- position: absolute;
197
+ position: relative;
215
198
  left: 12px;
216
- top: 50%;
217
- transform: translateY(-50%);
218
199
  width: 36px;
219
- max-height: calc(100% - 96px);
200
+ /* 原生 frame 的高度上限:band − 64px,再夹到 420px。 */
201
+ max-height: min(max(0px, calc(var(--dsh-tl-band) - 64px)), 420px);
220
202
  overflow-x: hidden;
221
203
  overflow-y: auto;
222
204
  padding-block: 4px;
@@ -272,9 +254,11 @@ window.__ModuleLoader__.load({
272
254
  max-width: calc(100vw - 2rem);
273
255
  padding: 12px;
274
256
  border-radius: 10px;
275
- background: Canvas;
276
- color: CanvasText;
277
- border: 1px solid color-mix(in srgb, CanvasText 15%, transparent);
257
+ /* 原生 tooltip 配对(dsh-web-frontend _bubble_):灰底 + 静态白字,
258
+ 主题切换由别名变量跟随;灰底与页面背景天然分层。 */
259
+ background: var(--dsw-alias-tooltip-bg, #43454a);
260
+ color: var(--dsw-static-neutral-bluish-00, #fff);
261
+ border: none;
278
262
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
279
263
  pointer-events: none;
280
264
  }
@@ -298,11 +282,18 @@ window.__ModuleLoader__.load({
298
282
  white-space: pre-line;
299
283
  font-size: 13px;
300
284
  line-height: 20px;
301
- opacity: 0.8;
285
+ color: var(--dsw-static-neutral-bluish-300, rgba(255, 255, 255, 0.8));
302
286
  }
303
287
  .dsh-tl-tip-assistant[data-kind="running"],
304
288
  .dsh-tl-tip-assistant[data-kind="empty"] {
305
- opacity: 0.55;
289
+ color: var(--dsw-static-neutral-bluish-400, rgba(255, 255, 255, 0.55));
290
+ }
291
+ /* 插件完全接管原生右侧 Turn 导航条:本样式表只随插件存在,插件卸载时
292
+ removeStyles 移除标签,原生导航条立即恢复。aria-label 跟随宿主语言
293
+ (英文 Turn navigation / 中文 轮次导航),两个都要盖住。 */
294
+ nav[aria-label='Turn navigation'],
295
+ nav[aria-label='轮次导航'] {
296
+ display: none !important;
306
297
  }
307
298
  `;
308
299
  /** Inject the stylesheet once per document. */
@@ -315,22 +306,28 @@ window.__ModuleLoader__.load({
315
306
  tag.textContent = TIMELINE_STYLES;
316
307
  doc.head.appendChild(tag);
317
308
  }
309
+ /** Remove the stylesheet (plugin teardown): restores the native turn navigator. */
310
+ function removeStyles(doc) {
311
+ doc.querySelector("style[data-plugin-css=\"chat-timeline\"]")?.remove();
312
+ }
318
313
  //#endregion
319
314
  //#region src/client/rail.tsx
320
315
  /**
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):
316
+ * TimelineRail — the conversation.input.overlay entry: a left-edge tick rail
317
+ * over the conversation, one tick per human question. Visual and interaction
318
+ * constants replicate the ZCode TurnNavigator (see .scratch/chat-timeline/spec.md):
324
319
  * 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.
320
+ * 320px preview cards after a 120ms delay, prefers-reduced-motion fallback,
321
+ * full aria labelling.
322
+ *
323
+ * Geometry follows the native TurnNavigator model: the vertical band
324
+ * (--dsh-tl-band, the host's viewport-minus-composer rule) is computed in CSS
325
+ * from the host variables that the seat inherits, so no JS measures it. Only
326
+ * the horizontal left edge is measured — the conversation column is a grid
327
+ * track, so it is not flush to the window edge once the right sidebar opens.
327
328
  */
328
329
  /** Slot height per tick (10px pitch, ZCode parity). */
329
330
  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
331
  /** Preview-card hover delays (ms). */
335
332
  const TIP_OPEN_DELAY = 120;
336
333
  const TIP_CLOSE_DELAY = 80;
@@ -364,29 +361,16 @@ window.__ModuleLoader__.load({
364
361
  * through system colors). */
365
362
  const TICK_COLOR_FOREGROUND = "CanvasText";
366
363
  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
364
  /**
380
365
  * Render the question navigator rail.
381
366
  * @param props - composed slot props.
382
367
  * @returns the rail, or null when it should not render.
383
368
  */
384
- function TimelineRail({ source, t }) {
369
+ function TimelineRail({ sessionId, source, t }) {
385
370
  const state = (0, react.useSyncExternalStore)(source.subscribe, source.getSnapshot);
386
- const reducedMotion = useReducedMotion();
387
371
  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);
372
+ const [container, setContainer] = (0, react.useState)(null);
373
+ const [navEl, setNavEl] = (0, react.useState)(null);
390
374
  const [activeIndex, setActiveIndex] = (0, react.useState)(-1);
391
375
  const [hoverIndex, setHoverIndex] = (0, react.useState)(void 0);
392
376
  const [tipIndex, setTipIndex] = (0, react.useState)(void 0);
@@ -395,24 +379,31 @@ window.__ModuleLoader__.load({
395
379
  close: void 0
396
380
  });
397
381
  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
382
  const snapshot = state.snapshot;
404
- const items = (0, react.useMemo)(() => snapshot === null ? [] : buildTimelineItems(snapshot.nodes, snapshot.running, labels), [snapshot, labels]);
383
+ const items = (0, react.useMemo)(() => snapshot === null ? [] : snapshot.items, [snapshot]);
384
+ const sourceRef = (0, react.useRef)(source);
385
+ sourceRef.current = source;
386
+ const itemsRef = (0, react.useRef)([]);
387
+ itemsRef.current = items;
388
+ const seqs = (0, react.useMemo)(() => items.map((item) => item.seq), [items]);
389
+ const seqsRef = (0, react.useRef)([]);
390
+ seqsRef.current = seqs;
391
+ const turns = (0, react.useMemo)(() => items.map((item) => item.turn), [items]);
392
+ const turnsRef = (0, react.useRef)([]);
393
+ turnsRef.current = turns;
405
394
  (0, react.useEffect)(() => {
406
395
  if (typeof document === "undefined") return;
407
396
  ensureStyles(document);
397
+ return () => removeStyles(document);
408
398
  }, []);
409
399
  (0, react.useEffect)(() => {
410
400
  if (probe === null) return;
411
401
  let attempts = 0;
412
402
  let timer;
413
403
  const tick = () => {
414
- if (probe.getContainer() !== null || attempts >= 10) {
415
- setContainerVersion((version) => version + 1);
404
+ const found = probe.getContainer();
405
+ if (found !== null || attempts >= 10) {
406
+ setContainer(found);
416
407
  return;
417
408
  }
418
409
  attempts += 1;
@@ -422,26 +413,28 @@ window.__ModuleLoader__.load({
422
413
  return () => {
423
414
  if (timer !== void 0) clearTimeout(timer);
424
415
  };
425
- }, [probe, state.sessionId]);
416
+ }, [probe, sessionId]);
426
417
  (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
- }
418
+ if (container === null || navEl === null) return;
419
+ const write = () => {
420
+ navEl.style.setProperty("--dsh-tl-left", `${container.getBoundingClientRect().left}px`);
421
+ };
422
+ write();
423
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(write);
424
+ observer?.observe(container);
425
+ window.addEventListener("resize", write);
426
+ return () => {
427
+ observer?.disconnect();
428
+ window.removeEventListener("resize", write);
429
+ };
430
+ }, [container, navEl]);
431
+ (0, react.useEffect)(() => {
432
+ if (container === null || probe === null) return;
433
433
  const sync = () => {
434
434
  if (frame.current !== 0) return;
435
435
  frame.current = requestAnimationFrame(() => {
436
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());
437
+ setActiveIndex(probe.activeIndex(turnsRef.current));
445
438
  });
446
439
  };
447
440
  sync();
@@ -456,8 +449,16 @@ window.__ModuleLoader__.load({
456
449
  if (frame.current !== 0) cancelAnimationFrame(frame.current);
457
450
  frame.current = 0;
458
451
  };
459
- }, [probe, containerVersion]);
460
- const visible = snapshot !== null && items.length >= 2 && rect !== null && rect.width >= MIN_CONTAINER_WIDTH;
452
+ }, [container, probe]);
453
+ (0, react.useEffect)(() => {
454
+ if (container === null || probe === null) return;
455
+ setActiveIndex(probe.activeIndex(turns));
456
+ }, [
457
+ container,
458
+ probe,
459
+ turns
460
+ ]);
461
+ const visible = snapshot !== null;
461
462
  const focusIndex = hoverIndex;
462
463
  const scheduleTip = (0, react.useCallback)((index) => {
463
464
  if (timers.current.open !== void 0) window.clearTimeout(timers.current.open);
@@ -473,22 +474,18 @@ window.__ModuleLoader__.load({
473
474
  if (timers.current.close !== void 0) window.clearTimeout(timers.current.close);
474
475
  }, []);
475
476
  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;
477
+ jumpToTurn(document, index, turnsRef.current, seqsRef.current, (seq) => sourceRef.current?.jumpThrough(seq) ?? Promise.resolve());
478
+ }, []);
479
+ if (!visible || probe === null) return null;
479
480
  const trackHeight = items.length * ITEM_PITCH;
480
481
  const tip = tipIndex !== void 0 ? items[tipIndex] : void 0;
481
482
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
483
+ ref: setNavEl,
482
484
  "aria-label": t("nav.label"),
483
485
  className: "dsh-tl-nav",
484
486
  "data-visible": visible ? "true" : "false",
485
487
  "data-testid": "dsh-chat-timeline",
486
488
  "data-item-count": items.length,
487
- style: {
488
- left: rect.left,
489
- top: rect.top,
490
- height: rect.height
491
- },
492
489
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
493
490
  className: "dsh-tl-scroll",
494
491
  onPointerLeave: () => {
@@ -558,106 +555,134 @@ window.__ModuleLoader__.load({
558
555
  }), tip !== void 0 && tipIndex !== void 0 ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TipCard, {
559
556
  item: tip,
560
557
  index: tipIndex,
561
- rect,
558
+ nav: navEl,
562
559
  t
563
560
  }), document.body) : null] });
564
561
  }
565
- function TipCard({ item, index, rect, t }) {
566
- const box = document.querySelector("[data-testid=\"dsh-chat-timeline-item\"][data-item-index=\"" + String(index) + "\"]")?.getBoundingClientRect();
562
+ function TipCard({ item, index, nav, t }) {
563
+ const box = (nav?.querySelector("[data-testid=\"dsh-chat-timeline-item\"][data-item-index=\"" + String(index) + "\"]"))?.getBoundingClientRect();
564
+ const railBox = nav?.getBoundingClientRect();
567
565
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
568
566
  className: "dsh-tl-tip",
569
567
  "data-testid": "dsh-chat-timeline-tip",
570
568
  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))
569
+ left: box !== void 0 ? Math.min(box.right + 8, window.innerWidth - 336) : railBox !== void 0 ? railBox.right + 8 : 8,
570
+ top: box !== void 0 ? Math.max(8, Math.min(box.top - 4, window.innerHeight - 160)) : 8
573
571
  },
574
572
  role: "tooltip",
575
573
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
576
574
  className: "dsh-tl-tip-user",
577
- children: item.userPreview
575
+ children: item.userFallback ? t("preview.userFallback") : item.userPreview
578
576
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
579
577
  className: "dsh-tl-tip-assistant",
580
578
  "data-kind": item.assistantKind,
581
- children: item.assistantPreview
579
+ children: item.assistantKind === "running" ? t("preview.assistantRunning") : item.assistantKind === "empty" ? t("preview.assistantEmpty") : item.assistantPreview
582
580
  })]
583
581
  });
584
582
  }
585
583
  //#endregion
586
584
  //#region src/client/source.ts
587
- const EMPTY_STATE = {
588
- sessionId: void 0,
589
- snapshot: null
585
+ /** Project one outline entry onto a tick (previews are host-bounded already). */
586
+ function itemFromOutline(entry, isLive, labels) {
587
+ const prompt = typeof entry.prompt === "string" ? entry.prompt : "";
588
+ const response = typeof entry.response === "string" ? entry.response : "";
589
+ const assistantKind = response ? "text" : isLive ? "running" : "empty";
590
+ return {
591
+ key: `t${entry.turn}`,
592
+ seq: entry.seq,
593
+ time: 0,
594
+ turn: entry.turn,
595
+ userPreview: prompt || labels.userFallback,
596
+ userFallback: !prompt,
597
+ assistantPreview: response || (isLive ? labels.assistantRunning : labels.assistantEmpty),
598
+ assistantKind,
599
+ running: isLive
600
+ };
601
+ }
602
+ /** Build the tick list from the outline plus the live running flag. */
603
+ function itemsFromOutline(outline, running, labels) {
604
+ const last = outline.length - 1;
605
+ return outline.map((entry, index) => itemFromOutline(entry, running && index === last, labels));
606
+ }
607
+ /** Fallback preview labels when the outline entry carries no text. */
608
+ const PREVIEW_LABELS = {
609
+ userFallback: "(无文本)",
610
+ assistantRunning: "(运行中)",
611
+ assistantEmpty: "(空)"
590
612
  };
591
613
  /**
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.
614
+ * Create the source for one session. The id is supplied by the host — the
615
+ * session-scoped seat hands it to `inject` — so this no longer guesses which
616
+ * session is open and no longer watches the sessions list: it binds straight
617
+ * to that session's outline projection and republishes whenever the outline or
618
+ * the running flag moves.
619
+ *
620
+ * A bounded retry covers the materialization window (the seat can render a
621
+ * beat before `binding(id)` answers); after that the rail stays hidden.
595
622
  * @param sessions - the sessions service face (pass ctx.sessions).
623
+ * @param sessionId - the session this rail belongs to.
596
624
  * @returns the source face.
597
625
  */
598
- function createTimelineSource(sessions) {
599
- let state = EMPTY_STATE;
626
+ function createTimelineSource(sessions, sessionId) {
627
+ let state = {
628
+ sessionId,
629
+ snapshot: null
630
+ };
600
631
  const listeners = /* @__PURE__ */ new Set();
601
- let unbindSession = null;
602
- let boundSessionId;
632
+ let subscribed = false;
603
633
  let retryTimer;
604
634
  let retryAttempts = 0;
605
635
  const emit = () => {
606
636
  for (const listener of listeners) listener();
607
637
  };
608
- const rebind = () => {
609
- if (retryTimer !== void 0) {
610
- clearTimeout(retryTimer);
611
- retryTimer = void 0;
612
- }
613
- let current;
638
+ const readBinding = () => {
614
639
  try {
615
- current = sessions.list.getSnapshot()?.current;
640
+ return sessions.binding(sessionId);
616
641
  } 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
642
  return;
627
643
  }
628
- let session;
629
- try {
630
- session = sessions.binding(current)?.session;
631
- } catch {
632
- session = void 0;
644
+ };
645
+ const bind = () => {
646
+ if (retryTimer !== void 0) {
647
+ clearTimeout(retryTimer);
648
+ retryTimer = void 0;
633
649
  }
634
- if (!session) {
650
+ const session = readBinding()?.session;
651
+ const outlineFace = (() => {
652
+ try {
653
+ return session?.projections?.faceOf?.("turnOutline");
654
+ } catch {
655
+ return;
656
+ }
657
+ })();
658
+ if (!session || !outlineFace) {
635
659
  state = {
636
- sessionId: current,
660
+ sessionId,
637
661
  snapshot: null
638
662
  };
639
- if (retryAttempts < 10) {
663
+ emit();
664
+ if (subscribed && retryAttempts < 10) {
640
665
  retryAttempts += 1;
641
- retryTimer = setTimeout(rebind, 500 * retryAttempts);
666
+ retryTimer = setTimeout(bind, 250 * retryAttempts);
642
667
  }
643
- emit();
644
668
  return;
645
669
  }
646
670
  retryAttempts = 0;
647
671
  const push = () => {
648
672
  try {
649
- const snap = session.getSnapshot();
673
+ const outline = outlineFace.getSnapshot();
674
+ const running = Boolean(session.getSnapshot()?.running);
650
675
  state = {
651
- sessionId: current,
676
+ sessionId,
652
677
  snapshot: {
653
- sessionId: current,
654
- nodes: Array.isArray(snap?.nodes) ? snap.nodes : [],
655
- running: Boolean(snap?.running)
678
+ sessionId,
679
+ items: Array.isArray(outline) ? itemsFromOutline(outline, running, PREVIEW_LABELS) : [],
680
+ running
656
681
  }
657
682
  };
658
683
  } catch {
659
684
  state = {
660
- sessionId: current,
685
+ sessionId,
661
686
  snapshot: null
662
687
  };
663
688
  }
@@ -665,21 +690,17 @@ window.__ModuleLoader__.load({
665
690
  };
666
691
  push();
667
692
  try {
693
+ outlineFace.subscribe(push);
668
694
  session.subscribe(push);
669
- unbindSession = () => {};
670
- } catch {
671
- unbindSession = null;
672
- }
695
+ } catch {}
673
696
  };
674
697
  return {
675
698
  subscribe(listener) {
676
699
  const first = listeners.size === 0;
677
700
  listeners.add(listener);
678
701
  if (first) {
679
- try {
680
- sessions.list.subscribe(rebind);
681
- } catch {}
682
- rebind();
702
+ subscribed = true;
703
+ bind();
683
704
  }
684
705
  return () => {
685
706
  listeners.delete(listener);
@@ -687,12 +708,17 @@ window.__ModuleLoader__.load({
687
708
  },
688
709
  getSnapshot() {
689
710
  return state;
711
+ },
712
+ jumpThrough(seq) {
713
+ const session = readBinding()?.session;
714
+ if (typeof session?.loadThrough !== "function") return Promise.resolve();
715
+ return session.loadThrough(seq).catch(() => {});
690
716
  }
691
717
  };
692
718
  }
693
- /** Build the source from the plugin client context. */
694
- function timelineSourceFromContext(ctx) {
695
- return createTimelineSource(ctx.sessions);
719
+ /** Build the source from the plugin client context and the seat's session id. */
720
+ function timelineSourceFromContext(ctx, sessionId) {
721
+ return createTimelineSource(ctx.sessions, sessionId);
696
722
  }
697
723
  //#endregion
698
724
  //#region src/client/locales.ts
@@ -721,7 +747,25 @@ window.__ModuleLoader__.load({
721
747
  //#region src/client/index.ts
722
748
  /** Dictionary namespace owned by this plugin. */
723
749
  const NS = "chat-timeline";
724
- /** Unique occupant id inside the shared shell.overlay list slot. */
750
+ /**
751
+ * Session-scoped seat the rail occupies.
752
+ *
753
+ * DSH 0.1.7 moved "which session is open" out of the sessions service: the list
754
+ * snapshot no longer carries `current`, `binding(id)` only borrows an already
755
+ * retained scope, and session-controller states outright that view selection
756
+ * stays outside the Controller. The only supported way to learn the open
757
+ * session is a session-scoped seat, whose `inject` receives the scope
758
+ * binding's key — the session id. A root-scope seat (shell.overlay) cannot be
759
+ * told, which is why the rail no longer lives there.
760
+ *
761
+ * `conversation.input.overlay` is rendered for every open session, and unlike
762
+ * `conversation.view` it is not enumerated into the conversation's view tabs.
763
+ * It also sits inside the conversation scroller, so the host's geometry
764
+ * variables (--dsh-conversation-viewport-height / --dsh-composer-height)
765
+ * inherit to the rail.
766
+ */
767
+ const SEAT = "conversation.input.overlay";
768
+ /** Unique occupant id inside the shared conversation.input.overlay list slot. */
725
769
  const ENTRY_ID = "chat-timeline";
726
770
  /** Services required by this plugin. */
727
771
  const inject = [
@@ -744,15 +788,20 @@ window.__ModuleLoader__.load({
744
788
  return () => {};
745
789
  }
746
790
  }, "chat-timeline: dictionaries");
747
- ctx.slots.inject("shell.overlay", () => {
791
+ ctx.slots.inject(SEAT, () => {
748
792
  try {
749
793
  return ctx.slots.register({
750
- name: "shell.overlay",
794
+ name: SEAT,
751
795
  id: ENTRY_ID,
796
+ order: 0,
752
797
  locale: NS,
753
- inject: () => ({ source: timelineSourceFromContext(ctx) })
798
+ inject: (sessionId) => ({
799
+ sessionId,
800
+ source: timelineSourceFromContext(ctx, sessionId)
801
+ })
754
802
  }, TimelineRail);
755
- } catch {
803
+ } catch (err) {
804
+ console.error(`chat-timeline: ${SEAT} register failed:`, err);
756
805
  return () => {};
757
806
  }
758
807
  });