@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.
package/lib/client.js CHANGED
@@ -33,23 +33,32 @@ window.__ModuleLoader__.load({
33
33
  /** Simplified Chinese dictionary (the key-set source of truth). */
34
34
  const zh = {
35
35
  "rail.panel": "消息导览",
36
- "rail.empty": "暂无用户消息"
36
+ "rail.empty": "无文本预览",
37
+ "rail.turn": "第 {turn} 轮",
38
+ "rail.image": "图片消息",
39
+ "rail.attachment": "附件消息"
37
40
  };
38
41
  /** English dictionary, checked complete against the zh key set. */
39
42
  const en = {
40
43
  "rail.panel": "Message timeline",
41
- "rail.empty": "No user messages"
44
+ "rail.empty": "No text preview",
45
+ "rail.turn": "Turn {turn}",
46
+ "rail.image": "Image message",
47
+ "rail.attachment": "Attachment message"
42
48
  };
43
49
  /**
44
50
  * Whether a node kind renders as a timeline row. The rail lists user messages
45
- * (and optionally steering), plus the message-tools edited/restored bubbles
46
- * that the host `order` does not always surface.
51
+ * (and optionally steering), the message-tools edited/restored bubbles that
52
+ * the host `order` does not always surface, and automated-turn trigger cards
53
+ * (whose turns have no human prompt but real transcript content). The same
54
+ * set drives the reading-position anchor, so landing on a trigger card keeps
55
+ * the selection on its turn instead of moving to the next user row.
47
56
  * @param kind - the node's kind string.
48
57
  * @param includeSteering - whether steering rows count as user messages.
49
58
  * @returns true when the kind belongs on the timeline.
50
59
  */
51
60
  function isTimelineRowKind(kind, includeSteering) {
52
- return kind === "user" || includeSteering && kind === "steering" || kind === "message-tools-edited" || kind === "message-tools-restored";
61
+ return kind === "user" || includeSteering && kind === "steering" || kind === "message-tools-edited" || kind === "message-tools-restored" || kind === "turn-trigger";
53
62
  }
54
63
  /**
55
64
  * Whether a node kind carries a hidden span (`{ hiddenStartSeq, seq }`) that
@@ -86,6 +95,25 @@ window.__ModuleLoader__.load({
86
95
  chatView: false
87
96
  };
88
97
  /**
98
+ * The rect of the first laid-out chat input card in the scrollport. A
99
+ * business composer (the Room composer) takes the `conversation.composer`
100
+ * chain as an overlay: the official input bar stays mounted under a
101
+ * `display: none` fallback wrapper, so its card is still the first marker in
102
+ * the DOM but measures as a zero rect at y=0 — ending the box there collapses
103
+ * the panel to zero height. Unlaid-out cards are skipped, so such a layout
104
+ * falls through to the seat top.
105
+ * @param scrollport - the official conversation scrollport element.
106
+ * @returns the card's rect, or null when no laid-out card is rendered.
107
+ */
108
+ function laidOutCard(scrollport) {
109
+ for (const card of scrollport.querySelectorAll("[data-composer-card]")) {
110
+ const rect = card.getBoundingClientRect();
111
+ if (rect.width === 0 && rect.height === 0) continue;
112
+ return rect;
113
+ }
114
+ return null;
115
+ }
116
+ /**
89
117
  * Measure the panel's viewport box from one scrollport: its rect inset by the
90
118
  * panel padding, minus the chat input card at the bottom and the conversation
91
119
  * tab strip at the top. The tabs render just above the scrollport, but
@@ -95,7 +123,7 @@ window.__ModuleLoader__.load({
95
123
  * whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
96
124
  * the input inside the seat, and counting them would push the timeline up off
97
125
  * the conversation. Falls back to the seat top, then the column bottom, when
98
- * the markers are absent.
126
+ * no laid-out card is rendered (see {@link laidOutCard}).
99
127
  * The scrollport's own width rides along for the width-cap fallback when the
100
128
  * message-flow probe is unanswered.
101
129
  * @param scrollport - the official conversation scrollport element.
@@ -113,9 +141,9 @@ window.__ModuleLoader__.load({
113
141
  break;
114
142
  }
115
143
  }
116
- const card = scrollport.querySelector("[data-composer-card]");
144
+ const card = laidOutCard(scrollport);
117
145
  const seat = scrollport.querySelector("[data-composer-seat]");
118
- const bottom = card !== null ? card.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : seat !== null ? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : rect.bottom - RAIL_VERTICAL_PADDING;
146
+ const bottom = card !== null ? card.top - RAIL_VERTICAL_PADDING : seat !== null ? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : rect.bottom - RAIL_VERTICAL_PADDING;
119
147
  return {
120
148
  left: rect.left + RAIL_LEFT_INSET,
121
149
  top: rect.top + topInset,
@@ -319,9 +347,7 @@ window.__ModuleLoader__.load({
319
347
  };
320
348
  }
321
349
  },
322
- jumpTo: (key) => {
323
- if (scrollport !== null) jumpRow(scrollport, key);
324
- },
350
+ jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
325
351
  dispose: () => {
326
352
  stopList();
327
353
  stopProvide?.();
@@ -352,6 +378,129 @@ window.__ModuleLoader__.load({
352
378
  return props.useChat ?? EMPTY_HOOK;
353
379
  }
354
380
  //#endregion
381
+ //#region src/client/outline.ts
382
+ /**
383
+ * Narrow one wire projection value to outline entries. `turn` and `seq` are
384
+ * load-bearing — a row cannot exist or jump without them — so damage there
385
+ * drops the entry; the previews are decorative and degrade to ''. A non-array
386
+ * value (absent unit included) narrows to undefined.
387
+ * @param value - the raw `useProjection('turnOutline')` read.
388
+ * @returns the surviving entries in wire order, or undefined when absent.
389
+ */
390
+ function outlineEntries(value) {
391
+ if (!Array.isArray(value)) return void 0;
392
+ const entries = [];
393
+ for (const raw of value) {
394
+ const entry = outlineEntry(raw);
395
+ if (entry !== void 0) entries.push(entry);
396
+ }
397
+ return entries;
398
+ }
399
+ /**
400
+ * Narrow one wire entry. `turn` and `seq` must be non-negative safe integers
401
+ * (seq additionally rejects -0); the previews default to '' when malformed.
402
+ * @param value - one raw wire entry.
403
+ * @returns the entry, or undefined when a load-bearing field is damaged.
404
+ */
405
+ function outlineEntry(value) {
406
+ if (typeof value !== "object" || value === null) return void 0;
407
+ const entry = value;
408
+ if (typeof entry.turn !== "number" || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return void 0;
409
+ if (typeof entry.seq !== "number" || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0)) return void 0;
410
+ return {
411
+ turn: entry.turn,
412
+ seq: entry.seq,
413
+ prompt: typeof entry.prompt === "string" ? entry.prompt : "",
414
+ response: typeof entry.response === "string" ? entry.response : ""
415
+ };
416
+ }
417
+ //#endregion
418
+ //#region src/client/rows.ts
419
+ /**
420
+ * The first visible loaded row of a turn, of any node kind: the landing
421
+ * target for a turn with no user-kind row (an automated turn renders a
422
+ * `turn-trigger` card, not a user bubble), mirroring the official rail's
423
+ * `user ?? loaded[0]` anchor. Only nodes in the host's visible `order`
424
+ * qualify — a store-only or hidden node is not a jump target.
425
+ * @param order - visible chat node keys in render order.
426
+ * @param nodes - the live node store.
427
+ * @param seq - the turn's `turn/start` seq (inclusive).
428
+ * @param end - the next turn's start seq (exclusive), or Infinity for the last turn.
429
+ * @returns the anchor key, or null when the turn has no visible loaded row.
430
+ */
431
+ function firstVisibleKeyInTurn(order, nodes, seq, end) {
432
+ for (const key of order) {
433
+ const node = nodes.get(key);
434
+ if (node === void 0) continue;
435
+ if (node.anchorSeq < seq) continue;
436
+ if (node.anchorSeq >= end) break;
437
+ if (node.visibility === "hidden") continue;
438
+ return key;
439
+ }
440
+ return null;
441
+ }
442
+ /**
443
+ * Wrap one loaded item as a rail row.
444
+ * @param item - the window row.
445
+ * @param turn - its owning outline turn, or undefined.
446
+ * @returns the loaded rail row.
447
+ */
448
+ function toLoadedRow(item, turn) {
449
+ return {
450
+ kind: "loaded",
451
+ id: item.key,
452
+ key: item.key,
453
+ node: item.node,
454
+ anchorSeq: item.node.anchorSeq,
455
+ turn
456
+ };
457
+ }
458
+ /**
459
+ * Merge loaded rows with the outline into the full row list. Both inputs are
460
+ * transcript-ordered (loaded rows anchorSeq-ascending, the outline strictly
461
+ * increasing by turn), so one pass interleaves them without a final sort: an
462
+ * outline entry whose seq range holds at least one loaded row is covered and
463
+ * emits nothing; every other entry emits its outline row at the range's
464
+ * position. A loaded row belongs to the last entry whose start it follows —
465
+ * when the window runs ahead of the projection feed, the newest rows read as
466
+ * the last named turn until the feed catches up (suppressing its outline row
467
+ * beats duplicating the turn).
468
+ * @param loaded - the window's user rows, anchorSeq-ascending.
469
+ * @param outline - narrowed outline entries, or undefined (capability absent).
470
+ * @returns the merged row list, transcript-ordered.
471
+ */
472
+ function mergeRows(loaded, outline) {
473
+ if (outline === void 0) return loaded.map((item) => toLoadedRow(item, void 0));
474
+ const rows = [];
475
+ let cursor = 0;
476
+ for (const [index, entry] of outline.entries()) {
477
+ const end = outline.at(index + 1)?.seq ?? Number.POSITIVE_INFINITY;
478
+ let covered = false;
479
+ for (;;) {
480
+ const item = loaded[cursor];
481
+ if (item === void 0 || item.node.anchorSeq >= end) break;
482
+ rows.push(toLoadedRow(item, item.node.anchorSeq >= entry.seq ? entry.turn : void 0));
483
+ covered = covered || item.node.anchorSeq >= entry.seq;
484
+ cursor += 1;
485
+ }
486
+ if (!covered) rows.push({
487
+ kind: "outline",
488
+ id: `turn:${entry.turn}`,
489
+ turn: entry.turn,
490
+ seq: entry.seq,
491
+ anchorSeq: entry.seq,
492
+ prompt: entry.prompt
493
+ });
494
+ }
495
+ for (;;) {
496
+ const item = loaded[cursor];
497
+ if (item === void 0) break;
498
+ rows.push(toLoadedRow(item, void 0));
499
+ cursor += 1;
500
+ }
501
+ return rows;
502
+ }
503
+ //#endregion
355
504
  //#region src/client/preview.ts
356
505
  /**
357
506
  * Join the text blocks of one message into a single preview string.
@@ -402,7 +551,7 @@ window.__ModuleLoader__.load({
402
551
  }
403
552
  //#endregion
404
553
  //#region \0dsh-css:src/client/TimelineRail.module.css.mjs
405
- const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel:before,._8u17xW_panel:after{content:\"\";margin:auto}._8u17xW_panel::-webkit-scrollbar{display:none}._8u17xW_item{width:100%;max-width:20px;color:var(--dsw-alias-label-tertiary);text-align:left;cursor:pointer;-webkit-user-select:none;user-select:none;pointer-events:auto;opacity:.55;background:0 0;border:none;flex:none;align-items:center;gap:8px;padding:5px 0;font-size:13px;line-height:1.5;transition:opacity .12s;display:flex}._8u17xW_panel:hover ._8u17xW_item,._8u17xW_panel:focus-within ._8u17xW_item{opacity:1;max-width:100%}._8u17xW_tick{background:var(--dsw-static-deepseek-400);border-radius:2px;flex:none;width:3px;height:14px}._8u17xW_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:.75}._8u17xW_tickCurrent{background:var(--dsw-static-deepseek-500)}._8u17xW_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}._8u17xW_panel:hover ._8u17xW_itemText,._8u17xW_panel:focus-within ._8u17xW_itemText{opacity:1;transition-delay:90ms}._8u17xW_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}._8u17xW_item:focus:not(:focus-visible){outline:none}._8u17xW_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}";
554
+ const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel::-webkit-scrollbar{display:none}._8u17xW_item{width:100%;max-width:20px;color:var(--dsw-alias-label-tertiary);text-align:left;cursor:pointer;-webkit-user-select:none;user-select:none;pointer-events:auto;opacity:.55;background:0 0;border:none;flex:none;align-items:center;gap:8px;padding:5px 0;font-size:13px;line-height:1.5;transition:opacity .12s;display:flex}._8u17xW_panel:hover ._8u17xW_item,._8u17xW_panel:focus-within ._8u17xW_item{opacity:1;max-width:100%}._8u17xW_tick{background:var(--dsw-alias-label-tertiary);border-radius:2px;flex:none;width:3px;height:14px}._8u17xW_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:1;font-weight:600}._8u17xW_tickCurrent{background:var(--dsw-static-deepseek-500);height:18px}._8u17xW_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}._8u17xW_panel:hover ._8u17xW_itemText,._8u17xW_panel:focus-within ._8u17xW_itemText{opacity:1;transition-delay:90ms}._8u17xW_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}._8u17xW_item:focus:not(:focus-visible){outline:none}._8u17xW_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}._8u17xW_itemPending ._8u17xW_tick{background:var(--dsw-static-deepseek-400);animation:1s ease-in-out infinite _8u17xW_railPending}@keyframes _8u17xW_railPending{50%{opacity:.2}}@media (prefers-reduced-motion:reduce){._8u17xW_itemPending ._8u17xW_tick{animation:none}}._8u17xW_panelNarrow{z-index:101}._8u17xW_panelNarrow:hover,._8u17xW_panelNarrow:focus-within{background:var(--dsw-alias-bg-layer-1);border-radius:8px;box-shadow:0 4px 16px #0000002e;width:var(--panel-wide)!important}";
406
555
  const tagId = "@khorsheed/dsh-message-timeline/TimelineRail.module.css";
407
556
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
408
557
  const tag = document.createElement("style");
@@ -412,13 +561,16 @@ window.__ModuleLoader__.load({
412
561
  document.head.appendChild(tag);
413
562
  }
414
563
  var TimelineRail_module_css_default = {
415
- "itemCurrent": "_8u17xW_itemCurrent",
564
+ "panelNarrow": "_8u17xW_panelNarrow",
565
+ "itemText": "_8u17xW_itemText",
416
566
  "panel": "_8u17xW_panel",
417
- "tick": "_8u17xW_tick",
418
- "item": "_8u17xW_item",
567
+ "itemPending": "_8u17xW_itemPending",
419
568
  "tickCurrent": "_8u17xW_tickCurrent",
569
+ "item": "_8u17xW_item",
570
+ "itemCurrent": "_8u17xW_itemCurrent",
420
571
  "itemFocused": "_8u17xW_itemFocused",
421
- "itemText": "_8u17xW_itemText"
572
+ "tick": "_8u17xW_tick",
573
+ "railPending": "_8u17xW_railPending"
422
574
  };
423
575
  //#endregion
424
576
  //#region src/client/TimelineRail.tsx
@@ -426,20 +578,52 @@ window.__ModuleLoader__.load({
426
578
  * Message timeline panel, browser half. One entry in the official
427
579
  * conversation.session.header.utilities seat anchors the plugin into the
428
580
  * session scope and renders, through a body portal, the flat floating
429
- * timeline over the left edge of the chat scrollport: one row per loaded
430
- * user message — a tick plus an ellipsized one-line preview, no frame and no
581
+ * timeline over the left edge of the chat scrollport: one row per user
582
+ * message — a tick plus an ellipsized one-line preview, no frame and no
431
583
  * visible scrollbar. At rest only the dimmed ticks show, the reading
432
584
  * position's tick in blue (the latest message until the tracker answers);
433
585
  * hovering or keyboard-focusing the panel reveals the row texts with the
434
586
  * blue row on top, and clicking a row jumps the transcript to that message.
435
587
  * The panel is always on while the chat view shows; the `enabled` config is
436
588
  * the off switch.
589
+ *
590
+ * The row list is whole-session from the moment the session opens: the host
591
+ * `turnOutline` projection names every started turn, and the loaded chat
592
+ * window overlays richer previews (plus steering rows and message-tools
593
+ * bubbles) for the turns it covers. A turn outside the window renders as a
594
+ * dimmed outline row; confirming it pages history back through the turn's
595
+ * `turn/start` seq (the session jump loader) and lands on the freshly loaded
596
+ * row. When the host serves no turnOutline value the panel degrades to the
597
+ * loaded window alone, paged by prefetch and panel-top scrolls.
437
598
  */
438
599
  /** Extract the preview blocks of one user/steering node (kind-checked by the caller). */
439
600
  function nodeContent(node) {
440
601
  return node.data.content ?? [];
441
602
  }
442
603
  /**
604
+ * One row's display text: the loaded row's joined text blocks, or the outline
605
+ * entry's bounded first-prompt preview. `null` means the message carries no
606
+ * text (an image/attachment-only input, or an automated turn with no human
607
+ * prompt) — the row renders the `rail.empty` label instead.
608
+ */
609
+ function rowPreview(row) {
610
+ return row.kind === "loaded" ? previewText(nodeContent(row.node)) : row.prompt !== "" ? row.prompt : null;
611
+ }
612
+ /**
613
+ * The row's display label: the text preview when there is one, else a
614
+ * kind-aware fallback — the turn number for an outline-only turn, and an
615
+ * image/attachment label for a loaded message with no text blocks.
616
+ */
617
+ function rowLabel(row, t) {
618
+ const preview = rowPreview(row);
619
+ if (preview !== null) return preview;
620
+ if (row.kind === "outline") return t("rail.turn", { turn: row.turn });
621
+ const types = new Set(nodeContent(row.node).map((block) => block.type));
622
+ if (types.has("image")) return t("rail.image");
623
+ if (types.has("file")) return t("rail.attachment");
624
+ return t("rail.empty");
625
+ }
626
+ /**
443
627
  * Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
444
628
  * band sits this far inside the box, so the width budget must give it back
445
629
  * for {@link PANEL_GAP} to be the visible gap to the message flow.
@@ -449,12 +633,22 @@ window.__ModuleLoader__.load({
449
633
  const PANEL_GAP = 16;
450
634
  /** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
451
635
  const DEGRADED_WIDTH_RATIO = .4;
636
+ /** Rest width of the narrow-layout tick strip (px) — fits the flow's left padding. */
637
+ const PANEL_NARROW_REST_WIDTH = 24;
638
+ /** Expanded width of the narrow-layout hover overlay (fraction of the scrollport). */
639
+ const NARROW_WIDE_RATIO = .6;
640
+ /**
641
+ * Frames an outline jump retries the DOM scroll after its row materializes in
642
+ * the chat store: the transcript renders the new page in the same commit or a
643
+ * near frame, and a half-second budget covers the skew without hanging.
644
+ */
645
+ const JUMP_RETRY_FRAMES = 30;
452
646
  /**
453
647
  * The header-utilities entry: the portal timeline panel.
454
648
  * @param props - composed props (see {@link TimelineRailProps}).
455
649
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
456
650
  */
457
- function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
651
+ function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
458
652
  const rail = useRail((s) => s);
459
653
  const useChatSlice = chatHookOf(standard);
460
654
  const useSession = standard.useSession;
@@ -462,6 +656,8 @@ window.__ModuleLoader__.load({
462
656
  const nodes = useChatSlice((c) => c.nodes);
463
657
  const hasMore = useSession((s) => s.hasMore);
464
658
  const loadingOlder = useSession((s) => s.loadingOlder);
659
+ const outlineValue = standard.useProjection?.("turnOutline");
660
+ const outline = (0, react.useMemo)(() => outlineEntries(outlineValue), [outlineValue]);
465
661
  const items = (0, react.useMemo)(() => {
466
662
  let store = [];
467
663
  let spans = [];
@@ -470,14 +666,14 @@ window.__ModuleLoader__.load({
470
666
  spans = foldHiddenSpans(store);
471
667
  } catch {}
472
668
  const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
473
- const result = [];
669
+ const loaded = [];
474
670
  const seen = /* @__PURE__ */ new Set();
475
671
  for (const key of order) {
476
672
  const node = nodes.get(key);
477
673
  if (node === void 0) continue;
478
674
  if (!isTimelineRowKind(node.kind, includeSteering)) continue;
479
675
  if (hidden(node)) continue;
480
- result.push({
676
+ loaded.push({
481
677
  key,
482
678
  node
483
679
  });
@@ -488,27 +684,95 @@ window.__ModuleLoader__.load({
488
684
  if (node.visibility === "hidden") continue;
489
685
  if (seen.has(node.key)) continue;
490
686
  if (hidden(node)) continue;
491
- result.push({
687
+ loaded.push({
492
688
  key: node.key,
493
689
  node
494
690
  });
495
691
  seen.add(node.key);
496
692
  }
497
- result.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
498
- return result;
693
+ loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
694
+ return mergeRows(loaded, outline);
499
695
  }, [
500
696
  order,
501
697
  nodes,
502
- includeSteering
698
+ includeSteering,
699
+ outline
503
700
  ]);
504
701
  const [focusKey, setFocusKey] = (0, react.useState)(null);
505
- const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null;
702
+ const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
506
703
  const panelRef = (0, react.useRef)(null);
704
+ const [pendingJump, setPendingJump] = (0, react.useState)(null);
705
+ (0, react.useEffect)(() => {
706
+ setPendingJump(null);
707
+ }, [sessionId]);
708
+ const firstSeq = (0, react.useMemo)(() => {
709
+ const firstKey = order[0];
710
+ if (firstKey === void 0) return null;
711
+ return nodes.get(firstKey)?.anchorSeq ?? null;
712
+ }, [order, nodes]);
713
+ (0, react.useEffect)(() => {
714
+ if (pendingJump === null || pendingJump.sessionId !== sessionId) return;
715
+ const landOn = (key) => {
716
+ let cancelled = false;
717
+ let frames = 0;
718
+ const attempt = () => {
719
+ if (cancelled) return;
720
+ frames += 1;
721
+ if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
722
+ setPendingJump(null);
723
+ return;
724
+ }
725
+ (typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout)(attempt);
726
+ };
727
+ attempt();
728
+ return () => {
729
+ cancelled = true;
730
+ };
731
+ };
732
+ const landed = items.find((row) => row.kind === "loaded" && row.turn === pendingJump.turn);
733
+ if (landed !== void 0) return landOn(landed.key);
734
+ const end = outline?.find((entry) => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
735
+ const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
736
+ if (anyKey !== null) return landOn(anyKey);
737
+ if (pendingJump.awaiting || loadingOlder) return;
738
+ if ((firstSeq === null || firstSeq > pendingJump.seq) && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
739
+ const jump = pendingJump;
740
+ setPendingJump({
741
+ ...jump,
742
+ requested: true,
743
+ awaiting: true,
744
+ fromHead: firstSeq
745
+ });
746
+ loadThrough(jump.seq).then(() => {
747
+ setPendingJump((current) => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn ? {
748
+ ...current,
749
+ awaiting: false
750
+ } : current);
751
+ });
752
+ return;
753
+ }
754
+ const fallback = items.find((row) => row.kind === "loaded" && row.anchorSeq >= pendingJump.seq);
755
+ if (fallback !== void 0) jumpTo(fallback.key);
756
+ setPendingJump(null);
757
+ }, [
758
+ items,
759
+ pendingJump,
760
+ sessionId,
761
+ loadingOlder,
762
+ hasMore,
763
+ firstSeq,
764
+ jumpTo,
765
+ loadThrough,
766
+ order,
767
+ nodes,
768
+ outline
769
+ ]);
507
770
  const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
508
771
  const visible = active && items.length > 0;
509
772
  const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
510
- const width = gutter === null ? Math.min(panelWidth, Math.max(120, rail.scrollportWidth * DEGRADED_WIDTH_RATIO)) : Math.min(panelWidth, Math.max(0, gutter));
511
- const tooNarrow = gutter !== null && width < 120;
773
+ const narrow = gutter !== null && gutter < 120;
774
+ const width = gutter === null ? Math.min(panelWidth, Math.max(120, rail.scrollportWidth * DEGRADED_WIDTH_RATIO)) : narrow ? PANEL_NARROW_REST_WIDTH : Math.min(panelWidth, gutter);
775
+ const wideWidth = Math.min(panelWidth, Math.max(120, rail.scrollportWidth * NARROW_WIDE_RATIO));
512
776
  (0, react.useEffect)(() => {
513
777
  panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)?.scrollIntoView({ block: "nearest" });
514
778
  }, [current]);
@@ -518,6 +782,7 @@ window.__ModuleLoader__.load({
518
782
  prefetchedPagesRef.current = 0;
519
783
  return;
520
784
  }
785
+ if (outline !== void 0) return;
521
786
  if (!hasMore || loadingOlder) return;
522
787
  if (items.length > 0 && prefetchedPagesRef.current >= initialPages) return;
523
788
  prefetchedPagesRef.current += 1;
@@ -528,72 +793,92 @@ window.__ModuleLoader__.load({
528
793
  loadingOlder,
529
794
  items.length,
530
795
  initialPages,
531
- loadOlder
796
+ loadOlder,
797
+ outline
532
798
  ]);
533
799
  const onPanelScroll = (event) => {
800
+ if (outline !== void 0) return;
534
801
  if (!hasMore || loadingOlder) return;
535
802
  if (event.currentTarget.scrollTop <= 8) loadOlder();
536
803
  };
537
- const confirm = (key) => {
804
+ const confirm = (row) => {
538
805
  setFocusKey(null);
539
- jumpTo(key);
806
+ if (row.kind === "loaded") {
807
+ setPendingJump(null);
808
+ jumpTo(row.key);
809
+ return;
810
+ }
811
+ setPendingJump({
812
+ sessionId,
813
+ turn: row.turn,
814
+ seq: row.seq,
815
+ requested: false,
816
+ awaiting: false,
817
+ fromHead: null
818
+ });
540
819
  };
541
- if (!visible || tooNarrow) return null;
820
+ if (!visible) return null;
542
821
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
543
822
  ref: panelRef,
544
- className: TimelineRail_module_css_default.panel,
823
+ className: `${TimelineRail_module_css_default.panel} ${narrow ? TimelineRail_module_css_default.panelNarrow : ""}`,
545
824
  role: "navigation",
546
825
  "aria-label": t("rail.panel"),
547
826
  tabIndex: 0,
548
827
  "data-timeline-panel": "",
549
828
  style: {
550
829
  left: rail.left,
551
- top: rail.top,
552
- height: rail.height,
553
- width
830
+ top: rail.top + rail.height / 2,
831
+ maxHeight: rail.height,
832
+ transform: "translateY(-50%)",
833
+ width,
834
+ ...narrow ? { "--panel-wide": `${wideWidth}px` } : {}
554
835
  },
555
836
  onScroll: onPanelScroll,
556
837
  onKeyDown: (event) => {
557
838
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
558
839
  event.preventDefault();
559
- const anchor = items.findIndex((item) => item.key === current);
840
+ const anchor = items.findIndex((item) => item.id === current);
560
841
  const next = event.key === "ArrowDown" ? items[Math.min(anchor + 1, items.length - 1)] : items[Math.max(anchor - 1, 0)];
561
842
  /* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
562
- if (next !== void 0) setFocusKey(next.key);
843
+ if (next !== void 0) setFocusKey(next.id);
563
844
  } else if (event.key === "Enter" || event.key === " ") {
564
845
  event.preventDefault();
565
- /* v8 ignore next -- the panel only renders with at least one item, so a current key always exists */
566
- if (current !== null) confirm(current);
846
+ const row = items.find((item) => item.id === current);
847
+ if (row !== void 0) confirm(row);
567
848
  }
568
849
  },
569
850
  onBlur: () => {
570
851
  setFocusKey(null);
571
852
  },
572
- children: items.map((item) => {
573
- const isCurrent = item.key === current;
574
- const className = item.key === focusKey ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemFocused}` : isCurrent ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemCurrent}` : TimelineRail_module_css_default.item;
853
+ children: items.map((row) => {
854
+ const isCurrent = row.id === current;
855
+ const isFocused = row.id === focusKey;
856
+ const isPending = row.kind === "outline" && pendingJump !== null && pendingJump.turn === row.turn;
857
+ const className = isFocused ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemFocused}` : isCurrent ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemCurrent}` : TimelineRail_module_css_default.item;
858
+ const classNames = isPending ? `${className} ${TimelineRail_module_css_default.itemPending}` : className;
575
859
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
576
860
  type: "button",
577
- "data-item-key": item.key,
861
+ "data-item-key": row.id,
578
862
  "aria-current": isCurrent || void 0,
579
- className,
863
+ "aria-busy": isPending || void 0,
864
+ className: classNames,
580
865
  onClick: () => {
581
- confirm(item.key);
866
+ confirm(row);
582
867
  },
583
868
  onMouseEnter: () => {
584
- setFocusKey(item.key);
869
+ setFocusKey(row.id);
585
870
  },
586
871
  onMouseLeave: () => {
587
- setFocusKey((key) => key === item.key ? null : key);
872
+ setFocusKey((key) => key === row.id ? null : key);
588
873
  },
589
874
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
590
875
  className: isCurrent ? `${TimelineRail_module_css_default.tick} ${TimelineRail_module_css_default.tickCurrent}` : TimelineRail_module_css_default.tick,
591
876
  "aria-hidden": "true"
592
877
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
593
878
  className: TimelineRail_module_css_default.itemText,
594
- children: previewText(nodeContent(item.node)) ?? t("rail.empty")
879
+ children: rowLabel(row, t)
595
880
  })]
596
- }, item.key);
881
+ }, row.id);
597
882
  })
598
883
  }), document.body);
599
884
  }
@@ -636,14 +921,15 @@ window.__ModuleLoader__.load({
636
921
  if (actx === void 0) throw new Error("message-timeline: session resolved no scope");
637
922
  const conversation = actx.get("conversation");
638
923
  if (conversation === void 0) throw new Error("message-timeline: conversation service unavailable");
924
+ const binding = ctx.sessions.binding(sessionId);
925
+ if (binding === void 0) throw new Error("message-timeline: session resolved no binding");
639
926
  return {
640
927
  includeSteering: options.includeSteering,
641
928
  panelWidth: options.panelWidth,
642
929
  initialPages: options.initialPages,
643
930
  loadOlder: () => conversation.loadOlder(),
644
- jumpTo: (key) => {
645
- tracker.jumpTo(key);
646
- },
931
+ loadThrough: (seq) => binding.session.loadThrough(seq),
932
+ jumpTo: (key) => tracker.jumpTo(key),
647
933
  hooks: { rail: tracker.state }
648
934
  };
649
935
  }
@@ -4,7 +4,7 @@ import type { TimelineRailProps } from './slots.ts';
4
4
  * @param props - composed props (see {@link TimelineRailProps}).
5
5
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
6
6
  */
7
- export declare function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }: TimelineRailProps): import("react").ReactPortal | null;
7
+ export declare function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }: TimelineRailProps): import("react").ReactPortal | null;
8
8
  /** Memoized export for the slot machinery (stable component identity). */
9
9
  export declare const TimelineRailEntry: import("react").MemoExoticComponent<typeof TimelineRail>;
10
10
  //# sourceMappingURL=TimelineRail.d.ts.map