@khorsheed/dsh-message-timeline 0.2.4 → 0.2.6

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,22 @@ 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
+ /** Rest width of the narrow-layout tick strip (px) — fits the flow's left padding. */
78
+ const PANEL_NARROW_REST_WIDTH = 24;
79
+ /** Expanded width of the narrow-layout hover overlay (fraction of the scrollport). */
80
+ const NARROW_WIDE_RATIO = 0.6;
81
+ /**
82
+ * Frames an outline jump retries the DOM scroll after its row materializes in
83
+ * the chat store: the transcript renders the new page in the same commit or a
84
+ * near frame, and a half-second budget covers the skew without hanging.
85
+ */
86
+ const JUMP_RETRY_FRAMES = 30;
37
87
  /**
38
88
  * The header-utilities entry: the portal timeline panel.
39
89
  * @param props - composed props (see {@link TimelineRailProps}).
40
90
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
41
91
  */
42
- export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
92
+ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
43
93
  const rail = useRail(s => s);
44
94
  // Chat data lives in the `useChat` standard prop on 0.1.2 (the rc-line
45
95
  // session-snapshot seat is gone); the helper degrades to an empty slice
@@ -50,6 +100,13 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
50
100
  const nodes = useChatSlice(c => c.nodes);
51
101
  const hasMore = useSession(s => s.hasMore);
52
102
  const loadingOlder = useSession(s => s.loadingOlder);
103
+ // The host projection seat exists on every supported host; the VALUE is
104
+ // present only where the assembly mounts the turn-outline unit. Both reads
105
+ // degrade to undefined, which keeps the window-paging list. The narrow is
106
+ // memoized on the projection's reference-stable value so unrelated renders
107
+ // do not churn the merged list (or re-run the jump effect).
108
+ const outlineValue = standard.useProjection?.('turnOutline');
109
+ const outline = useMemo(() => outlineEntries(outlineValue), [outlineValue]);
53
110
  const items = useMemo(() => {
54
111
  // The node store is an in-memory snapshot; take it once so the span fold
55
112
  // and the message-tools append read the same snapshot.
@@ -74,7 +131,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
74
131
  // ordinary session carries no span node, so the fold is empty and every
75
132
  // order row still renders: this filter never alters the baseline.
76
133
  const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
77
- const result = [];
134
+ const loaded = [];
78
135
  const seen = new Set();
79
136
  for (const key of order) {
80
137
  const node = nodes.get(key);
@@ -84,7 +141,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
84
141
  continue;
85
142
  if (hidden(node))
86
143
  continue;
87
- result.push({ key, node });
144
+ loaded.push({ key, node });
88
145
  seen.add(key);
89
146
  }
90
147
  // After a message-tools in-place edit the replacement materializes as a
@@ -105,7 +162,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
105
162
  continue;
106
163
  if (hidden(node))
107
164
  continue;
108
- result.push({ key: node.key, node });
165
+ loaded.push({ key: node.key, node });
109
166
  seen.add(node.key);
110
167
  }
111
168
  // The order loop is seq-sorted, but the appended bubbles walked the store
@@ -116,38 +173,112 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
116
173
  // ordinary session has no appended bubbles, so this never reorders the
117
174
  // baseline (which is already seq-sorted). Dropped rows are gone before the
118
175
  // 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]);
176
+ loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
177
+ return mergeRows(loaded, outline);
178
+ }, [order, nodes, includeSteering, outline]);
122
179
  // The lit row: the hover/arrow preselection while it moves, otherwise the
123
180
  // live reading position the tracker publishes, defaulting to the latest
124
181
  // message before the tracker answers. ArrowUp/Down or hovering moves a
125
- // bold preselection; Enter or a click confirms it and jumps.
182
+ // bold preselection; Enter or a click confirms it and jumps. Loaded rows
183
+ // identify by their node key (the tracker's active marker speaks the same
184
+ // key); outline rows identify as `turn:<n>`.
126
185
  const [focusKey, setFocusKey] = useState(null);
127
- const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null;
186
+ const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
128
187
  const panelRef = useRef(null);
188
+ const [pendingJump, setPendingJump] = useState(null);
189
+ // A jump never crosses sessions; the entry's session identity switches
190
+ // without a remount when the main view moves.
191
+ useEffect(() => { setPendingJump(null); }, [sessionId]);
192
+ // The window head: the first visible node's anchorSeq (ChatView's firstSeq
193
+ // derivation), the coverage line for the outline jump's re-request guard.
194
+ const firstSeq = useMemo(() => {
195
+ const firstKey = order[0];
196
+ if (firstKey === undefined)
197
+ return null;
198
+ return nodes.get(firstKey)?.anchorSeq ?? null;
199
+ }, [order, nodes]);
200
+ // The outline-jump state machine. Fire (click) only arms the jump; this
201
+ // effect owns every loadThrough request, the landing scroll, and the give-
202
+ // up fallback, re-running as the window, the paging flags, or the jump
203
+ // itself change. The loop converges because loadThrough pages until the
204
+ // window covers the target seq (or history runs out), and a settle that
205
+ // moved nothing falls through to the fallback instead of re-requesting.
206
+ useEffect(() => {
207
+ if (pendingJump === null || pendingJump.sessionId !== sessionId)
208
+ return;
209
+ // Scroll to a key once its transcript row renders (the same commit or a
210
+ // near frame); the returned cleanup cancels the retries.
211
+ const landOn = (key) => {
212
+ let cancelled = false;
213
+ let frames = 0;
214
+ const attempt = () => {
215
+ if (cancelled)
216
+ return;
217
+ frames += 1;
218
+ if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
219
+ setPendingJump(null);
220
+ return;
221
+ }
222
+ (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(attempt);
223
+ };
224
+ attempt();
225
+ return () => { cancelled = true; };
226
+ };
227
+ const landed = items.find((row) => row.kind === 'loaded' && row.turn === pendingJump.turn);
228
+ if (landed !== undefined)
229
+ return landOn(landed.key);
230
+ // No user-kind row for the turn: an automated turn (a restart/scheduled
231
+ // trigger renders as `turn-trigger`, not a user row) or a withdraw-hidden
232
+ // prompt. Land on the turn's first visible loaded node of any kind — the
233
+ // official rail's `user ?? loaded[0]` anchor.
234
+ const end = outline?.find(entry => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
235
+ const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
236
+ if (anyKey !== null)
237
+ return landOn(anyKey);
238
+ if (pendingJump.awaiting || loadingOlder)
239
+ return;
240
+ // Settled (or freshly armed) without any visible row for the turn: page
241
+ // again while the window head still moves and history remains; a stalled
242
+ // head lands on the nearest loaded row at/after the turn instead.
243
+ const uncovered = firstSeq === null || firstSeq > pendingJump.seq;
244
+ if (uncovered && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
245
+ const jump = pendingJump;
246
+ setPendingJump({ ...jump, requested: true, awaiting: true, fromHead: firstSeq });
247
+ void loadThrough(jump.seq).then(() => {
248
+ setPendingJump(current => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn
249
+ ? { ...current, awaiting: false }
250
+ : current);
251
+ });
252
+ return;
253
+ }
254
+ const fallback = items.find((row) => row.kind === 'loaded' && row.anchorSeq >= pendingJump.seq);
255
+ if (fallback !== undefined)
256
+ jumpTo(fallback.key);
257
+ setPendingJump(null);
258
+ }, [items, pendingJump, sessionId, loadingOlder, hasMore, firstSeq, jumpTo, loadThrough, order, nodes, outline]);
129
259
  // The panel is a chat-view affordance: hide it while the session shows
130
260
  // 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.
261
+ // marker. With the outline the list starts whole; without it a session
262
+ // whose loaded event window holds no user message yet (a huge assistant
263
+ // turn pushed it past the first page) renders no rows but must still pull
264
+ // pages until one materializes — hence `active` splits from `visible`.
135
265
  const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
136
266
  const visible = active && items.length > 0;
137
- // The panel must never cover the message flow: its width is the configured
138
- // preferred width capped by the scrollport's left gutter — the message
139
- // flow's left edge minus the panel's left edge, less the panel's right
140
- // padding and the visible breathing gap. A gutter too small for the minimum
141
- // usable width hides the panel entirely (the timeline is an overlay
142
- // affordance; squeezed into nothing it only intercepts the transcript).
143
- // When the flow probe is unanswered (official structure change), the width
144
- // degrades to a fraction of the scrollport instead — never throws, never
145
- // covers more than the fallback.
267
+ // The panel must never cover the message flow AT REST: its width is the
268
+ // configured preferred width capped by the scrollport's left gutter — the
269
+ // message flow's left edge minus the panel's left edge, less the panel's
270
+ // right padding and the visible breathing gap. A gutter too small for the
271
+ // minimum width no longer hides the panel: it degrades to a tick strip in
272
+ // the flow's left padding and expands over the flow on hover/focus with a
273
+ // solid background (right sidebars and narrow windows keep the rail
274
+ // usable). When the flow probe is unanswered (official structure change),
275
+ // the width degrades to a fraction of the scrollport instead.
146
276
  const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
277
+ const narrow = gutter !== null && gutter < PANEL_WIDTH_MIN;
147
278
  const width = gutter === null
148
279
  ? Math.min(panelWidth, Math.max(PANEL_WIDTH_MIN, rail.scrollportWidth * DEGRADED_WIDTH_RATIO))
149
- : Math.min(panelWidth, Math.max(0, gutter));
150
- const tooNarrow = gutter !== null && width < PANEL_WIDTH_MIN;
280
+ : narrow ? PANEL_NARROW_REST_WIDTH : Math.min(panelWidth, gutter);
281
+ const wideWidth = Math.min(panelWidth, Math.max(PANEL_WIDTH_MIN, rail.scrollportWidth * NARROW_WIDE_RATIO));
151
282
  // Keep the lit row in view: the panel follows the reading position (a new
152
283
  // message scrolls its row in), and mouse browsing is never yanked because
153
284
  // the hovered row is the current one and always visible under the pointer.
@@ -155,61 +286,92 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
155
286
  panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)
156
287
  ?.scrollIntoView({ block: 'nearest' });
157
288
  }, [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.
289
+ // Fallback mode (no outline) prefetches history while the panel is on a
290
+ // chat view: keep pulling pages until the first user message materializes
291
+ // (bootstrap), then until initialPages pages arrived, so the list starts
292
+ // near-complete. Older pages load on demand when the panel scrolls to its
293
+ // top. The outline mode skips this entirely — the projection already names
294
+ // every turn, so no page has to arrive for the list to be complete.
162
295
  const prefetchedPagesRef = useRef(0);
163
296
  useEffect(() => {
164
297
  if (!active) {
165
298
  prefetchedPagesRef.current = 0;
166
299
  return;
167
300
  }
301
+ if (outline !== undefined)
302
+ return;
168
303
  if (!hasMore || loadingOlder)
169
304
  return;
170
305
  if (items.length > 0 && prefetchedPagesRef.current >= initialPages)
171
306
  return;
172
307
  prefetchedPagesRef.current += 1;
173
308
  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.
309
+ }, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder, outline]);
310
+ // Fallback mode loads older history by scrolling the panel to its top; the
311
+ // official chat view owns the load-older button, so the panel stays
312
+ // chromeless. The outline mode's list is already complete.
177
313
  const onPanelScroll = (event) => {
314
+ if (outline !== undefined)
315
+ return;
178
316
  if (!hasMore || loadingOlder)
179
317
  return;
180
318
  if (event.currentTarget.scrollTop <= 8)
181
319
  void loadOlder();
182
320
  };
183
- const confirm = (key) => {
321
+ const confirm = (row) => {
184
322
  setFocusKey(null);
185
- jumpTo(key);
323
+ if (row.kind === 'loaded') {
324
+ // A direct jump supersedes any armed outline jump (its landing would
325
+ // otherwise yank the transcript later).
326
+ setPendingJump(null);
327
+ jumpTo(row.key);
328
+ return;
329
+ }
330
+ // An outline row arms the jump; the effect above runs the paging and the
331
+ // landing scroll. Re-clicking another outline row retargets the jump.
332
+ setPendingJump({ sessionId, turn: row.turn, seq: row.seq, requested: false, awaiting: false, fromHead: null });
186
333
  };
187
- if (!visible || tooNarrow)
334
+ if (!visible)
188
335
  return null;
189
- 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) => {
336
+ return createPortal(_jsx("div", { ref: panelRef, className: `${css.panel} ${narrow ? css.panelNarrow : ''}`, role: "navigation", "aria-label": t('rail.panel'), tabIndex: 0, "data-timeline-panel": "", style: {
337
+ // The box fits its rows (up to the rail height) and centers on the
338
+ // rail's midline, so the narrow-mode hover background wraps the list
339
+ // instead of painting the whole column; a longer list fills the rail
340
+ // and scrolls.
341
+ left: rail.left,
342
+ top: rail.top + rail.height / 2,
343
+ maxHeight: rail.height,
344
+ transform: 'translateY(-50%)',
345
+ width,
346
+ ...(narrow ? { '--panel-wide': `${wideWidth}px` } : {}),
347
+ }, onScroll: onPanelScroll, onKeyDown: (event) => {
190
348
  if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
191
349
  event.preventDefault();
192
- const anchor = items.findIndex(item => item.key === current);
350
+ const anchor = items.findIndex(item => item.id === current);
193
351
  const next = event.key === 'ArrowDown'
194
352
  ? items[Math.min(anchor + 1, items.length - 1)]
195
353
  : items[Math.max(anchor - 1, 0)];
196
354
  /* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
197
355
  if (next !== undefined)
198
- setFocusKey(next.key);
356
+ setFocusKey(next.id);
199
357
  }
200
358
  else if (event.key === 'Enter' || event.key === ' ') {
201
359
  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);
360
+ const row = items.find(item => item.id === current);
361
+ if (row !== undefined)
362
+ confirm(row);
205
363
  }
206
- }, onBlur: () => { setFocusKey(null); }, children: items.map((item) => {
207
- const isCurrent = item.key === current;
208
- const isFocused = item.key === focusKey;
364
+ }, onBlur: () => { setFocusKey(null); }, children: items.map((row) => {
365
+ const isCurrent = row.id === current;
366
+ const isFocused = row.id === focusKey;
367
+ const isPending = row.kind === 'outline' && pendingJump !== null && pendingJump.turn === row.turn;
209
368
  const className = isFocused
210
369
  ? `${css.item} ${css.itemFocused}`
211
370
  : 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));
371
+ // Outline rows share the plain styling; the pending pulse is the only
372
+ // transient cue that a row pages history in before landing.
373
+ const classNames = isPending ? `${className} ${css.itemPending}` : className;
374
+ 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
375
  }) }), document.body);
214
376
  }
215
377
  /** 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
+ }
@@ -25,7 +25,7 @@ import type { TimelineRailState } from './slots.ts';
25
25
  * whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
26
26
  * the input inside the seat, and counting them would push the timeline up off
27
27
  * the conversation. Falls back to the seat top, then the column bottom, when
28
- * the markers are absent.
28
+ * no laid-out card is rendered (see {@link laidOutCard}).
29
29
  * The scrollport's own width rides along for the width-cap fallback when the
30
30
  * message-flow probe is unanswered.
31
31
  * @param scrollport - the official conversation scrollport element.
@@ -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
  }
@@ -14,6 +14,26 @@ const IDLE = {
14
14
  sessionId: undefined, ready: false, left: 0, top: 0, height: 0, scrollportWidth: 0, flowLeft: null,
15
15
  activeKey: null, chatView: false,
16
16
  };
17
+ /**
18
+ * The rect of the first laid-out chat input card in the scrollport. A
19
+ * business composer (the Room composer) takes the `conversation.composer`
20
+ * chain as an overlay: the official input bar stays mounted under a
21
+ * `display: none` fallback wrapper, so its card is still the first marker in
22
+ * the DOM but measures as a zero rect at y=0 — ending the box there collapses
23
+ * the panel to zero height. Unlaid-out cards are skipped, so such a layout
24
+ * falls through to the seat top.
25
+ * @param scrollport - the official conversation scrollport element.
26
+ * @returns the card's rect, or null when no laid-out card is rendered.
27
+ */
28
+ function laidOutCard(scrollport) {
29
+ for (const card of scrollport.querySelectorAll('[data-composer-card]')) {
30
+ const rect = card.getBoundingClientRect();
31
+ if (rect.width === 0 && rect.height === 0)
32
+ continue;
33
+ return rect;
34
+ }
35
+ return null;
36
+ }
17
37
  /**
18
38
  * Measure the panel's viewport box from one scrollport: its rect inset by the
19
39
  * panel padding, minus the chat input card at the bottom and the conversation
@@ -24,7 +44,7 @@ const IDLE = {
24
44
  * whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
25
45
  * the input inside the seat, and counting them would push the timeline up off
26
46
  * the conversation. Falls back to the seat top, then the column bottom, when
27
- * the markers are absent.
47
+ * no laid-out card is rendered (see {@link laidOutCard}).
28
48
  * The scrollport's own width rides along for the width-cap fallback when the
29
49
  * message-flow probe is unanswered.
30
50
  * @param scrollport - the official conversation scrollport element.
@@ -44,10 +64,10 @@ export function measureGeometry(scrollport) {
44
64
  break;
45
65
  }
46
66
  }
47
- const card = scrollport.querySelector('[data-composer-card]');
67
+ const card = laidOutCard(scrollport);
48
68
  const seat = scrollport.querySelector('[data-composer-seat]');
49
69
  const bottom = card !== null
50
- ? card.getBoundingClientRect().top - RAIL_VERTICAL_PADDING
70
+ ? card.top - RAIL_VERTICAL_PADDING
51
71
  : seat !== null
52
72
  ? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING
53
73
  : rect.bottom - RAIL_VERTICAL_PADDING;
@@ -294,10 +314,7 @@ export function installRailTracker(ctx, includeSteering) {
294
314
  return () => { listeners.delete(fn); };
295
315
  },
296
316
  },
297
- jumpTo: (key) => {
298
- if (scrollport !== null)
299
- jumpRow(scrollport, key);
300
- },
317
+ jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
301
318
  dispose: () => {
302
319
  stopList();
303
320
  stopProvide?.();