@opengeni/react 0.34.2 → 0.35.2

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.
Files changed (62) hide show
  1. package/dist/{chunk-GZ4KR67U.js → chunk-ALA3UGWB.js} +2 -2
  2. package/dist/chunk-ATSTR5P3.js +138 -0
  3. package/dist/chunk-ATSTR5P3.js.map +1 -0
  4. package/dist/{chunk-L7R5RK6A.js → chunk-AXQQ2V2I.js} +1312 -926
  5. package/dist/chunk-AXQQ2V2I.js.map +1 -0
  6. package/dist/{chunk-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
  7. package/dist/chunk-FT2TXNGZ.js.map +1 -0
  8. package/dist/{chunk-TCCE5ZU5.js → chunk-J2RVJ6JX.js} +39 -9
  9. package/dist/chunk-J2RVJ6JX.js.map +1 -0
  10. package/dist/{chunk-4QOQ2DSR.js → chunk-LAKLF4PA.js} +14 -1
  11. package/dist/chunk-LAKLF4PA.js.map +1 -0
  12. package/dist/{chunk-A2WITZTI.js → chunk-OKKMZDQF.js} +3 -2
  13. package/dist/chunk-OKKMZDQF.js.map +1 -0
  14. package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
  15. package/dist/chunk-PVW56EVR.js.map +1 -0
  16. package/dist/components/chat-composer.d.ts +2 -1
  17. package/dist/components/composer.d.ts +8 -2
  18. package/dist/components/copy-button.d.ts +4 -2
  19. package/dist/components/tip-follow.d.ts +3 -0
  20. package/dist/composer.d.ts +2 -1
  21. package/dist/composer.js +4 -3
  22. package/dist/hooks/use-turn-queue.d.ts +3 -1
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.js +105 -101
  25. package/dist/index.js.map +1 -1
  26. package/dist/lib/format.d.ts +2 -0
  27. package/dist/machines.js +2 -2
  28. package/dist/session-ui.js +4 -3
  29. package/dist/session.js +4 -4
  30. package/dist/timeline/index.d.ts +1 -1
  31. package/dist/timeline/parsers.d.ts +5 -0
  32. package/dist/timeline/projection.d.ts +1 -12
  33. package/dist/timeline/tool-display-name.d.ts +17 -6
  34. package/dist/workstream-control-event.d.ts +6 -0
  35. package/package.json +2 -2
  36. package/src/components/chat-composer.tsx +1 -1
  37. package/src/components/composer-transcription-control.tsx +70 -57
  38. package/src/components/composer.tsx +197 -126
  39. package/src/components/copy-button.tsx +27 -8
  40. package/src/components/message-timeline.tsx +385 -71
  41. package/src/components/tip-follow.ts +15 -0
  42. package/src/composer.ts +1 -1
  43. package/src/hooks/use-composer.ts +7 -1
  44. package/src/hooks/use-turn-queue.ts +4 -0
  45. package/src/index.ts +2 -1
  46. package/src/lib/format.ts +14 -0
  47. package/src/timeline/index.ts +2 -0
  48. package/src/timeline/parsers.ts +18 -1
  49. package/src/timeline/projection.ts +51 -20
  50. package/src/timeline/registry.ts +15 -2
  51. package/src/timeline/shared.tsx +30 -28
  52. package/src/timeline/tool-display-name.ts +30 -11
  53. package/src/timeline/tool-renderers.tsx +347 -16
  54. package/src/timeline/turn-summary.tsx +5 -2
  55. package/src/workstream-control-event.ts +6 -0
  56. package/dist/chunk-4QOQ2DSR.js.map +0 -1
  57. package/dist/chunk-A2WITZTI.js.map +0 -1
  58. package/dist/chunk-KZRUYCL4.js.map +0 -1
  59. package/dist/chunk-L7R5RK6A.js.map +0 -1
  60. package/dist/chunk-MS3FBZ6A.js.map +0 -1
  61. package/dist/chunk-TCCE5ZU5.js.map +0 -1
  62. /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
@@ -22,6 +22,7 @@ import type { ComponentType } from "react";
22
22
  import { AnimatePresence, motion } from "motion/react";
23
23
  import { Collapsible } from "radix-ui";
24
24
  import {
25
+ Component,
25
26
  memo,
26
27
  useCallback,
27
28
  useEffect,
@@ -32,7 +33,7 @@ import {
32
33
  type ReactNode,
33
34
  } from "react";
34
35
  import { cn } from "../lib/cn";
35
- import { formatRelativeTime, truncate } from "../lib/format";
36
+ import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
36
37
  import { prefersReducedMotion } from "../lib/motion";
37
38
  import { Markdown } from "./markdown";
38
39
  import {
@@ -41,6 +42,7 @@ import {
41
42
  tipFollowCancel,
42
43
  tipFollowCompensateShrink,
43
44
  tipFollowStep,
45
+ supportsScrollEndEvent,
44
46
  TIP_FOLLOW_READER_UP_EPS_PX,
45
47
  TIP_FOLLOW_SHRINK_EPS_PX,
46
48
  type TipFollowState,
@@ -158,12 +160,16 @@ export type MessageTimelineProps = {
158
160
  * replaces was the "content is hidden, then pops in in batches" wobble.)
159
161
  *
160
162
  * Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
161
- * - DOM truth mounts immediately; the camera eases down to the tip (not
163
+ * - Load/remount: hidden until tip is hard-snapped across a short settle; then
164
+ * reveal. Live tip: DOM grows immediately; the camera eases down (not
162
165
  * tip-glued feed-forward — that is the one-line yank).
163
166
  * - One continuous follow while hot (faster τ when behind); sleeps when cold.
164
167
  * - While pinned, tip-debt from growth/collapse must NEVER unpin — only
165
- * wheel/keys/pointer-armed scroll-up. Height shrink compensates scrollTop
166
- * by Δh (collapse owns motion); tip-ease pauses briefly so the two don't fight.
168
+ * wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
169
+ * tip while the tip-follow camera is idle (Vimium / unfocused PageUp).
170
+ * Height shrink compensates scrollTop by Δh (collapse owns motion); tip-ease
171
+ * pauses briefly so the two don't fight. Programmatic camera writes are
172
+ * tagged so their scroll echoes never count as leave.
167
173
  * - overflow-anchor off while pinned so the browser cannot instant-correct.
168
174
  * - Scrolled up → history prepends restore via the retained group anchor
169
175
  * (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
@@ -288,9 +294,10 @@ export function MessageTimeline({
288
294
  const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
289
295
  const resizeFollowRafRef = useRef<number | null>(null);
290
296
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
291
- // Content stays invisible until its first bottom-anchored frame, so a flash
292
- // of the window's TOP while a large timeline lays out is structurally
293
- // impossible the reader only ever sees it already at the bottom.
297
+ // Content stays invisible until the tip is hard-parked across a short
298
+ // post-commit settle (two rAFs). That absorbs sync late layout while hidden
299
+ // so load/remount does not ease into the tip live tip-follow is unchanged
300
+ // once revealed. A flash of the window's TOP is still structurally impossible.
294
301
  const [revealed, setRevealed] = useState(false);
295
302
  // Mirror `pinned` into a ref, written ONLY by applyPinned, so the
296
303
  // ResizeObserver rAF (a stable closure) reads the live value and a snap can
@@ -325,11 +332,24 @@ export function MessageTimeline({
325
332
  const lastScrollHeightRef = useRef(0);
326
333
  const lastClientHeightRef = useRef(0);
327
334
  /**
328
- * Armed by pointerdown on the scroller. Geometric scroll-up only unpins
329
- * while armed mid-turn fold+growth also drops scrollTop with a flat
330
- * maxScroll and must NOT count as leave. Wheel/keys unpin directly.
335
+ * Armed by pointerdown on the scroller. Immediate geometric scroll-up unpins
336
+ * while armed (scrollbar / touch drag). Wheel/keys unpin directly. Extension
337
+ * jumps (Vimium) settle via scrollend while the camera is idle.
331
338
  */
332
339
  const readerIntentArmRef = useRef(false);
340
+ /**
341
+ * Count of camera/snap scrollTop writes whose scroll echoes are not yet
342
+ * consumed. A boolean was wrong when the browser coalesced two writes into
343
+ * one scroll event (or fired two) — use a count, and clear to 0 on echo.
344
+ */
345
+ const programmaticScrollRef = useRef(0);
346
+ /**
347
+ * Unarmed scroll-away observed; waiting for scrollend (or rAF fallback).
348
+ * Blocks layout tip-follow so a stream token cannot yank before leave settles.
349
+ */
350
+ const pendingReaderLeaveRef = useRef(false);
351
+ /** Fallback leave check when `scrollend` is missing (one rAF, not a timer). */
352
+ const leaveFallbackRafRef = useRef<number | null>(null);
333
353
  // Resting fold state per durable group id (see fold-memory.ts). Outlives the
334
354
  // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
335
355
  // that already settled closed — or that the reader closed — never reopens.
@@ -369,6 +389,27 @@ export function MessageTimeline({
369
389
  lastClientHeightRef.current = node.clientHeight;
370
390
  }, []);
371
391
 
392
+ const writeScrollTop = useCallback((node: HTMLElement, top: number) => {
393
+ const next = Math.max(0, top);
394
+ if (node.scrollTop === next) {
395
+ return;
396
+ }
397
+ programmaticScrollRef.current += 1;
398
+ node.scrollTop = next;
399
+ }, []);
400
+
401
+ const cancelLeaveFallback = useCallback(() => {
402
+ if (leaveFallbackRafRef.current !== null) {
403
+ cancelFrame(leaveFallbackRafRef.current);
404
+ leaveFallbackRafRef.current = null;
405
+ }
406
+ }, []);
407
+
408
+ const clearPendingReaderLeave = useCallback(() => {
409
+ pendingReaderLeaveRef.current = false;
410
+ cancelLeaveFallback();
411
+ }, [cancelLeaveFallback]);
412
+
372
413
  const stopFollow = useCallback(() => {
373
414
  followRef.current = tipFollowCancel(followRef.current);
374
415
  if (followFrameRef.current !== null) {
@@ -377,7 +418,7 @@ export function MessageTimeline({
377
418
  }
378
419
  }, []);
379
420
 
380
- /** Reader left the tip — wheel, keyboard, or pointer-armed scroll-up. */
421
+ /** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
381
422
  const releasePinFromReader = useCallback(
382
423
  (node?: HTMLElement | null) => {
383
424
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
@@ -388,6 +429,7 @@ export function MessageTimeline({
388
429
  return;
389
430
  }
390
431
  readerIntentArmRef.current = false;
432
+ clearPendingReaderLeave();
391
433
  stopFollow();
392
434
  applyPinned(false);
393
435
  if (wantPinRef.current) {
@@ -398,9 +440,51 @@ export function MessageTimeline({
398
440
  setOlderPrefetchArmed(true);
399
441
  }
400
442
  },
401
- [autoFollow, applyPinned, stopFollow],
443
+ [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
402
444
  );
403
445
 
446
+ /**
447
+ * Settled away from the tip while the camera is idle — Vimium / unfocused
448
+ * PageUp. Folds are recovered by layout tip-follow before this fires at tip.
449
+ */
450
+ const releasePinAfterScrollSettled = useCallback(
451
+ (node: HTMLElement) => {
452
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
453
+ return;
454
+ }
455
+ if (programmaticScrollRef.current > 0) {
456
+ return;
457
+ }
458
+ if (followRef.current.running || followFrameRef.current !== null) {
459
+ return;
460
+ }
461
+ if (isNearBottom(node) || maxScrollOf(node) <= 1) {
462
+ clearPendingReaderLeave();
463
+ return;
464
+ }
465
+ releasePinFromReader(node);
466
+ if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
467
+ olderLoadGateRef.current = "armed";
468
+ }
469
+ },
470
+ [autoFollow, clearPendingReaderLeave, releasePinFromReader],
471
+ );
472
+
473
+ const scheduleLeaveFallback = useCallback(() => {
474
+ // Prefer scrollend when the engine supports it.
475
+ if (supportsScrollEndEvent()) {
476
+ return;
477
+ }
478
+ cancelLeaveFallback();
479
+ leaveFallbackRafRef.current = requestFrame(() => {
480
+ leaveFallbackRafRef.current = null;
481
+ const current = scrollRef.current;
482
+ if (current) {
483
+ releasePinAfterScrollSettled(current);
484
+ }
485
+ });
486
+ }, [cancelLeaveFallback, releasePinAfterScrollSettled]);
487
+
404
488
  const onWheel = (event: {
405
489
  deltaY: number;
406
490
  deltaX: number;
@@ -455,14 +539,15 @@ export function MessageTimeline({
455
539
  const snapToBottom = useCallback(
456
540
  (node: HTMLElement) => {
457
541
  stopFollow();
458
- node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
542
+ cancelLeaveFallback();
543
+ writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
459
544
  syncScrollBaseline(node);
460
545
  followRef.current = {
461
546
  ...followRef.current,
462
547
  lastHeight: node.scrollHeight,
463
548
  };
464
549
  },
465
- [stopFollow, syncScrollBaseline],
550
+ [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
466
551
  );
467
552
 
468
553
  const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
@@ -472,6 +557,12 @@ export function MessageTimeline({
472
557
  stopFollow();
473
558
  return;
474
559
  }
560
+ // Reader/extension leave in flight — do not yank back before scrollend.
561
+ if (pendingReaderLeaveRef.current) {
562
+ stopFollow();
563
+ return;
564
+ }
565
+ cancelLeaveFallback();
475
566
  // Prefer the rAF timestamp so ease integrates against vsync (and tests
476
567
  // can advance a synthetic clock via requestAnimationFrame callbacks).
477
568
  const now =
@@ -493,9 +584,7 @@ export function MessageTimeline({
493
584
  node.scrollHeight,
494
585
  node.clientHeight,
495
586
  );
496
- if (node.scrollTop !== nextTop) {
497
- node.scrollTop = nextTop;
498
- }
587
+ writeScrollTop(node, nextTop);
499
588
  syncScrollBaseline(node);
500
589
  followRef.current = {
501
590
  ...followRef.current,
@@ -531,11 +620,10 @@ export function MessageTimeline({
531
620
  revealed: revealedRef.current,
532
621
  });
533
622
  followRef.current = result.state;
534
- if (node.scrollTop !== result.scrollTop) {
535
- node.scrollTop = result.scrollTop;
536
- }
623
+ writeScrollTop(node, result.scrollTop);
537
624
  syncScrollBaseline(node);
538
625
  if (result.state.running) {
626
+ cancelLeaveFallback();
539
627
  if (followFrameRef.current === null) {
540
628
  followFrameRef.current = requestFrame((frameNow) => {
541
629
  followFrameRef.current = null;
@@ -550,11 +638,12 @@ export function MessageTimeline({
550
638
  followFrameRef.current = null;
551
639
  }
552
640
  },
553
- [stopFollow, syncScrollBaseline],
641
+ [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
554
642
  );
555
643
  driveFollowRef.current = driveFollow;
556
644
 
557
645
  useEffect(() => stopFollow, [stopFollow]);
646
+ useEffect(() => () => cancelLeaveFallback(), [cancelLeaveFallback]);
558
647
 
559
648
  // The single post-commit scroll authority. Runs after EVERY commit (no dep
560
649
  // list): any commit may change content height, and the decision is cheap.
@@ -578,7 +667,7 @@ export function MessageTimeline({
578
667
  // prepend correction (it would shift the reader away from the top).
579
668
  pendingJumpToStartRef.current = false;
580
669
  stopFollow();
581
- node.scrollTop = 0;
670
+ writeScrollTop(node, 0);
582
671
  } else if (wantPinRef.current && !hasNewer) {
583
672
  // Jump-to-latest was pressed on a history window and the tip window is
584
673
  // in THIS commit — consume pre-paint so the first tip frame is already
@@ -589,8 +678,19 @@ export function MessageTimeline({
589
678
  snapToBottom(node);
590
679
  }
591
680
  } else if (autoFollow && pinnedRef.current && !hasNewer) {
592
- // Live tip: tip-follow camera eases down (snap only first paint / jumps).
593
- driveFollow(node);
681
+ // Load/remount (still hidden): hard-park. Live tip after reveal: ease.
682
+ // Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
683
+ // Flat/shrink commits (fold) still recover — height did not grow under us.
684
+ if (!revealedRef.current) {
685
+ snapToBottom(node);
686
+ } else if (pendingReaderLeaveRef.current) {
687
+ if (node.scrollHeight <= lastScrollHeightRef.current) {
688
+ clearPendingReaderLeave();
689
+ driveFollow(node);
690
+ }
691
+ } else {
692
+ driveFollow(node);
693
+ }
594
694
  } else if (prepended) {
595
695
  // Keep the reader on the same retained rows. Prefer the offsetTop delta
596
696
  // of the group that still holds the previous first item — that stays
@@ -622,7 +722,7 @@ export function MessageTimeline({
622
722
  if (delta !== null) {
623
723
  const expected = previousScrollTop + delta;
624
724
  if (Math.abs(node.scrollTop - expected) > 2) {
625
- node.scrollTop = expected;
725
+ writeScrollTop(node, expected);
626
726
  }
627
727
  }
628
728
  }
@@ -661,11 +761,46 @@ export function MessageTimeline({
661
761
  }
662
762
  groupOffsetByKeyRef.current = nextOffsetByKey;
663
763
  }
664
- if (!revealed && groups.length > 0) {
665
- setRevealed(true);
666
- }
667
764
  });
668
765
 
766
+ // First paint / session remount: keep the scroller hidden, snap to tip for
767
+ // two animation frames (late sync layout), then reveal. Does not change the
768
+ // tip-follow ease law used once `revealed` is true.
769
+ useLayoutEffect(() => {
770
+ if (revealed || allGroups.length === 0) {
771
+ return;
772
+ }
773
+ let cancelled = false;
774
+ let frame2 = 0;
775
+ const park = () => {
776
+ const node = scrollRef.current;
777
+ if (node && autoFollow && pinnedRef.current && !hasNewerRef.current) {
778
+ snapToBottom(node);
779
+ }
780
+ };
781
+ park();
782
+ const frame1 = requestFrame(() => {
783
+ if (cancelled) {
784
+ return;
785
+ }
786
+ park();
787
+ frame2 = requestFrame(() => {
788
+ if (cancelled) {
789
+ return;
790
+ }
791
+ park();
792
+ setRevealed(true);
793
+ });
794
+ });
795
+ return () => {
796
+ cancelled = true;
797
+ cancelFrame(frame1);
798
+ if (frame2 !== 0) {
799
+ cancelFrame(frame2);
800
+ }
801
+ };
802
+ }, [revealed, allGroups.length, autoFollow, snapToBottom]);
803
+
669
804
  // A cleared timeline (stream identity change) re-arms the reveal + prefetch
670
805
  // gate and returns to bottom-follow for the next session's first paint.
671
806
  useLayoutEffect(() => {
@@ -792,9 +927,15 @@ export function MessageTimeline({
792
927
  if (!current) {
793
928
  return;
794
929
  }
795
- if (autoFollow && pinnedRef.current && !hasNewerRef.current) {
796
- driveFollow(current);
930
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
931
+ return;
797
932
  }
933
+ // Still unveiling the first tip frame: hard-park (no ease settle).
934
+ if (!revealedRef.current) {
935
+ snapToBottom(current);
936
+ return;
937
+ }
938
+ driveFollow(current);
798
939
  });
799
940
  });
800
941
  observer.observe(inner);
@@ -808,7 +949,7 @@ export function MessageTimeline({
808
949
  resizeFollowRafRef.current = null;
809
950
  }
810
951
  };
811
- }, [autoFollow, driveFollow]);
952
+ }, [autoFollow, driveFollow, snapToBottom]);
812
953
 
813
954
  // Entering a non-tip history window: drop any live pin so the page bottom
814
955
  // cannot re-stick follow across loadNewer. Leaving it (the tip window
@@ -838,9 +979,9 @@ export function MessageTimeline({
838
979
  }
839
980
  }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
840
981
 
841
- // Unpin: wheel / keys always; pointer-armed geometric scroll-up only.
842
- // Mid-turn fold + narration drops scrollTop while maxScroll stays flat —
843
- // treating that as reader-up was the Jump-to-latest bug on soft-follow.
982
+ // Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
983
+ // immediately; extension jumps settle via scrollend (or one-rAF fallback).
984
+ // Do not tip-follow-yank an in-flight unarmed scroll-away that ate Vimium.
844
985
  const onScroll = () => {
845
986
  const node = scrollRef.current;
846
987
  if (!node) {
@@ -857,17 +998,34 @@ export function MessageTimeline({
857
998
  const heightShrunk =
858
999
  followRef.current.lastHeight > 0 &&
859
1000
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
1001
+ // Consume all pending camera-write echoes (browsers may coalesce writes).
1002
+ const programmatic = programmaticScrollRef.current > 0;
1003
+ if (programmatic) {
1004
+ programmaticScrollRef.current = 0;
1005
+ }
860
1006
 
861
1007
  if (autoFollow && pinnedRef.current && !hasNewer) {
862
1008
  // Fold / composer shrink: compensate before baseline sync so driveFollow
863
1009
  // still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
864
1010
  if (heightShrunk || maxFell) {
865
1011
  readerIntentArmRef.current = false;
1012
+ clearPendingReaderLeave();
866
1013
  driveFollow(node);
867
1014
  return;
868
1015
  }
1016
+ if (programmatic) {
1017
+ syncScrollBaseline(node);
1018
+ followRef.current = {
1019
+ ...followRef.current,
1020
+ lastHeight: nextHeight,
1021
+ };
1022
+ return;
1023
+ }
869
1024
  syncScrollBaseline(node);
870
1025
  const nearBottomPinned = isNearBottom(node);
1026
+ if (nearBottomPinned) {
1027
+ clearPendingReaderLeave();
1028
+ }
871
1029
  // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
872
1030
  if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
873
1031
  readerIntentArmRef.current = false;
@@ -877,9 +1035,15 @@ export function MessageTimeline({
877
1035
  }
878
1036
  return;
879
1037
  }
880
- // Growth debt / fold noise / camera echo: stay pinned and recover tip.
881
- if (!nearBottomPinned || readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
882
- driveFollow(node);
1038
+ // Tip grew under a still viewport (no reader-up): ease back to the tip.
1039
+ // Reader/extension scroll-up in progress: do not yank — scrollend decides.
1040
+ if (!nearBottomPinned && readerUp <= TIP_FOLLOW_READER_UP_EPS_PX) {
1041
+ if (!pendingReaderLeaveRef.current) {
1042
+ driveFollow(node);
1043
+ }
1044
+ } else if (!nearBottomPinned && readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
1045
+ pendingReaderLeaveRef.current = true;
1046
+ scheduleLeaveFallback();
883
1047
  } else {
884
1048
  followRef.current = {
885
1049
  ...followRef.current,
@@ -922,6 +1086,20 @@ export function MessageTimeline({
922
1086
  }
923
1087
  };
924
1088
 
1089
+ const onScrollEnd = () => {
1090
+ const node = scrollRef.current;
1091
+ if (!node) {
1092
+ return;
1093
+ }
1094
+ cancelLeaveFallback();
1095
+ if (programmaticScrollRef.current > 0) {
1096
+ programmaticScrollRef.current = 0;
1097
+ syncScrollBaseline(node);
1098
+ return;
1099
+ }
1100
+ releasePinAfterScrollSettled(node);
1101
+ };
1102
+
925
1103
  return (
926
1104
  <LightboxProvider>
927
1105
  <FoldMemoryProvider value={foldMemoryRef.current}>
@@ -935,6 +1113,7 @@ export function MessageTimeline({
935
1113
  ref={scrollRef}
936
1114
  tabIndex={-1}
937
1115
  onScroll={onScroll}
1116
+ onScrollEnd={onScrollEnd}
938
1117
  onWheel={onWheel}
939
1118
  onPointerDown={onPointerDown}
940
1119
  onKeyDown={onKeyDown}
@@ -980,21 +1159,34 @@ export function MessageTimeline({
980
1159
  : 0;
981
1160
  return (
982
1161
  <div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
983
- <TimelineGroupView
984
- group={group}
985
- renderMessageText={renderMessageText}
986
- onOpenSession={onOpenSession}
987
- onMemoryClick={onMemoryClick}
988
- onReconnect={onReconnect}
989
- resolveProviderLogo={resolveProviderLogo}
990
- toolRegistry={toolRegistry}
991
- turnSummary={turnSummary}
992
- foldLiveCluster={isAgentProgress(next)}
993
- trailingAgentText={trailingAgentTextAfterTurn(group, next)}
994
- contextCompactionCount={
995
- contextCompactionCount > 0 ? contextCompactionCount : undefined
996
- }
997
- />
1162
+ <TimelineGroupRenderBoundary
1163
+ resetKeys={[
1164
+ group,
1165
+ renderMessageText,
1166
+ onOpenSession,
1167
+ onMemoryClick,
1168
+ onReconnect,
1169
+ resolveProviderLogo,
1170
+ toolRegistry,
1171
+ turnSummary,
1172
+ ]}
1173
+ >
1174
+ <TimelineGroupView
1175
+ group={group}
1176
+ renderMessageText={renderMessageText}
1177
+ onOpenSession={onOpenSession}
1178
+ onMemoryClick={onMemoryClick}
1179
+ onReconnect={onReconnect}
1180
+ resolveProviderLogo={resolveProviderLogo}
1181
+ toolRegistry={toolRegistry}
1182
+ turnSummary={turnSummary}
1183
+ foldLiveCluster={isAgentProgress(next)}
1184
+ trailingAgentText={trailingAgentTextAfterTurn(group, next)}
1185
+ contextCompactionCount={
1186
+ contextCompactionCount > 0 ? contextCompactionCount : undefined
1187
+ }
1188
+ />
1189
+ </TimelineGroupRenderBoundary>
998
1190
  </div>
999
1191
  );
1000
1192
  })}
@@ -1242,6 +1434,75 @@ function cancelFrame(id: number): void {
1242
1434
  window.clearTimeout(id);
1243
1435
  }
1244
1436
 
1437
+ type TimelineGroupRenderBoundaryProps = {
1438
+ children: ReactNode;
1439
+ resetKeys: readonly unknown[];
1440
+ };
1441
+
1442
+ type TimelineGroupRenderBoundaryState = {
1443
+ failed: boolean;
1444
+ resetKeys: readonly unknown[];
1445
+ };
1446
+
1447
+ function timelineRenderResetKeysChanged(
1448
+ previous: readonly unknown[],
1449
+ next: readonly unknown[],
1450
+ ): boolean {
1451
+ return (
1452
+ previous.length !== next.length || previous.some((key, index) => !Object.is(key, next[index]))
1453
+ );
1454
+ }
1455
+
1456
+ /**
1457
+ * A malformed historical payload or consumer renderer must not take down the
1458
+ * entire conversation. Keep the boundary outside the row component so React
1459
+ * can replace an invalid element type (error #130) with a bounded fallback.
1460
+ */
1461
+ class TimelineGroupRenderBoundary extends Component<
1462
+ TimelineGroupRenderBoundaryProps,
1463
+ TimelineGroupRenderBoundaryState
1464
+ > {
1465
+ state: TimelineGroupRenderBoundaryState = {
1466
+ failed: false,
1467
+ resetKeys: this.props.resetKeys,
1468
+ };
1469
+
1470
+ static getDerivedStateFromError(): Partial<TimelineGroupRenderBoundaryState> {
1471
+ return { failed: true };
1472
+ }
1473
+
1474
+ static getDerivedStateFromProps(
1475
+ props: TimelineGroupRenderBoundaryProps,
1476
+ state: TimelineGroupRenderBoundaryState,
1477
+ ): Partial<TimelineGroupRenderBoundaryState> | null {
1478
+ if (timelineRenderResetKeysChanged(state.resetKeys, props.resetKeys)) {
1479
+ return { failed: false, resetKeys: props.resetKeys };
1480
+ }
1481
+ return null;
1482
+ }
1483
+
1484
+ render(): ReactNode {
1485
+ if (this.state.failed) {
1486
+ return (
1487
+ <div
1488
+ data-testid="timeline-group-render-error"
1489
+ role="status"
1490
+ className="flex items-start gap-2 rounded-lg border border-og-border bg-og-surface-muted px-3 py-2 text-sm text-og-fg-muted"
1491
+ >
1492
+ <TriangleAlertIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
1493
+ <div>
1494
+ <p className="font-medium text-og-fg">Timeline item unavailable</p>
1495
+ <p>
1496
+ This item could not be displayed. The rest of the conversation is still available.
1497
+ </p>
1498
+ </div>
1499
+ </div>
1500
+ );
1501
+ }
1502
+ return this.props.children;
1503
+ }
1504
+ }
1505
+
1245
1506
  // The full loaded window stays mounted, so settled history rows must be cheap
1246
1507
  // on every commit: projection reuses group objects for unchanged groups, so
1247
1508
  // memo skips them. Live projection creates a new group object, and
@@ -1308,7 +1569,10 @@ const TimelineGroupView = memo(function TimelineGroupView({
1308
1569
  // Latch live→folded so a top-level shell that was already mounted open can
1309
1570
  // start the settle beat without remounting bare rail → wrapper.
1310
1571
  const liveActivitySettle = useLiveSettleFold(activityShouldFold && !insideTurn);
1311
- const turnDefaultOpen = !insideTurn && group.kind === "turn" && group.outcome === "failed";
1572
+ const turnDefaultOpen =
1573
+ !insideTurn &&
1574
+ group.kind === "turn" &&
1575
+ (group.outcome === "failed" || timelineGroupContainsAuthNeeded(group));
1312
1576
  // activity-* → turn-* remount: carry resting state so settleFold does not
1313
1577
  // re-open a chip the reader already watched collapse.
1314
1578
  if (group.kind === "turn" && foldMemory && !insideTurn) {
@@ -1400,21 +1664,37 @@ const TimelineGroupView = memo(function TimelineGroupView({
1400
1664
  // more "N steps" was the redundant double fold).
1401
1665
  const nestClusters = foldableActivityClusterCount(group.groups) >= 2;
1402
1666
  const turnCopyText = collectTurnCopyText(group.groups, trailingAgentText);
1403
- const body = group.groups.map((child) => (
1404
- <TimelineGroupView
1405
- key={timelineGroupKey(child)}
1406
- group={child}
1407
- renderMessageText={renderMessageText}
1408
- onOpenSession={onOpenSession}
1409
- onMemoryClick={onMemoryClick}
1410
- onReconnect={onReconnect}
1411
- resolveProviderLogo={resolveProviderLogo}
1412
- toolRegistry={toolRegistry}
1413
- turnSummary={turnSummary}
1414
- insideTurn
1415
- nestClusterChips={nestClusters}
1416
- />
1417
- ));
1667
+ const body = group.groups.map((child) => {
1668
+ const key = timelineGroupKey(child);
1669
+ return (
1670
+ <TimelineGroupRenderBoundary
1671
+ key={key}
1672
+ resetKeys={[
1673
+ child,
1674
+ renderMessageText,
1675
+ onOpenSession,
1676
+ onMemoryClick,
1677
+ onReconnect,
1678
+ resolveProviderLogo,
1679
+ toolRegistry,
1680
+ turnSummary,
1681
+ ]}
1682
+ >
1683
+ <TimelineGroupView
1684
+ group={child}
1685
+ renderMessageText={renderMessageText}
1686
+ onOpenSession={onOpenSession}
1687
+ onMemoryClick={onMemoryClick}
1688
+ onReconnect={onReconnect}
1689
+ resolveProviderLogo={resolveProviderLogo}
1690
+ toolRegistry={toolRegistry}
1691
+ turnSummary={turnSummary}
1692
+ insideTurn
1693
+ nestClusterChips={nestClusters}
1694
+ />
1695
+ </TimelineGroupRenderBoundary>
1696
+ );
1697
+ });
1418
1698
  return (
1419
1699
  <TurnSummary
1420
1700
  items={activityItems}
@@ -1501,6 +1781,17 @@ function timelineGroupKey(group: TimelineGroup): string {
1501
1781
  }
1502
1782
  }
1503
1783
 
1784
+ function timelineGroupContainsAuthNeeded(group: TimelineGroup): boolean {
1785
+ switch (group.kind) {
1786
+ case "item":
1787
+ return group.item.kind === "auth-needed";
1788
+ case "activity":
1789
+ return false;
1790
+ case "turn":
1791
+ return group.groups.some(timelineGroupContainsAuthNeeded);
1792
+ }
1793
+ }
1794
+
1504
1795
  function timelineGroupItemIds(group: TimelineGroup): string[] {
1505
1796
  switch (group.kind) {
1506
1797
  case "item":
@@ -1787,6 +2078,22 @@ function compactionSkipSubtitle(reason: string | null): string {
1787
2078
  }
1788
2079
  }
1789
2080
 
2081
+ /** Hover-reveal clock beside the copy control (sent / finished). */
2082
+ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
2083
+ return (
2084
+ <time
2085
+ dateTime={occurredAt}
2086
+ className={cn(
2087
+ "shrink-0 tabular-nums text-og-xs text-og-fg-subtle",
2088
+ "opacity-0 transition-opacity duration-150",
2089
+ "group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-70",
2090
+ )}
2091
+ >
2092
+ {formatClockTime(occurredAt)}
2093
+ </time>
2094
+ );
2095
+ }
2096
+
1790
2097
  function UserMessageRow({
1791
2098
  item,
1792
2099
  renderMessageText,
@@ -1804,6 +2111,7 @@ function UserMessageRow({
1804
2111
  copyText={item.text}
1805
2112
  label="Copy message"
1806
2113
  className="w-fit max-w-full min-w-0"
2114
+ trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
1807
2115
  >
1808
2116
  <div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
1809
2117
  {renderMessageText ? (
@@ -1835,13 +2143,15 @@ function AgentMessageRow({
1835
2143
  ) : (
1836
2144
  <Markdown streaming={item.streaming}>{item.text}</Markdown>
1837
2145
  );
1838
- // While streaming, copy is still useful (current text) but keep chrome calm.
2146
+ // While streaming, copy is still useful (current text) but keep chrome calm
2147
+ // stamp only after the message finishes (occurredAt tracks completion).
1839
2148
  return (
1840
2149
  <CopyHoverFrame
1841
2150
  copyText={item.text}
1842
2151
  label="Copy message"
1843
- align="end"
2152
+ align="start"
1844
2153
  className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
2154
+ trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
1845
2155
  >
1846
2156
  {body}
1847
2157
  </CopyHoverFrame>
@@ -2236,7 +2546,9 @@ function AuthNeededRow({
2236
2546
  const [failed, setFailed] = useState(false);
2237
2547
  const provider = providerLabel(item.providerDomain);
2238
2548
  const unavailable =
2239
- item.reason === "unsupported_auth" || item.reason === "resource_scope_unavailable";
2549
+ item.reason === "personal_authority_unavailable" ||
2550
+ item.reason === "unsupported_auth" ||
2551
+ item.reason === "resource_scope_unavailable";
2240
2552
  const missing = item.reason === "missing_connection";
2241
2553
  const actionLabel = missing ? "Connect" : "Reconnect";
2242
2554
 
@@ -2390,6 +2702,8 @@ function authReasonLine(reason: AuthNeededItem["reason"]): string {
2390
2702
  case "expired":
2391
2703
  case "refresh_failed":
2392
2704
  return "Its access expired.";
2705
+ case "personal_authority_unavailable":
2706
+ return "This automation was not granted access to your personal connection.";
2393
2707
  case "unsupported_auth":
2394
2708
  return "This connection cannot authenticate the configured tool endpoint.";
2395
2709
  case "resource_scope_unavailable":