@khorsheed/dsh-message-timeline 0.2.3 → 0.2.5

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.
@@ -3,19 +3,30 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  * Message timeline panel, browser half. One entry in the official
4
4
  * conversation.session.header.utilities seat anchors the plugin into the
5
5
  * session scope and renders, through a body portal, the flat floating
6
- * timeline over the left edge of the chat scrollport: one row per loaded
7
- * user message — a tick plus an ellipsized one-line preview, no frame and no
6
+ * timeline over the left edge of the chat scrollport: one row per user
7
+ * message — a tick plus an ellipsized one-line preview, no frame and no
8
8
  * visible scrollbar. At rest only the dimmed ticks show, the reading
9
9
  * position's tick in blue (the latest message until the tracker answers);
10
10
  * hovering or keyboard-focusing the panel reveals the row texts with the
11
11
  * blue row on top, and clicking a row jumps the transcript to that message.
12
12
  * The panel is always on while the chat view shows; the `enabled` config is
13
13
  * the off switch.
14
+ *
15
+ * The row list is whole-session from the moment the session opens: the host
16
+ * `turnOutline` projection names every started turn, and the loaded chat
17
+ * window overlays richer previews (plus steering rows and message-tools
18
+ * bubbles) for the turns it covers. A turn outside the window renders as a
19
+ * dimmed outline row; confirming it pages history back through the turn's
20
+ * `turn/start` seq (the session jump loader) and lands on the freshly loaded
21
+ * row. When the host serves no turnOutline value the panel degrades to the
22
+ * loaded window alone, paged by prefetch and panel-top scrolls.
14
23
  */
15
24
  import { memo, useEffect, useMemo, useRef, useState } from 'react';
16
25
  import { createPortal } from 'react-dom';
17
26
  import { PANEL_WIDTH_MIN } from './config.js';
18
27
  import { chatHookOf } from './chat-hook.js';
28
+ import { outlineEntries } from './outline.js';
29
+ import { mergeRows, firstVisibleKeyInTurn } from './rows.js';
19
30
  import { previewText } from './preview.js';
20
31
  import { foldHiddenSpans, isSeqHidden } from './hidden-spans.js';
21
32
  import { isTimelineRowKind, EDIT_KIND, RESTORED_KIND } from './timeline-kinds.js';
@@ -24,6 +35,35 @@ import css from './TimelineRail.module.css';
24
35
  function nodeContent(node) {
25
36
  return node.data.content ?? [];
26
37
  }
38
+ /**
39
+ * One row's display text: the loaded row's joined text blocks, or the outline
40
+ * entry's bounded first-prompt preview. `null` means the message carries no
41
+ * text (an image/attachment-only input, or an automated turn with no human
42
+ * prompt) — the row renders the `rail.empty` label instead.
43
+ */
44
+ function rowPreview(row) {
45
+ return row.kind === 'loaded'
46
+ ? previewText(nodeContent(row.node))
47
+ : (row.prompt !== '' ? row.prompt : null);
48
+ }
49
+ /**
50
+ * The row's display label: the text preview when there is one, else a
51
+ * kind-aware fallback — the turn number for an outline-only turn, and an
52
+ * image/attachment label for a loaded message with no text blocks.
53
+ */
54
+ function rowLabel(row, t) {
55
+ const preview = rowPreview(row);
56
+ if (preview !== null)
57
+ return preview;
58
+ if (row.kind === 'outline')
59
+ return t('rail.turn', { turn: row.turn });
60
+ const types = new Set(nodeContent(row.node).map(block => block.type));
61
+ if (types.has('image'))
62
+ return t('rail.image');
63
+ if (types.has('file'))
64
+ return t('rail.attachment');
65
+ return t('rail.empty');
66
+ }
27
67
  /**
28
68
  * Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
29
69
  * band sits this far inside the box, so the width budget must give it back
@@ -34,12 +74,18 @@ const PANEL_PADDING_X = 8;
34
74
  const PANEL_GAP = 16;
35
75
  /** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
36
76
  const DEGRADED_WIDTH_RATIO = 0.4;
77
+ /**
78
+ * Frames an outline jump retries the DOM scroll after its row materializes in
79
+ * the chat store: the transcript renders the new page in the same commit or a
80
+ * near frame, and a half-second budget covers the skew without hanging.
81
+ */
82
+ const JUMP_RETRY_FRAMES = 30;
37
83
  /**
38
84
  * The header-utilities entry: the portal timeline panel.
39
85
  * @param props - composed props (see {@link TimelineRailProps}).
40
86
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
41
87
  */
42
- export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
88
+ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
43
89
  const rail = useRail(s => s);
44
90
  // Chat data lives in the `useChat` standard prop on 0.1.2 (the rc-line
45
91
  // session-snapshot seat is gone); the helper degrades to an empty slice
@@ -50,6 +96,13 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
50
96
  const nodes = useChatSlice(c => c.nodes);
51
97
  const hasMore = useSession(s => s.hasMore);
52
98
  const loadingOlder = useSession(s => s.loadingOlder);
99
+ // The host projection seat exists on every supported host; the VALUE is
100
+ // present only where the assembly mounts the turn-outline unit. Both reads
101
+ // degrade to undefined, which keeps the window-paging list. The narrow is
102
+ // memoized on the projection's reference-stable value so unrelated renders
103
+ // do not churn the merged list (or re-run the jump effect).
104
+ const outlineValue = standard.useProjection?.('turnOutline');
105
+ const outline = useMemo(() => outlineEntries(outlineValue), [outlineValue]);
53
106
  const items = useMemo(() => {
54
107
  // The node store is an in-memory snapshot; take it once so the span fold
55
108
  // and the message-tools append read the same snapshot.
@@ -74,7 +127,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
74
127
  // ordinary session carries no span node, so the fold is empty and every
75
128
  // order row still renders: this filter never alters the baseline.
76
129
  const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
77
- const result = [];
130
+ const loaded = [];
78
131
  const seen = new Set();
79
132
  for (const key of order) {
80
133
  const node = nodes.get(key);
@@ -84,7 +137,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
84
137
  continue;
85
138
  if (hidden(node))
86
139
  continue;
87
- result.push({ key, node });
140
+ loaded.push({ key, node });
88
141
  seen.add(key);
89
142
  }
90
143
  // After a message-tools in-place edit the replacement materializes as a
@@ -105,7 +158,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
105
158
  continue;
106
159
  if (hidden(node))
107
160
  continue;
108
- result.push({ key: node.key, node });
161
+ loaded.push({ key: node.key, node });
109
162
  seen.add(node.key);
110
163
  }
111
164
  // The order loop is seq-sorted, but the appended bubbles walked the store
@@ -116,22 +169,95 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
116
169
  // ordinary session has no appended bubbles, so this never reorders the
117
170
  // baseline (which is already seq-sorted). Dropped rows are gone before the
118
171
  // sort, so a withdrawn original never resurfaces.
119
- result.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
120
- return result;
121
- }, [order, nodes, includeSteering]);
172
+ loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
173
+ return mergeRows(loaded, outline);
174
+ }, [order, nodes, includeSteering, outline]);
122
175
  // The lit row: the hover/arrow preselection while it moves, otherwise the
123
176
  // live reading position the tracker publishes, defaulting to the latest
124
177
  // message before the tracker answers. ArrowUp/Down or hovering moves a
125
- // bold preselection; Enter or a click confirms it and jumps.
178
+ // bold preselection; Enter or a click confirms it and jumps. Loaded rows
179
+ // identify by their node key (the tracker's active marker speaks the same
180
+ // key); outline rows identify as `turn:<n>`.
126
181
  const [focusKey, setFocusKey] = useState(null);
127
- const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null;
182
+ const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
128
183
  const panelRef = useRef(null);
184
+ const [pendingJump, setPendingJump] = useState(null);
185
+ // A jump never crosses sessions; the entry's session identity switches
186
+ // without a remount when the main view moves.
187
+ useEffect(() => { setPendingJump(null); }, [sessionId]);
188
+ // The window head: the first visible node's anchorSeq (ChatView's firstSeq
189
+ // derivation), the coverage line for the outline jump's re-request guard.
190
+ const firstSeq = useMemo(() => {
191
+ const firstKey = order[0];
192
+ if (firstKey === undefined)
193
+ return null;
194
+ return nodes.get(firstKey)?.anchorSeq ?? null;
195
+ }, [order, nodes]);
196
+ // The outline-jump state machine. Fire (click) only arms the jump; this
197
+ // effect owns every loadThrough request, the landing scroll, and the give-
198
+ // up fallback, re-running as the window, the paging flags, or the jump
199
+ // itself change. The loop converges because loadThrough pages until the
200
+ // window covers the target seq (or history runs out), and a settle that
201
+ // moved nothing falls through to the fallback instead of re-requesting.
202
+ useEffect(() => {
203
+ if (pendingJump === null || pendingJump.sessionId !== sessionId)
204
+ return;
205
+ // Scroll to a key once its transcript row renders (the same commit or a
206
+ // near frame); the returned cleanup cancels the retries.
207
+ const landOn = (key) => {
208
+ let cancelled = false;
209
+ let frames = 0;
210
+ const attempt = () => {
211
+ if (cancelled)
212
+ return;
213
+ frames += 1;
214
+ if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
215
+ setPendingJump(null);
216
+ return;
217
+ }
218
+ (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(attempt);
219
+ };
220
+ attempt();
221
+ return () => { cancelled = true; };
222
+ };
223
+ const landed = items.find((row) => row.kind === 'loaded' && row.turn === pendingJump.turn);
224
+ if (landed !== undefined)
225
+ return landOn(landed.key);
226
+ // No user-kind row for the turn: an automated turn (a restart/scheduled
227
+ // trigger renders as `turn-trigger`, not a user row) or a withdraw-hidden
228
+ // prompt. Land on the turn's first visible loaded node of any kind — the
229
+ // official rail's `user ?? loaded[0]` anchor.
230
+ const end = outline?.find(entry => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
231
+ const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
232
+ if (anyKey !== null)
233
+ return landOn(anyKey);
234
+ if (pendingJump.awaiting || loadingOlder)
235
+ return;
236
+ // Settled (or freshly armed) without any visible row for the turn: page
237
+ // again while the window head still moves and history remains; a stalled
238
+ // head lands on the nearest loaded row at/after the turn instead.
239
+ const uncovered = firstSeq === null || firstSeq > pendingJump.seq;
240
+ if (uncovered && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
241
+ const jump = pendingJump;
242
+ setPendingJump({ ...jump, requested: true, awaiting: true, fromHead: firstSeq });
243
+ void loadThrough(jump.seq).then(() => {
244
+ setPendingJump(current => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn
245
+ ? { ...current, awaiting: false }
246
+ : current);
247
+ });
248
+ return;
249
+ }
250
+ const fallback = items.find((row) => row.kind === 'loaded' && row.anchorSeq >= pendingJump.seq);
251
+ if (fallback !== undefined)
252
+ jumpTo(fallback.key);
253
+ setPendingJump(null);
254
+ }, [items, pendingJump, sessionId, loadingOlder, hasMore, firstSeq, jumpTo, loadThrough, order, nodes, outline]);
129
255
  // The panel is a chat-view affordance: hide it while the session shows
130
256
  // another tab (trajectory etc.), detected through ChatView's data-chat-flow
131
- // marker. `active` splits from `visible` so history paging can bootstrap: a
132
- // session whose loaded event window holds no user message yet (a huge
133
- // assistant turn pushed it past the first page) renders no rows but must
134
- // still pull pages until one materializes.
257
+ // marker. With the outline the list starts whole; without it a session
258
+ // whose loaded event window holds no user message yet (a huge assistant
259
+ // turn pushed it past the first page) renders no rows but must still pull
260
+ // pages until one materializes — hence `active` splits from `visible`.
135
261
  const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
136
262
  const visible = active && items.length > 0;
137
263
  // The panel must never cover the message flow: its width is the configured
@@ -155,61 +281,81 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
155
281
  panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)
156
282
  ?.scrollIntoView({ block: 'nearest' });
157
283
  }, [current]);
158
- // Prefetch history while the panel is on a chat view: keep pulling pages
159
- // until the first user message materializes (bootstrap), then until
160
- // initialPages pages arrived, so the list starts near-complete. Older
161
- // pages load on demand when the panel scrolls to its top.
284
+ // Fallback mode (no outline) prefetches history while the panel is on a
285
+ // chat view: keep pulling pages until the first user message materializes
286
+ // (bootstrap), then until initialPages pages arrived, so the list starts
287
+ // near-complete. Older pages load on demand when the panel scrolls to its
288
+ // top. The outline mode skips this entirely — the projection already names
289
+ // every turn, so no page has to arrive for the list to be complete.
162
290
  const prefetchedPagesRef = useRef(0);
163
291
  useEffect(() => {
164
292
  if (!active) {
165
293
  prefetchedPagesRef.current = 0;
166
294
  return;
167
295
  }
296
+ if (outline !== undefined)
297
+ return;
168
298
  if (!hasMore || loadingOlder)
169
299
  return;
170
300
  if (items.length > 0 && prefetchedPagesRef.current >= initialPages)
171
301
  return;
172
302
  prefetchedPagesRef.current += 1;
173
303
  void loadOlder();
174
- }, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder]);
175
- // Older history loads by scrolling the panel to its top; the official chat
176
- // view owns the load-older button, so the panel stays chromeless.
304
+ }, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder, outline]);
305
+ // Fallback mode loads older history by scrolling the panel to its top; the
306
+ // official chat view owns the load-older button, so the panel stays
307
+ // chromeless. The outline mode's list is already complete.
177
308
  const onPanelScroll = (event) => {
309
+ if (outline !== undefined)
310
+ return;
178
311
  if (!hasMore || loadingOlder)
179
312
  return;
180
313
  if (event.currentTarget.scrollTop <= 8)
181
314
  void loadOlder();
182
315
  };
183
- const confirm = (key) => {
316
+ const confirm = (row) => {
184
317
  setFocusKey(null);
185
- jumpTo(key);
318
+ if (row.kind === 'loaded') {
319
+ // A direct jump supersedes any armed outline jump (its landing would
320
+ // otherwise yank the transcript later).
321
+ setPendingJump(null);
322
+ jumpTo(row.key);
323
+ return;
324
+ }
325
+ // An outline row arms the jump; the effect above runs the paging and the
326
+ // landing scroll. Re-clicking another outline row retargets the jump.
327
+ setPendingJump({ sessionId, turn: row.turn, seq: row.seq, requested: false, awaiting: false, fromHead: null });
186
328
  };
187
329
  if (!visible || tooNarrow)
188
330
  return null;
189
331
  return createPortal(_jsx("div", { ref: panelRef, className: css.panel, role: "navigation", "aria-label": t('rail.panel'), tabIndex: 0, "data-timeline-panel": "", style: { left: rail.left, top: rail.top, height: rail.height, width }, onScroll: onPanelScroll, onKeyDown: (event) => {
190
332
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
191
333
  event.preventDefault();
192
- const anchor = items.findIndex(item => item.key === current);
334
+ const anchor = items.findIndex(item => item.id === current);
193
335
  const next = event.key === 'ArrowDown'
194
336
  ? items[Math.min(anchor + 1, items.length - 1)]
195
337
  : items[Math.max(anchor - 1, 0)];
196
338
  /* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
197
339
  if (next !== undefined)
198
- setFocusKey(next.key);
340
+ setFocusKey(next.id);
199
341
  }
200
342
  else if (event.key === 'Enter' || event.key === ' ') {
201
343
  event.preventDefault();
202
- /* v8 ignore next -- the panel only renders with at least one item, so a current key always exists */
203
- if (current !== null)
204
- confirm(current);
344
+ const row = items.find(item => item.id === current);
345
+ if (row !== undefined)
346
+ confirm(row);
205
347
  }
206
- }, onBlur: () => { setFocusKey(null); }, children: items.map((item) => {
207
- const isCurrent = item.key === current;
208
- const isFocused = item.key === focusKey;
348
+ }, onBlur: () => { setFocusKey(null); }, children: items.map((row) => {
349
+ const isCurrent = row.id === current;
350
+ const isFocused = row.id === focusKey;
351
+ const isPending = row.kind === 'outline' && pendingJump !== null && pendingJump.turn === row.turn;
209
352
  const className = isFocused
210
353
  ? `${css.item} ${css.itemFocused}`
211
354
  : isCurrent ? `${css.item} ${css.itemCurrent}` : css.item;
212
- return (_jsxs("button", { type: "button", "data-item-key": item.key, "aria-current": isCurrent || undefined, className: className, onClick: () => { confirm(item.key); }, onMouseEnter: () => { setFocusKey(item.key); }, onMouseLeave: () => { setFocusKey(key => key === item.key ? null : key); }, children: [_jsx("span", { className: isCurrent ? `${css.tick} ${css.tickCurrent}` : css.tick, "aria-hidden": "true" }), _jsx("span", { className: css.itemText, children: previewText(nodeContent(item.node)) ?? t('rail.empty') })] }, item.key));
355
+ // Outline rows share the plain styling; the pending pulse is the only
356
+ // transient cue that a row pages history in before landing.
357
+ const classNames = isPending ? `${className} ${css.itemPending}` : className;
358
+ return (_jsxs("button", { type: "button", "data-item-key": row.id, "aria-current": isCurrent || undefined, "aria-busy": isPending || undefined, className: classNames, onClick: () => { confirm(row); }, onMouseEnter: () => { setFocusKey(row.id); }, onMouseLeave: () => { setFocusKey(key => key === row.id ? null : key); }, children: [_jsx("span", { className: isCurrent ? `${css.tick} ${css.tickCurrent}` : css.tick, "aria-hidden": "true" }), _jsx("span", { className: css.itemText, children: rowLabel(row, t) })] }, row.id));
213
359
  }) }), document.body);
214
360
  }
215
361
  /** Memoized export for the slot machinery (stable component identity). */
@@ -20,8 +20,11 @@ export interface TimelineConfig {
20
20
  */
21
21
  panelWidth: number;
22
22
  /**
23
- * History pages to prefetch when the rail opens (50 events each); older
24
- * pages load on demand when the rail or the panel is scrolled to its top.
23
+ * History pages to prefetch when the rail opens (50 events each), in the
24
+ * window-paging fallback only: when the host serves the turnOutline
25
+ * projection the list starts whole and nothing prefetches. In the fallback,
26
+ * older pages load on demand when the rail or the panel is scrolled to its
27
+ * top.
25
28
  */
26
29
  initialPages: number;
27
30
  }
@@ -3,11 +3,15 @@
3
3
  * official `conversation.session.header.utilities` seat (a right-aligned
4
4
  * optional-utility slot) that anchors the plugin into the session scope; the
5
5
  * component itself renders only through a body portal: the flat floating
6
- * timeline panel over the chat scrollport's left edge — one row per loaded
7
- * user message, no frame, no visible scrollbar. The
8
- * panel reads the session chat snapshot through the framework `useSession`
9
- * hook, jumps through the official row anchor attributes, and degrades to a
10
- * hidden panel when those attributes change — no official code is modified.
6
+ * timeline panel over the chat scrollport's left edge — one row per user
7
+ * message of the whole session, no frame, no visible scrollbar. The row list
8
+ * merges the host `turnOutline` projection (every started turn, served whole)
9
+ * with the loaded chat window; confirming a not-yet-loaded row pages history
10
+ * through the session's jump loader (`loadThrough`) before scrolling. When
11
+ * the projection is absent the panel reads the loaded window alone, paged
12
+ * through the session conversation service, jumps through the official row
13
+ * anchor attributes, and degrades to a hidden panel when those attributes
14
+ * change — no official code is modified.
11
15
  * @module @khorsheed/dsh-message-timeline/client
12
16
  */
13
17
  import type { Context } from '@deepseek-ai/cordis';
@@ -36,12 +36,16 @@ export function apply(ctx, config) {
36
36
  const conversation = actx.get('conversation');
37
37
  if (conversation === undefined)
38
38
  throw new Error('message-timeline: conversation service unavailable');
39
+ const binding = ctx.sessions.binding(sessionId);
40
+ if (binding === undefined)
41
+ throw new Error('message-timeline: session resolved no binding');
39
42
  return {
40
43
  includeSteering: options.includeSteering,
41
44
  panelWidth: options.panelWidth,
42
45
  initialPages: options.initialPages,
43
46
  loadOlder: () => conversation.loadOlder(),
44
- jumpTo: (key) => { tracker.jumpTo(key); },
47
+ loadThrough: seq => binding.session.loadThrough(seq),
48
+ jumpTo: (key) => tracker.jumpTo(key),
45
49
  hooks: { rail: tracker.state },
46
50
  };
47
51
  },
@@ -3,6 +3,9 @@
3
3
  export declare const zh: {
4
4
  'rail.panel': string;
5
5
  'rail.empty': string;
6
+ 'rail.turn': string;
7
+ 'rail.image': string;
8
+ 'rail.attachment': string;
6
9
  };
7
10
  /** The message-timeline namespace key union. */
8
11
  export type TimelineKey = keyof typeof zh;
@@ -16,5 +19,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
16
19
  export declare const en: {
17
20
  'rail.panel': string;
18
21
  'rail.empty': string;
22
+ 'rail.turn': string;
23
+ 'rail.image': string;
24
+ 'rail.attachment': string;
19
25
  };
20
26
  //# sourceMappingURL=locales.d.ts.map
@@ -2,10 +2,16 @@
2
2
  /** Simplified Chinese dictionary (the key-set source of truth). */
3
3
  export const zh = {
4
4
  'rail.panel': '消息导览',
5
- 'rail.empty': '暂无用户消息',
5
+ 'rail.empty': '无文本预览',
6
+ 'rail.turn': '第 {turn} 轮',
7
+ 'rail.image': '图片消息',
8
+ 'rail.attachment': '附件消息',
6
9
  };
7
10
  /** English dictionary, checked complete against the zh key set. */
8
11
  export const en = {
9
12
  'rail.panel': 'Message timeline',
10
- 'rail.empty': 'No user messages',
13
+ 'rail.empty': 'No text preview',
14
+ 'rail.turn': 'Turn {turn}',
15
+ 'rail.image': 'Image message',
16
+ 'rail.attachment': 'Attachment message',
11
17
  };
@@ -0,0 +1,12 @@
1
+ import type { TurnOutlineEntry } from '@deepseek-ai/dsh-session-turn-outline/client';
2
+ export type { TurnOutlineEntry };
3
+ /**
4
+ * Narrow one wire projection value to outline entries. `turn` and `seq` are
5
+ * load-bearing — a row cannot exist or jump without them — so damage there
6
+ * drops the entry; the previews are decorative and degrade to ''. A non-array
7
+ * value (absent unit included) narrows to undefined.
8
+ * @param value - the raw `useProjection('turnOutline')` read.
9
+ * @returns the surviving entries in wire order, or undefined when absent.
10
+ */
11
+ export declare function outlineEntries(value: unknown): readonly TurnOutlineEntry[] | undefined;
12
+ //# sourceMappingURL=outline.d.ts.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Narrow one wire projection value to outline entries. `turn` and `seq` are
3
+ * load-bearing — a row cannot exist or jump without them — so damage there
4
+ * drops the entry; the previews are decorative and degrade to ''. A non-array
5
+ * value (absent unit included) narrows to undefined.
6
+ * @param value - the raw `useProjection('turnOutline')` read.
7
+ * @returns the surviving entries in wire order, or undefined when absent.
8
+ */
9
+ export function outlineEntries(value) {
10
+ if (!Array.isArray(value))
11
+ return undefined;
12
+ const entries = [];
13
+ for (const raw of value) {
14
+ const entry = outlineEntry(raw);
15
+ if (entry !== undefined)
16
+ entries.push(entry);
17
+ }
18
+ return entries;
19
+ }
20
+ /**
21
+ * Narrow one wire entry. `turn` and `seq` must be non-negative safe integers
22
+ * (seq additionally rejects -0); the previews default to '' when malformed.
23
+ * @param value - one raw wire entry.
24
+ * @returns the entry, or undefined when a load-bearing field is damaged.
25
+ */
26
+ function outlineEntry(value) {
27
+ if (typeof value !== 'object' || value === null)
28
+ return undefined;
29
+ const entry = value;
30
+ if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0)
31
+ return undefined;
32
+ if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0))
33
+ return undefined;
34
+ return {
35
+ turn: entry.turn,
36
+ seq: entry.seq,
37
+ prompt: typeof entry.prompt === 'string' ? entry.prompt : '',
38
+ response: typeof entry.response === 'string' ? entry.response : '',
39
+ };
40
+ }
@@ -78,8 +78,11 @@ export declare function jumpRow(scrollport: HTMLElement, key: string): boolean;
78
78
  export interface RailTracker {
79
79
  /** Live rail geometry and active marker for the current session. */
80
80
  readonly state: HostObservable<TimelineRailState>;
81
- /** Scroll the transcript to the message addressed by `key` (no-op while unbound). */
82
- jumpTo(key: string): void;
81
+ /**
82
+ * Scroll the transcript to the message addressed by `key`.
83
+ * @returns whether the row was found and scrolled (false while unbound).
84
+ */
85
+ jumpTo(key: string): boolean;
83
86
  /** Unbind every listener and observer. */
84
87
  dispose(): void;
85
88
  }
@@ -294,10 +294,7 @@ export function installRailTracker(ctx, includeSteering) {
294
294
  return () => { listeners.delete(fn); };
295
295
  },
296
296
  },
297
- jumpTo: (key) => {
298
- if (scrollport !== null)
299
- jumpRow(scrollport, key);
300
- },
297
+ jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
301
298
  dispose: () => {
302
299
  stopList();
303
300
  stopProvide?.();
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Rail row assembly: the merge of the loaded chat window's user rows with the
3
+ * host turnOutline projection into the full timeline. The outline names every
4
+ * started turn (its `turn/start` seq plus a bounded first-prompt preview); the
5
+ * loaded window supplies richer previews, steering rows, and message-tools
6
+ * bubbles for the turns it covers. A turn seen in the window renders its
7
+ * loaded rows and drops out of the outline; a turn outside the window renders
8
+ * as an outline row that pages history in on click.
9
+ * @module @khorsheed/dsh-message-timeline/client/rows
10
+ */
11
+ import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
12
+ import type { TurnOutlineEntry } from './outline.ts';
13
+ import type { TimelineItem } from './slots.ts';
14
+ /** One loaded-window row: jumps straight to its transcript anchor. */
15
+ export interface LoadedRailRow {
16
+ readonly kind: 'loaded';
17
+ /** Row identity: the node key, which the tracker's active marker also speaks. */
18
+ readonly id: string;
19
+ /** The chat node key (the `data-chat-anchor-key` the jump targets). */
20
+ readonly key: string;
21
+ /** The underlying chat node, for content preview. */
22
+ readonly node: ChatConversationViewNode;
23
+ /** Transcript position (the node's anchor seq) — the merge key. */
24
+ readonly anchorSeq: number;
25
+ /** The owning outline turn, or undefined outside every outline range (a stale outline). */
26
+ readonly turn: number | undefined;
27
+ }
28
+ /**
29
+ * One outline-only row: the turn's events are outside the loaded window, so
30
+ * the row previews from the wire entry and confirming it pages history back
31
+ * through the turn's `turn/start` seq before landing.
32
+ */
33
+ export interface OutlineRailRow {
34
+ readonly kind: 'outline';
35
+ /** Row identity (`turn:<n>` — the prefix cannot collide with a node key). */
36
+ readonly id: string;
37
+ readonly turn: number;
38
+ /** The turn's `turn/start` seq — the loadThrough target. */
39
+ readonly seq: TurnOutlineEntry['seq'];
40
+ /** Same position as `seq`, as the merge key. */
41
+ readonly anchorSeq: number;
42
+ /** Bounded first-prompt preview ('' for a textless prompt). */
43
+ readonly prompt: string;
44
+ }
45
+ /** One timeline row. */
46
+ export type RailRow = LoadedRailRow | OutlineRailRow;
47
+ /**
48
+ * The first visible loaded row of a turn, of any node kind: the landing
49
+ * target for a turn with no user-kind row (an automated turn renders a
50
+ * `turn-trigger` card, not a user bubble), mirroring the official rail's
51
+ * `user ?? loaded[0]` anchor. Only nodes in the host's visible `order`
52
+ * qualify — a store-only or hidden node is not a jump target.
53
+ * @param order - visible chat node keys in render order.
54
+ * @param nodes - the live node store.
55
+ * @param seq - the turn's `turn/start` seq (inclusive).
56
+ * @param end - the next turn's start seq (exclusive), or Infinity for the last turn.
57
+ * @returns the anchor key, or null when the turn has no visible loaded row.
58
+ */
59
+ export declare function firstVisibleKeyInTurn(order: readonly string[], nodes: {
60
+ get(key: string): ChatConversationViewNode | undefined;
61
+ }, seq: number, end: number): string | null;
62
+ /**
63
+ * Merge loaded rows with the outline into the full row list. Both inputs are
64
+ * transcript-ordered (loaded rows anchorSeq-ascending, the outline strictly
65
+ * increasing by turn), so one pass interleaves them without a final sort: an
66
+ * outline entry whose seq range holds at least one loaded row is covered and
67
+ * emits nothing; every other entry emits its outline row at the range's
68
+ * position. A loaded row belongs to the last entry whose start it follows —
69
+ * when the window runs ahead of the projection feed, the newest rows read as
70
+ * the last named turn until the feed catches up (suppressing its outline row
71
+ * beats duplicating the turn).
72
+ * @param loaded - the window's user rows, anchorSeq-ascending.
73
+ * @param outline - narrowed outline entries, or undefined (capability absent).
74
+ * @returns the merged row list, transcript-ordered.
75
+ */
76
+ export declare function mergeRows(loaded: readonly TimelineItem[], outline: readonly TurnOutlineEntry[] | undefined): readonly RailRow[];
77
+ //# sourceMappingURL=rows.d.ts.map