@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.
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
@@ -319,9 +328,7 @@ window.__ModuleLoader__.load({
319
328
  };
320
329
  }
321
330
  },
322
- jumpTo: (key) => {
323
- if (scrollport !== null) jumpRow(scrollport, key);
324
- },
331
+ jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
325
332
  dispose: () => {
326
333
  stopList();
327
334
  stopProvide?.();
@@ -352,6 +359,129 @@ window.__ModuleLoader__.load({
352
359
  return props.useChat ?? EMPTY_HOOK;
353
360
  }
354
361
  //#endregion
362
+ //#region src/client/outline.ts
363
+ /**
364
+ * Narrow one wire projection value to outline entries. `turn` and `seq` are
365
+ * load-bearing — a row cannot exist or jump without them — so damage there
366
+ * drops the entry; the previews are decorative and degrade to ''. A non-array
367
+ * value (absent unit included) narrows to undefined.
368
+ * @param value - the raw `useProjection('turnOutline')` read.
369
+ * @returns the surviving entries in wire order, or undefined when absent.
370
+ */
371
+ function outlineEntries(value) {
372
+ if (!Array.isArray(value)) return void 0;
373
+ const entries = [];
374
+ for (const raw of value) {
375
+ const entry = outlineEntry(raw);
376
+ if (entry !== void 0) entries.push(entry);
377
+ }
378
+ return entries;
379
+ }
380
+ /**
381
+ * Narrow one wire entry. `turn` and `seq` must be non-negative safe integers
382
+ * (seq additionally rejects -0); the previews default to '' when malformed.
383
+ * @param value - one raw wire entry.
384
+ * @returns the entry, or undefined when a load-bearing field is damaged.
385
+ */
386
+ function outlineEntry(value) {
387
+ if (typeof value !== "object" || value === null) return void 0;
388
+ const entry = value;
389
+ if (typeof entry.turn !== "number" || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return void 0;
390
+ if (typeof entry.seq !== "number" || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0)) return void 0;
391
+ return {
392
+ turn: entry.turn,
393
+ seq: entry.seq,
394
+ prompt: typeof entry.prompt === "string" ? entry.prompt : "",
395
+ response: typeof entry.response === "string" ? entry.response : ""
396
+ };
397
+ }
398
+ //#endregion
399
+ //#region src/client/rows.ts
400
+ /**
401
+ * The first visible loaded row of a turn, of any node kind: the landing
402
+ * target for a turn with no user-kind row (an automated turn renders a
403
+ * `turn-trigger` card, not a user bubble), mirroring the official rail's
404
+ * `user ?? loaded[0]` anchor. Only nodes in the host's visible `order`
405
+ * qualify — a store-only or hidden node is not a jump target.
406
+ * @param order - visible chat node keys in render order.
407
+ * @param nodes - the live node store.
408
+ * @param seq - the turn's `turn/start` seq (inclusive).
409
+ * @param end - the next turn's start seq (exclusive), or Infinity for the last turn.
410
+ * @returns the anchor key, or null when the turn has no visible loaded row.
411
+ */
412
+ function firstVisibleKeyInTurn(order, nodes, seq, end) {
413
+ for (const key of order) {
414
+ const node = nodes.get(key);
415
+ if (node === void 0) continue;
416
+ if (node.anchorSeq < seq) continue;
417
+ if (node.anchorSeq >= end) break;
418
+ if (node.visibility === "hidden") continue;
419
+ return key;
420
+ }
421
+ return null;
422
+ }
423
+ /**
424
+ * Wrap one loaded item as a rail row.
425
+ * @param item - the window row.
426
+ * @param turn - its owning outline turn, or undefined.
427
+ * @returns the loaded rail row.
428
+ */
429
+ function toLoadedRow(item, turn) {
430
+ return {
431
+ kind: "loaded",
432
+ id: item.key,
433
+ key: item.key,
434
+ node: item.node,
435
+ anchorSeq: item.node.anchorSeq,
436
+ turn
437
+ };
438
+ }
439
+ /**
440
+ * Merge loaded rows with the outline into the full row list. Both inputs are
441
+ * transcript-ordered (loaded rows anchorSeq-ascending, the outline strictly
442
+ * increasing by turn), so one pass interleaves them without a final sort: an
443
+ * outline entry whose seq range holds at least one loaded row is covered and
444
+ * emits nothing; every other entry emits its outline row at the range's
445
+ * position. A loaded row belongs to the last entry whose start it follows —
446
+ * when the window runs ahead of the projection feed, the newest rows read as
447
+ * the last named turn until the feed catches up (suppressing its outline row
448
+ * beats duplicating the turn).
449
+ * @param loaded - the window's user rows, anchorSeq-ascending.
450
+ * @param outline - narrowed outline entries, or undefined (capability absent).
451
+ * @returns the merged row list, transcript-ordered.
452
+ */
453
+ function mergeRows(loaded, outline) {
454
+ if (outline === void 0) return loaded.map((item) => toLoadedRow(item, void 0));
455
+ const rows = [];
456
+ let cursor = 0;
457
+ for (const [index, entry] of outline.entries()) {
458
+ const end = outline.at(index + 1)?.seq ?? Number.POSITIVE_INFINITY;
459
+ let covered = false;
460
+ for (;;) {
461
+ const item = loaded[cursor];
462
+ if (item === void 0 || item.node.anchorSeq >= end) break;
463
+ rows.push(toLoadedRow(item, item.node.anchorSeq >= entry.seq ? entry.turn : void 0));
464
+ covered = covered || item.node.anchorSeq >= entry.seq;
465
+ cursor += 1;
466
+ }
467
+ if (!covered) rows.push({
468
+ kind: "outline",
469
+ id: `turn:${entry.turn}`,
470
+ turn: entry.turn,
471
+ seq: entry.seq,
472
+ anchorSeq: entry.seq,
473
+ prompt: entry.prompt
474
+ });
475
+ }
476
+ for (;;) {
477
+ const item = loaded[cursor];
478
+ if (item === void 0) break;
479
+ rows.push(toLoadedRow(item, void 0));
480
+ cursor += 1;
481
+ }
482
+ return rows;
483
+ }
484
+ //#endregion
355
485
  //#region src/client/preview.ts
356
486
  /**
357
487
  * Join the text blocks of one message into a single preview string.
@@ -402,7 +532,7 @@ window.__ModuleLoader__.load({
402
532
  }
403
533
  //#endregion
404
534
  //#region \0dsh-css:src/client/TimelineRail.module.css.mjs
405
- const css = ".xnuhBq_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}.xnuhBq_panel:before,.xnuhBq_panel:after{content:\"\";margin:auto}.xnuhBq_panel::-webkit-scrollbar{display:none}.xnuhBq_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}.xnuhBq_panel:hover .xnuhBq_item,.xnuhBq_panel:focus-within .xnuhBq_item{opacity:1;max-width:100%}.xnuhBq_tick{background:var(--dsw-static-deepseek-400);border-radius:2px;flex:none;width:3px;height:14px}.xnuhBq_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:.75}.xnuhBq_tickCurrent{background:var(--dsw-static-deepseek-500)}.xnuhBq_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}.xnuhBq_panel:hover .xnuhBq_itemText,.xnuhBq_panel:focus-within .xnuhBq_itemText{opacity:1;transition-delay:90ms}.xnuhBq_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}.xnuhBq_item:focus:not(:focus-visible){outline:none}.xnuhBq_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}";
535
+ 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-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}}";
406
536
  const tagId = "@khorsheed/dsh-message-timeline/TimelineRail.module.css";
407
537
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
408
538
  const tag = document.createElement("style");
@@ -412,13 +542,15 @@ window.__ModuleLoader__.load({
412
542
  document.head.appendChild(tag);
413
543
  }
414
544
  var TimelineRail_module_css_default = {
415
- "panel": "xnuhBq_panel",
416
- "itemText": "xnuhBq_itemText",
417
- "itemFocused": "xnuhBq_itemFocused",
418
- "itemCurrent": "xnuhBq_itemCurrent",
419
- "tickCurrent": "xnuhBq_tickCurrent",
420
- "item": "xnuhBq_item",
421
- "tick": "xnuhBq_tick"
545
+ "itemText": "_8u17xW_itemText",
546
+ "railPending": "_8u17xW_railPending",
547
+ "panel": "_8u17xW_panel",
548
+ "tickCurrent": "_8u17xW_tickCurrent",
549
+ "item": "_8u17xW_item",
550
+ "itemCurrent": "_8u17xW_itemCurrent",
551
+ "itemPending": "_8u17xW_itemPending",
552
+ "tick": "_8u17xW_tick",
553
+ "itemFocused": "_8u17xW_itemFocused"
422
554
  };
423
555
  //#endregion
424
556
  //#region src/client/TimelineRail.tsx
@@ -426,20 +558,52 @@ window.__ModuleLoader__.load({
426
558
  * Message timeline panel, browser half. One entry in the official
427
559
  * conversation.session.header.utilities seat anchors the plugin into the
428
560
  * 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
561
+ * timeline over the left edge of the chat scrollport: one row per user
562
+ * message — a tick plus an ellipsized one-line preview, no frame and no
431
563
  * visible scrollbar. At rest only the dimmed ticks show, the reading
432
564
  * position's tick in blue (the latest message until the tracker answers);
433
565
  * hovering or keyboard-focusing the panel reveals the row texts with the
434
566
  * blue row on top, and clicking a row jumps the transcript to that message.
435
567
  * The panel is always on while the chat view shows; the `enabled` config is
436
568
  * the off switch.
569
+ *
570
+ * The row list is whole-session from the moment the session opens: the host
571
+ * `turnOutline` projection names every started turn, and the loaded chat
572
+ * window overlays richer previews (plus steering rows and message-tools
573
+ * bubbles) for the turns it covers. A turn outside the window renders as a
574
+ * dimmed outline row; confirming it pages history back through the turn's
575
+ * `turn/start` seq (the session jump loader) and lands on the freshly loaded
576
+ * row. When the host serves no turnOutline value the panel degrades to the
577
+ * loaded window alone, paged by prefetch and panel-top scrolls.
437
578
  */
438
579
  /** Extract the preview blocks of one user/steering node (kind-checked by the caller). */
439
580
  function nodeContent(node) {
440
581
  return node.data.content ?? [];
441
582
  }
442
583
  /**
584
+ * One row's display text: the loaded row's joined text blocks, or the outline
585
+ * entry's bounded first-prompt preview. `null` means the message carries no
586
+ * text (an image/attachment-only input, or an automated turn with no human
587
+ * prompt) — the row renders the `rail.empty` label instead.
588
+ */
589
+ function rowPreview(row) {
590
+ return row.kind === "loaded" ? previewText(nodeContent(row.node)) : row.prompt !== "" ? row.prompt : null;
591
+ }
592
+ /**
593
+ * The row's display label: the text preview when there is one, else a
594
+ * kind-aware fallback — the turn number for an outline-only turn, and an
595
+ * image/attachment label for a loaded message with no text blocks.
596
+ */
597
+ function rowLabel(row, t) {
598
+ const preview = rowPreview(row);
599
+ if (preview !== null) return preview;
600
+ if (row.kind === "outline") return t("rail.turn", { turn: row.turn });
601
+ const types = new Set(nodeContent(row.node).map((block) => block.type));
602
+ if (types.has("image")) return t("rail.image");
603
+ if (types.has("file")) return t("rail.attachment");
604
+ return t("rail.empty");
605
+ }
606
+ /**
443
607
  * Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
444
608
  * band sits this far inside the box, so the width budget must give it back
445
609
  * for {@link PANEL_GAP} to be the visible gap to the message flow.
@@ -450,11 +614,17 @@ window.__ModuleLoader__.load({
450
614
  /** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
451
615
  const DEGRADED_WIDTH_RATIO = .4;
452
616
  /**
617
+ * Frames an outline jump retries the DOM scroll after its row materializes in
618
+ * the chat store: the transcript renders the new page in the same commit or a
619
+ * near frame, and a half-second budget covers the skew without hanging.
620
+ */
621
+ const JUMP_RETRY_FRAMES = 30;
622
+ /**
453
623
  * The header-utilities entry: the portal timeline panel.
454
624
  * @param props - composed props (see {@link TimelineRailProps}).
455
625
  * @returns nothing visible in the seat; the floating panel while the chat view shows.
456
626
  */
457
- function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
627
+ function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
458
628
  const rail = useRail((s) => s);
459
629
  const useChatSlice = chatHookOf(standard);
460
630
  const useSession = standard.useSession;
@@ -462,6 +632,8 @@ window.__ModuleLoader__.load({
462
632
  const nodes = useChatSlice((c) => c.nodes);
463
633
  const hasMore = useSession((s) => s.hasMore);
464
634
  const loadingOlder = useSession((s) => s.loadingOlder);
635
+ const outlineValue = standard.useProjection?.("turnOutline");
636
+ const outline = (0, react.useMemo)(() => outlineEntries(outlineValue), [outlineValue]);
465
637
  const items = (0, react.useMemo)(() => {
466
638
  let store = [];
467
639
  let spans = [];
@@ -470,14 +642,14 @@ window.__ModuleLoader__.load({
470
642
  spans = foldHiddenSpans(store);
471
643
  } catch {}
472
644
  const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
473
- const result = [];
645
+ const loaded = [];
474
646
  const seen = /* @__PURE__ */ new Set();
475
647
  for (const key of order) {
476
648
  const node = nodes.get(key);
477
649
  if (node === void 0) continue;
478
650
  if (!isTimelineRowKind(node.kind, includeSteering)) continue;
479
651
  if (hidden(node)) continue;
480
- result.push({
652
+ loaded.push({
481
653
  key,
482
654
  node
483
655
  });
@@ -488,22 +660,89 @@ window.__ModuleLoader__.load({
488
660
  if (node.visibility === "hidden") continue;
489
661
  if (seen.has(node.key)) continue;
490
662
  if (hidden(node)) continue;
491
- result.push({
663
+ loaded.push({
492
664
  key: node.key,
493
665
  node
494
666
  });
495
667
  seen.add(node.key);
496
668
  }
497
- result.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
498
- return result;
669
+ loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
670
+ return mergeRows(loaded, outline);
499
671
  }, [
500
672
  order,
501
673
  nodes,
502
- includeSteering
674
+ includeSteering,
675
+ outline
503
676
  ]);
504
677
  const [focusKey, setFocusKey] = (0, react.useState)(null);
505
- const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null;
678
+ const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
506
679
  const panelRef = (0, react.useRef)(null);
680
+ const [pendingJump, setPendingJump] = (0, react.useState)(null);
681
+ (0, react.useEffect)(() => {
682
+ setPendingJump(null);
683
+ }, [sessionId]);
684
+ const firstSeq = (0, react.useMemo)(() => {
685
+ const firstKey = order[0];
686
+ if (firstKey === void 0) return null;
687
+ return nodes.get(firstKey)?.anchorSeq ?? null;
688
+ }, [order, nodes]);
689
+ (0, react.useEffect)(() => {
690
+ if (pendingJump === null || pendingJump.sessionId !== sessionId) return;
691
+ const landOn = (key) => {
692
+ let cancelled = false;
693
+ let frames = 0;
694
+ const attempt = () => {
695
+ if (cancelled) return;
696
+ frames += 1;
697
+ if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
698
+ setPendingJump(null);
699
+ return;
700
+ }
701
+ (typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout)(attempt);
702
+ };
703
+ attempt();
704
+ return () => {
705
+ cancelled = true;
706
+ };
707
+ };
708
+ const landed = items.find((row) => row.kind === "loaded" && row.turn === pendingJump.turn);
709
+ if (landed !== void 0) return landOn(landed.key);
710
+ const end = outline?.find((entry) => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
711
+ const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
712
+ if (anyKey !== null) return landOn(anyKey);
713
+ if (pendingJump.awaiting || loadingOlder) return;
714
+ if ((firstSeq === null || firstSeq > pendingJump.seq) && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
715
+ const jump = pendingJump;
716
+ setPendingJump({
717
+ ...jump,
718
+ requested: true,
719
+ awaiting: true,
720
+ fromHead: firstSeq
721
+ });
722
+ loadThrough(jump.seq).then(() => {
723
+ setPendingJump((current) => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn ? {
724
+ ...current,
725
+ awaiting: false
726
+ } : current);
727
+ });
728
+ return;
729
+ }
730
+ const fallback = items.find((row) => row.kind === "loaded" && row.anchorSeq >= pendingJump.seq);
731
+ if (fallback !== void 0) jumpTo(fallback.key);
732
+ setPendingJump(null);
733
+ }, [
734
+ items,
735
+ pendingJump,
736
+ sessionId,
737
+ loadingOlder,
738
+ hasMore,
739
+ firstSeq,
740
+ jumpTo,
741
+ loadThrough,
742
+ order,
743
+ nodes,
744
+ outline
745
+ ]);
507
746
  const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
508
747
  const visible = active && items.length > 0;
509
748
  const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
@@ -518,6 +757,7 @@ window.__ModuleLoader__.load({
518
757
  prefetchedPagesRef.current = 0;
519
758
  return;
520
759
  }
760
+ if (outline !== void 0) return;
521
761
  if (!hasMore || loadingOlder) return;
522
762
  if (items.length > 0 && prefetchedPagesRef.current >= initialPages) return;
523
763
  prefetchedPagesRef.current += 1;
@@ -528,15 +768,29 @@ window.__ModuleLoader__.load({
528
768
  loadingOlder,
529
769
  items.length,
530
770
  initialPages,
531
- loadOlder
771
+ loadOlder,
772
+ outline
532
773
  ]);
533
774
  const onPanelScroll = (event) => {
775
+ if (outline !== void 0) return;
534
776
  if (!hasMore || loadingOlder) return;
535
777
  if (event.currentTarget.scrollTop <= 8) loadOlder();
536
778
  };
537
- const confirm = (key) => {
779
+ const confirm = (row) => {
538
780
  setFocusKey(null);
539
- jumpTo(key);
781
+ if (row.kind === "loaded") {
782
+ setPendingJump(null);
783
+ jumpTo(row.key);
784
+ return;
785
+ }
786
+ setPendingJump({
787
+ sessionId,
788
+ turn: row.turn,
789
+ seq: row.seq,
790
+ requested: false,
791
+ awaiting: false,
792
+ fromHead: null
793
+ });
540
794
  };
541
795
  if (!visible || tooNarrow) return null;
542
796
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -556,44 +810,48 @@ window.__ModuleLoader__.load({
556
810
  onKeyDown: (event) => {
557
811
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
558
812
  event.preventDefault();
559
- const anchor = items.findIndex((item) => item.key === current);
813
+ const anchor = items.findIndex((item) => item.id === current);
560
814
  const next = event.key === "ArrowDown" ? items[Math.min(anchor + 1, items.length - 1)] : items[Math.max(anchor - 1, 0)];
561
815
  /* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
562
- if (next !== void 0) setFocusKey(next.key);
816
+ if (next !== void 0) setFocusKey(next.id);
563
817
  } else if (event.key === "Enter" || event.key === " ") {
564
818
  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);
819
+ const row = items.find((item) => item.id === current);
820
+ if (row !== void 0) confirm(row);
567
821
  }
568
822
  },
569
823
  onBlur: () => {
570
824
  setFocusKey(null);
571
825
  },
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;
826
+ children: items.map((row) => {
827
+ const isCurrent = row.id === current;
828
+ const isFocused = row.id === focusKey;
829
+ const isPending = row.kind === "outline" && pendingJump !== null && pendingJump.turn === row.turn;
830
+ 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;
831
+ const classNames = isPending ? `${className} ${TimelineRail_module_css_default.itemPending}` : className;
575
832
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
576
833
  type: "button",
577
- "data-item-key": item.key,
834
+ "data-item-key": row.id,
578
835
  "aria-current": isCurrent || void 0,
579
- className,
836
+ "aria-busy": isPending || void 0,
837
+ className: classNames,
580
838
  onClick: () => {
581
- confirm(item.key);
839
+ confirm(row);
582
840
  },
583
841
  onMouseEnter: () => {
584
- setFocusKey(item.key);
842
+ setFocusKey(row.id);
585
843
  },
586
844
  onMouseLeave: () => {
587
- setFocusKey((key) => key === item.key ? null : key);
845
+ setFocusKey((key) => key === row.id ? null : key);
588
846
  },
589
847
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
590
848
  className: isCurrent ? `${TimelineRail_module_css_default.tick} ${TimelineRail_module_css_default.tickCurrent}` : TimelineRail_module_css_default.tick,
591
849
  "aria-hidden": "true"
592
850
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
593
851
  className: TimelineRail_module_css_default.itemText,
594
- children: previewText(nodeContent(item.node)) ?? t("rail.empty")
852
+ children: rowLabel(row, t)
595
853
  })]
596
- }, item.key);
854
+ }, row.id);
597
855
  })
598
856
  }), document.body);
599
857
  }
@@ -636,14 +894,15 @@ window.__ModuleLoader__.load({
636
894
  if (actx === void 0) throw new Error("message-timeline: session resolved no scope");
637
895
  const conversation = actx.get("conversation");
638
896
  if (conversation === void 0) throw new Error("message-timeline: conversation service unavailable");
897
+ const binding = ctx.sessions.binding(sessionId);
898
+ if (binding === void 0) throw new Error("message-timeline: session resolved no binding");
639
899
  return {
640
900
  includeSteering: options.includeSteering,
641
901
  panelWidth: options.panelWidth,
642
902
  initialPages: options.initialPages,
643
903
  loadOlder: () => conversation.loadOlder(),
644
- jumpTo: (key) => {
645
- tracker.jumpTo(key);
646
- },
904
+ loadThrough: (seq) => binding.session.loadThrough(seq),
905
+ jumpTo: (key) => tracker.jumpTo(key),
647
906
  hooks: { rail: tracker.state }
648
907
  };
649
908
  }
@@ -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