@opengeni/react 0.34.1 → 0.34.3

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 (60) hide show
  1. package/dist/{chunk-A2WITZTI.js → chunk-4NMH6UJ3.js} +2 -2
  2. package/dist/{chunk-GZ4KR67U.js → chunk-ALA3UGWB.js} +2 -2
  3. package/dist/chunk-ATSTR5P3.js +138 -0
  4. package/dist/chunk-ATSTR5P3.js.map +1 -0
  5. package/dist/{chunk-KZRUYCL4.js → chunk-FT2TXNGZ.js} +5 -2
  6. package/dist/chunk-FT2TXNGZ.js.map +1 -0
  7. package/dist/{chunk-4QOQ2DSR.js → chunk-LAKLF4PA.js} +14 -1
  8. package/dist/chunk-LAKLF4PA.js.map +1 -0
  9. package/dist/{chunk-TCCE5ZU5.js → chunk-MEGI4FS6.js} +38 -9
  10. package/dist/chunk-MEGI4FS6.js.map +1 -0
  11. package/dist/{chunk-MS3FBZ6A.js → chunk-PVW56EVR.js} +105 -62
  12. package/dist/chunk-PVW56EVR.js.map +1 -0
  13. package/dist/{chunk-L7R5RK6A.js → chunk-QRYJCWGD.js} +1199 -897
  14. package/dist/chunk-QRYJCWGD.js.map +1 -0
  15. package/dist/components/chat-composer.d.ts +2 -1
  16. package/dist/components/composer.d.ts +8 -2
  17. package/dist/components/copy-button.d.ts +4 -2
  18. package/dist/components/tip-follow.d.ts +3 -0
  19. package/dist/composer.d.ts +2 -1
  20. package/dist/composer.js +4 -3
  21. package/dist/index.d.ts +2 -2
  22. package/dist/index.js +106 -101
  23. package/dist/index.js.map +1 -1
  24. package/dist/lib/format.d.ts +2 -0
  25. package/dist/machines.js +2 -2
  26. package/dist/session-ui.js +4 -3
  27. package/dist/session.js +4 -4
  28. package/dist/timeline/index.d.ts +1 -1
  29. package/dist/timeline/parsers.d.ts +5 -0
  30. package/dist/timeline/projection.d.ts +1 -12
  31. package/dist/timeline/tool-display-name.d.ts +17 -6
  32. package/dist/workstream-control-event.d.ts +6 -0
  33. package/package.json +1 -1
  34. package/src/components/chat-composer.tsx +1 -1
  35. package/src/components/composer-transcription-control.tsx +70 -57
  36. package/src/components/composer.tsx +197 -126
  37. package/src/components/copy-button.tsx +27 -8
  38. package/src/components/message-timeline.tsx +236 -39
  39. package/src/components/tip-follow.ts +15 -0
  40. package/src/composer.ts +1 -1
  41. package/src/hooks/use-composer.ts +7 -1
  42. package/src/hooks/use-workspace-capture.ts +6 -0
  43. package/src/index.ts +2 -1
  44. package/src/lib/format.ts +14 -0
  45. package/src/timeline/index.ts +2 -0
  46. package/src/timeline/parsers.ts +18 -1
  47. package/src/timeline/projection.ts +50 -20
  48. package/src/timeline/registry.ts +15 -2
  49. package/src/timeline/shared.tsx +30 -28
  50. package/src/timeline/tool-display-name.ts +30 -11
  51. package/src/timeline/tool-renderers.tsx +347 -16
  52. package/src/timeline/turn-summary.tsx +5 -2
  53. package/src/workstream-control-event.ts +6 -0
  54. package/dist/chunk-4QOQ2DSR.js.map +0 -1
  55. package/dist/chunk-KZRUYCL4.js.map +0 -1
  56. package/dist/chunk-L7R5RK6A.js.map +0 -1
  57. package/dist/chunk-MS3FBZ6A.js.map +0 -1
  58. package/dist/chunk-TCCE5ZU5.js.map +0 -1
  59. /package/dist/{chunk-A2WITZTI.js.map → chunk-4NMH6UJ3.js.map} +0 -0
  60. /package/dist/{chunk-GZ4KR67U.js.map → chunk-ALA3UGWB.js.map} +0 -0
@@ -32,7 +32,7 @@ import {
32
32
  type ReactNode,
33
33
  } from "react";
34
34
  import { cn } from "../lib/cn";
35
- import { formatRelativeTime, truncate } from "../lib/format";
35
+ import { formatClockTime, formatRelativeTime, truncate } from "../lib/format";
36
36
  import { prefersReducedMotion } from "../lib/motion";
37
37
  import { Markdown } from "./markdown";
38
38
  import {
@@ -41,6 +41,7 @@ import {
41
41
  tipFollowCancel,
42
42
  tipFollowCompensateShrink,
43
43
  tipFollowStep,
44
+ supportsScrollEndEvent,
44
45
  TIP_FOLLOW_READER_UP_EPS_PX,
45
46
  TIP_FOLLOW_SHRINK_EPS_PX,
46
47
  type TipFollowState,
@@ -158,12 +159,16 @@ export type MessageTimelineProps = {
158
159
  * replaces was the "content is hidden, then pops in in batches" wobble.)
159
160
  *
160
161
  * Scroll invariant (tip-follow camera — see `./tip-follow.ts`):
161
- * - DOM truth mounts immediately; the camera eases down to the tip (not
162
+ * - Load/remount: hidden until tip is hard-snapped across a short settle; then
163
+ * reveal. Live tip: DOM grows immediately; the camera eases down (not
162
164
  * tip-glued feed-forward — that is the one-line yank).
163
165
  * - One continuous follow while hot (faster τ when behind); sleeps when cold.
164
166
  * - 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.
167
+ * wheel/keys/pointer-armed scroll-up, or a settled scrollend away from the
168
+ * tip while the tip-follow camera is idle (Vimium / unfocused PageUp).
169
+ * Height shrink compensates scrollTop by Δh (collapse owns motion); tip-ease
170
+ * pauses briefly so the two don't fight. Programmatic camera writes are
171
+ * tagged so their scroll echoes never count as leave.
167
172
  * - overflow-anchor off while pinned so the browser cannot instant-correct.
168
173
  * - Scrolled up → history prepends restore via the retained group anchor
169
174
  * (offsetTop delta); loadOlder can truncate the tip, so scrollHeight delta
@@ -288,9 +293,10 @@ export function MessageTimeline({
288
293
  const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
289
294
  const resizeFollowRafRef = useRef<number | null>(null);
290
295
  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.
296
+ // Content stays invisible until the tip is hard-parked across a short
297
+ // post-commit settle (two rAFs). That absorbs sync late layout while hidden
298
+ // so load/remount does not ease into the tip live tip-follow is unchanged
299
+ // once revealed. A flash of the window's TOP is still structurally impossible.
294
300
  const [revealed, setRevealed] = useState(false);
295
301
  // Mirror `pinned` into a ref, written ONLY by applyPinned, so the
296
302
  // ResizeObserver rAF (a stable closure) reads the live value and a snap can
@@ -325,11 +331,24 @@ export function MessageTimeline({
325
331
  const lastScrollHeightRef = useRef(0);
326
332
  const lastClientHeightRef = useRef(0);
327
333
  /**
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.
334
+ * Armed by pointerdown on the scroller. Immediate geometric scroll-up unpins
335
+ * while armed (scrollbar / touch drag). Wheel/keys unpin directly. Extension
336
+ * jumps (Vimium) settle via scrollend while the camera is idle.
331
337
  */
332
338
  const readerIntentArmRef = useRef(false);
339
+ /**
340
+ * Count of camera/snap scrollTop writes whose scroll echoes are not yet
341
+ * consumed. A boolean was wrong when the browser coalesced two writes into
342
+ * one scroll event (or fired two) — use a count, and clear to 0 on echo.
343
+ */
344
+ const programmaticScrollRef = useRef(0);
345
+ /**
346
+ * Unarmed scroll-away observed; waiting for scrollend (or rAF fallback).
347
+ * Blocks layout tip-follow so a stream token cannot yank before leave settles.
348
+ */
349
+ const pendingReaderLeaveRef = useRef(false);
350
+ /** Fallback leave check when `scrollend` is missing (one rAF, not a timer). */
351
+ const leaveFallbackRafRef = useRef<number | null>(null);
333
352
  // Resting fold state per durable group id (see fold-memory.ts). Outlives the
334
353
  // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
335
354
  // that already settled closed — or that the reader closed — never reopens.
@@ -369,6 +388,27 @@ export function MessageTimeline({
369
388
  lastClientHeightRef.current = node.clientHeight;
370
389
  }, []);
371
390
 
391
+ const writeScrollTop = useCallback((node: HTMLElement, top: number) => {
392
+ const next = Math.max(0, top);
393
+ if (node.scrollTop === next) {
394
+ return;
395
+ }
396
+ programmaticScrollRef.current += 1;
397
+ node.scrollTop = next;
398
+ }, []);
399
+
400
+ const cancelLeaveFallback = useCallback(() => {
401
+ if (leaveFallbackRafRef.current !== null) {
402
+ cancelFrame(leaveFallbackRafRef.current);
403
+ leaveFallbackRafRef.current = null;
404
+ }
405
+ }, []);
406
+
407
+ const clearPendingReaderLeave = useCallback(() => {
408
+ pendingReaderLeaveRef.current = false;
409
+ cancelLeaveFallback();
410
+ }, [cancelLeaveFallback]);
411
+
372
412
  const stopFollow = useCallback(() => {
373
413
  followRef.current = tipFollowCancel(followRef.current);
374
414
  if (followFrameRef.current !== null) {
@@ -377,7 +417,7 @@ export function MessageTimeline({
377
417
  }
378
418
  }, []);
379
419
 
380
- /** Reader left the tip — wheel, keyboard, or pointer-armed scroll-up. */
420
+ /** Reader left the tip — wheel, keyboard, pointer-armed scroll-up, or scrollend. */
381
421
  const releasePinFromReader = useCallback(
382
422
  (node?: HTMLElement | null) => {
383
423
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
@@ -388,6 +428,7 @@ export function MessageTimeline({
388
428
  return;
389
429
  }
390
430
  readerIntentArmRef.current = false;
431
+ clearPendingReaderLeave();
391
432
  stopFollow();
392
433
  applyPinned(false);
393
434
  if (wantPinRef.current) {
@@ -398,9 +439,51 @@ export function MessageTimeline({
398
439
  setOlderPrefetchArmed(true);
399
440
  }
400
441
  },
401
- [autoFollow, applyPinned, stopFollow],
442
+ [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
402
443
  );
403
444
 
445
+ /**
446
+ * Settled away from the tip while the camera is idle — Vimium / unfocused
447
+ * PageUp. Folds are recovered by layout tip-follow before this fires at tip.
448
+ */
449
+ const releasePinAfterScrollSettled = useCallback(
450
+ (node: HTMLElement) => {
451
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
452
+ return;
453
+ }
454
+ if (programmaticScrollRef.current > 0) {
455
+ return;
456
+ }
457
+ if (followRef.current.running || followFrameRef.current !== null) {
458
+ return;
459
+ }
460
+ if (isNearBottom(node) || maxScrollOf(node) <= 1) {
461
+ clearPendingReaderLeave();
462
+ return;
463
+ }
464
+ releasePinFromReader(node);
465
+ if (olderLoadGateRef.current === "cooling" && node.scrollTop > OLDER_PREFETCH_MARGIN_PX) {
466
+ olderLoadGateRef.current = "armed";
467
+ }
468
+ },
469
+ [autoFollow, clearPendingReaderLeave, releasePinFromReader],
470
+ );
471
+
472
+ const scheduleLeaveFallback = useCallback(() => {
473
+ // Prefer scrollend when the engine supports it.
474
+ if (supportsScrollEndEvent()) {
475
+ return;
476
+ }
477
+ cancelLeaveFallback();
478
+ leaveFallbackRafRef.current = requestFrame(() => {
479
+ leaveFallbackRafRef.current = null;
480
+ const current = scrollRef.current;
481
+ if (current) {
482
+ releasePinAfterScrollSettled(current);
483
+ }
484
+ });
485
+ }, [cancelLeaveFallback, releasePinAfterScrollSettled]);
486
+
404
487
  const onWheel = (event: {
405
488
  deltaY: number;
406
489
  deltaX: number;
@@ -455,14 +538,15 @@ export function MessageTimeline({
455
538
  const snapToBottom = useCallback(
456
539
  (node: HTMLElement) => {
457
540
  stopFollow();
458
- node.scrollTop = Math.max(0, node.scrollHeight - node.clientHeight);
541
+ cancelLeaveFallback();
542
+ writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
459
543
  syncScrollBaseline(node);
460
544
  followRef.current = {
461
545
  ...followRef.current,
462
546
  lastHeight: node.scrollHeight,
463
547
  };
464
548
  },
465
- [stopFollow, syncScrollBaseline],
549
+ [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
466
550
  );
467
551
 
468
552
  const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
@@ -472,6 +556,12 @@ export function MessageTimeline({
472
556
  stopFollow();
473
557
  return;
474
558
  }
559
+ // Reader/extension leave in flight — do not yank back before scrollend.
560
+ if (pendingReaderLeaveRef.current) {
561
+ stopFollow();
562
+ return;
563
+ }
564
+ cancelLeaveFallback();
475
565
  // Prefer the rAF timestamp so ease integrates against vsync (and tests
476
566
  // can advance a synthetic clock via requestAnimationFrame callbacks).
477
567
  const now =
@@ -493,9 +583,7 @@ export function MessageTimeline({
493
583
  node.scrollHeight,
494
584
  node.clientHeight,
495
585
  );
496
- if (node.scrollTop !== nextTop) {
497
- node.scrollTop = nextTop;
498
- }
586
+ writeScrollTop(node, nextTop);
499
587
  syncScrollBaseline(node);
500
588
  followRef.current = {
501
589
  ...followRef.current,
@@ -531,11 +619,10 @@ export function MessageTimeline({
531
619
  revealed: revealedRef.current,
532
620
  });
533
621
  followRef.current = result.state;
534
- if (node.scrollTop !== result.scrollTop) {
535
- node.scrollTop = result.scrollTop;
536
- }
622
+ writeScrollTop(node, result.scrollTop);
537
623
  syncScrollBaseline(node);
538
624
  if (result.state.running) {
625
+ cancelLeaveFallback();
539
626
  if (followFrameRef.current === null) {
540
627
  followFrameRef.current = requestFrame((frameNow) => {
541
628
  followFrameRef.current = null;
@@ -550,11 +637,12 @@ export function MessageTimeline({
550
637
  followFrameRef.current = null;
551
638
  }
552
639
  },
553
- [stopFollow, syncScrollBaseline],
640
+ [cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
554
641
  );
555
642
  driveFollowRef.current = driveFollow;
556
643
 
557
644
  useEffect(() => stopFollow, [stopFollow]);
645
+ useEffect(() => () => cancelLeaveFallback(), [cancelLeaveFallback]);
558
646
 
559
647
  // The single post-commit scroll authority. Runs after EVERY commit (no dep
560
648
  // list): any commit may change content height, and the decision is cheap.
@@ -578,7 +666,7 @@ export function MessageTimeline({
578
666
  // prepend correction (it would shift the reader away from the top).
579
667
  pendingJumpToStartRef.current = false;
580
668
  stopFollow();
581
- node.scrollTop = 0;
669
+ writeScrollTop(node, 0);
582
670
  } else if (wantPinRef.current && !hasNewer) {
583
671
  // Jump-to-latest was pressed on a history window and the tip window is
584
672
  // in THIS commit — consume pre-paint so the first tip frame is already
@@ -589,8 +677,19 @@ export function MessageTimeline({
589
677
  snapToBottom(node);
590
678
  }
591
679
  } else if (autoFollow && pinnedRef.current && !hasNewer) {
592
- // Live tip: tip-follow camera eases down (snap only first paint / jumps).
593
- driveFollow(node);
680
+ // Load/remount (still hidden): hard-park. Live tip after reveal: ease.
681
+ // Pending unarmed leave: tip *growth* must not yank (Vimium during stream).
682
+ // Flat/shrink commits (fold) still recover — height did not grow under us.
683
+ if (!revealedRef.current) {
684
+ snapToBottom(node);
685
+ } else if (pendingReaderLeaveRef.current) {
686
+ if (node.scrollHeight <= lastScrollHeightRef.current) {
687
+ clearPendingReaderLeave();
688
+ driveFollow(node);
689
+ }
690
+ } else {
691
+ driveFollow(node);
692
+ }
594
693
  } else if (prepended) {
595
694
  // Keep the reader on the same retained rows. Prefer the offsetTop delta
596
695
  // of the group that still holds the previous first item — that stays
@@ -622,7 +721,7 @@ export function MessageTimeline({
622
721
  if (delta !== null) {
623
722
  const expected = previousScrollTop + delta;
624
723
  if (Math.abs(node.scrollTop - expected) > 2) {
625
- node.scrollTop = expected;
724
+ writeScrollTop(node, expected);
626
725
  }
627
726
  }
628
727
  }
@@ -661,11 +760,46 @@ export function MessageTimeline({
661
760
  }
662
761
  groupOffsetByKeyRef.current = nextOffsetByKey;
663
762
  }
664
- if (!revealed && groups.length > 0) {
665
- setRevealed(true);
666
- }
667
763
  });
668
764
 
765
+ // First paint / session remount: keep the scroller hidden, snap to tip for
766
+ // two animation frames (late sync layout), then reveal. Does not change the
767
+ // tip-follow ease law used once `revealed` is true.
768
+ useLayoutEffect(() => {
769
+ if (revealed || allGroups.length === 0) {
770
+ return;
771
+ }
772
+ let cancelled = false;
773
+ let frame2 = 0;
774
+ const park = () => {
775
+ const node = scrollRef.current;
776
+ if (node && autoFollow && pinnedRef.current && !hasNewerRef.current) {
777
+ snapToBottom(node);
778
+ }
779
+ };
780
+ park();
781
+ const frame1 = requestFrame(() => {
782
+ if (cancelled) {
783
+ return;
784
+ }
785
+ park();
786
+ frame2 = requestFrame(() => {
787
+ if (cancelled) {
788
+ return;
789
+ }
790
+ park();
791
+ setRevealed(true);
792
+ });
793
+ });
794
+ return () => {
795
+ cancelled = true;
796
+ cancelFrame(frame1);
797
+ if (frame2 !== 0) {
798
+ cancelFrame(frame2);
799
+ }
800
+ };
801
+ }, [revealed, allGroups.length, autoFollow, snapToBottom]);
802
+
669
803
  // A cleared timeline (stream identity change) re-arms the reveal + prefetch
670
804
  // gate and returns to bottom-follow for the next session's first paint.
671
805
  useLayoutEffect(() => {
@@ -792,9 +926,15 @@ export function MessageTimeline({
792
926
  if (!current) {
793
927
  return;
794
928
  }
795
- if (autoFollow && pinnedRef.current && !hasNewerRef.current) {
796
- driveFollow(current);
929
+ if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
930
+ return;
931
+ }
932
+ // Still unveiling the first tip frame: hard-park (no ease settle).
933
+ if (!revealedRef.current) {
934
+ snapToBottom(current);
935
+ return;
797
936
  }
937
+ driveFollow(current);
798
938
  });
799
939
  });
800
940
  observer.observe(inner);
@@ -808,7 +948,7 @@ export function MessageTimeline({
808
948
  resizeFollowRafRef.current = null;
809
949
  }
810
950
  };
811
- }, [autoFollow, driveFollow]);
951
+ }, [autoFollow, driveFollow, snapToBottom]);
812
952
 
813
953
  // Entering a non-tip history window: drop any live pin so the page bottom
814
954
  // cannot re-stick follow across loadNewer. Leaving it (the tip window
@@ -838,9 +978,9 @@ export function MessageTimeline({
838
978
  }
839
979
  }, [hasNewer, autoFollow, applyPinned, snapToBottom, stopFollow]);
840
980
 
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.
981
+ // Pinned: layout/camera recover tip debt; wheel/keys/pointer-arm unpin
982
+ // immediately; extension jumps settle via scrollend (or one-rAF fallback).
983
+ // Do not tip-follow-yank an in-flight unarmed scroll-away that ate Vimium.
844
984
  const onScroll = () => {
845
985
  const node = scrollRef.current;
846
986
  if (!node) {
@@ -857,17 +997,34 @@ export function MessageTimeline({
857
997
  const heightShrunk =
858
998
  followRef.current.lastHeight > 0 &&
859
999
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
1000
+ // Consume all pending camera-write echoes (browsers may coalesce writes).
1001
+ const programmatic = programmaticScrollRef.current > 0;
1002
+ if (programmatic) {
1003
+ programmaticScrollRef.current = 0;
1004
+ }
860
1005
 
861
1006
  if (autoFollow && pinnedRef.current && !hasNewer) {
862
1007
  // Fold / composer shrink: compensate before baseline sync so driveFollow
863
1008
  // still sees the pre-shrink scrollTop (avoid double-subtract after clamp).
864
1009
  if (heightShrunk || maxFell) {
865
1010
  readerIntentArmRef.current = false;
1011
+ clearPendingReaderLeave();
866
1012
  driveFollow(node);
867
1013
  return;
868
1014
  }
1015
+ if (programmatic) {
1016
+ syncScrollBaseline(node);
1017
+ followRef.current = {
1018
+ ...followRef.current,
1019
+ lastHeight: nextHeight,
1020
+ };
1021
+ return;
1022
+ }
869
1023
  syncScrollBaseline(node);
870
1024
  const nearBottomPinned = isNearBottom(node);
1025
+ if (nearBottomPinned) {
1026
+ clearPendingReaderLeave();
1027
+ }
871
1028
  // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
872
1029
  if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
873
1030
  readerIntentArmRef.current = false;
@@ -877,9 +1034,15 @@ export function MessageTimeline({
877
1034
  }
878
1035
  return;
879
1036
  }
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);
1037
+ // Tip grew under a still viewport (no reader-up): ease back to the tip.
1038
+ // Reader/extension scroll-up in progress: do not yank — scrollend decides.
1039
+ if (!nearBottomPinned && readerUp <= TIP_FOLLOW_READER_UP_EPS_PX) {
1040
+ if (!pendingReaderLeaveRef.current) {
1041
+ driveFollow(node);
1042
+ }
1043
+ } else if (!nearBottomPinned && readerUp > TIP_FOLLOW_READER_UP_EPS_PX) {
1044
+ pendingReaderLeaveRef.current = true;
1045
+ scheduleLeaveFallback();
883
1046
  } else {
884
1047
  followRef.current = {
885
1048
  ...followRef.current,
@@ -922,6 +1085,20 @@ export function MessageTimeline({
922
1085
  }
923
1086
  };
924
1087
 
1088
+ const onScrollEnd = () => {
1089
+ const node = scrollRef.current;
1090
+ if (!node) {
1091
+ return;
1092
+ }
1093
+ cancelLeaveFallback();
1094
+ if (programmaticScrollRef.current > 0) {
1095
+ programmaticScrollRef.current = 0;
1096
+ syncScrollBaseline(node);
1097
+ return;
1098
+ }
1099
+ releasePinAfterScrollSettled(node);
1100
+ };
1101
+
925
1102
  return (
926
1103
  <LightboxProvider>
927
1104
  <FoldMemoryProvider value={foldMemoryRef.current}>
@@ -935,6 +1112,7 @@ export function MessageTimeline({
935
1112
  ref={scrollRef}
936
1113
  tabIndex={-1}
937
1114
  onScroll={onScroll}
1115
+ onScrollEnd={onScrollEnd}
938
1116
  onWheel={onWheel}
939
1117
  onPointerDown={onPointerDown}
940
1118
  onKeyDown={onKeyDown}
@@ -1787,6 +1965,22 @@ function compactionSkipSubtitle(reason: string | null): string {
1787
1965
  }
1788
1966
  }
1789
1967
 
1968
+ /** Hover-reveal clock beside the copy control (sent / finished). */
1969
+ function MessageFooterTime({ occurredAt }: { occurredAt: string }) {
1970
+ return (
1971
+ <time
1972
+ dateTime={occurredAt}
1973
+ className={cn(
1974
+ "shrink-0 tabular-nums text-og-xs text-og-fg-subtle",
1975
+ "opacity-0 transition-opacity duration-150",
1976
+ "group-hover/copy:opacity-100 group-focus-within/copy:opacity-100 pointer-coarse:opacity-70",
1977
+ )}
1978
+ >
1979
+ {formatClockTime(occurredAt)}
1980
+ </time>
1981
+ );
1982
+ }
1983
+
1790
1984
  function UserMessageRow({
1791
1985
  item,
1792
1986
  renderMessageText,
@@ -1804,6 +1998,7 @@ function UserMessageRow({
1804
1998
  copyText={item.text}
1805
1999
  label="Copy message"
1806
2000
  className="w-fit max-w-full min-w-0"
2001
+ trailing={<MessageFooterTime occurredAt={item.occurredAt} />}
1807
2002
  >
1808
2003
  <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
2004
  {renderMessageText ? (
@@ -1835,13 +2030,15 @@ function AgentMessageRow({
1835
2030
  ) : (
1836
2031
  <Markdown streaming={item.streaming}>{item.text}</Markdown>
1837
2032
  );
1838
- // While streaming, copy is still useful (current text) but keep chrome calm.
2033
+ // While streaming, copy is still useful (current text) but keep chrome calm
2034
+ // stamp only after the message finishes (occurredAt tracks completion).
1839
2035
  return (
1840
2036
  <CopyHoverFrame
1841
2037
  copyText={item.text}
1842
2038
  label="Copy message"
1843
- align="end"
2039
+ align="start"
1844
2040
  className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}
2041
+ trailing={item.streaming ? null : <MessageFooterTime occurredAt={item.occurredAt} />}
1845
2042
  >
1846
2043
  {body}
1847
2044
  </CopyHoverFrame>
@@ -81,6 +81,21 @@ export const TIP_FOLLOW_VELOCITY_ARM_DEBT_PX = 72;
81
81
  export const TIP_FOLLOW_VELOCITY_DECAY_MS = _t(180);
82
82
  /** Reader-up pixels above clamp budget that count as leaving the tip. */
83
83
  export const TIP_FOLLOW_READER_UP_EPS_PX = 2;
84
+
85
+ /** Test-only override for scrollend feature detection (`null` = probe DOM). */
86
+ let scrollEndSupportOverride: boolean | null = null;
87
+
88
+ /** @internal */ export function setScrollEndSupportForTests(value: boolean | null): void {
89
+ scrollEndSupportOverride = value;
90
+ }
91
+
92
+ /** True when the engine exposes element `scrollend` (prefer over rAF leave). */
93
+ export function supportsScrollEndEvent(): boolean {
94
+ if (scrollEndSupportOverride !== null) {
95
+ return scrollEndSupportOverride;
96
+ }
97
+ return typeof HTMLElement !== "undefined" && "onscrollend" in HTMLElement.prototype;
98
+ }
84
99
  /** @deprecated Shrink lock removed; kept so old imports do not break. */
85
100
  export const TIP_FOLLOW_SHRINK_LOCK_MS = 0;
86
101
  /** @deprecated Absorb thresholds removed — growth track is continuous. */
package/src/composer.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * `import * as Composer from "@opengeni/react/composer"`.
5
5
  */
6
+ export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
6
7
  export {
7
8
  Actions,
8
9
  AttachButton,
@@ -16,7 +17,6 @@ export {
16
17
  Hint,
17
18
  Input,
18
19
  ModelPicker,
19
- OPEN_WORKSTREAM_CONTROL_EVENT,
20
20
  PauseButton,
21
21
  PausedState,
22
22
  RestoredResources,
@@ -420,7 +420,13 @@ export function useComposer(
420
420
  localSignatureAtStart === null
421
421
  ? localAtStart !== 0
422
422
  : localSignatureAtStart !== lastSavedSignature.current;
423
- setDraftLoading(true);
423
+ // Only blank the picker on first hydrate / hard reload. Reconcile and
424
+ // event-triggered soft reloads (loadOlder SSE reconnect) must not flicker
425
+ // draftLoading — stale-while-revalidate keeps the settled UI mounted.
426
+ const showLoading = replaceLocal || draftRef.current === null;
427
+ if (showLoading) {
428
+ setDraftLoading(true);
429
+ }
424
430
  try {
425
431
  const fetched = await client.getComposerDraft(workspaceId, sessionId);
426
432
  if (
@@ -315,6 +315,12 @@ export function useWorkspaceCapture(
315
315
  if (!enabled) return;
316
316
  if (visibleAnnouncedRevision === null) return;
317
317
  if (loadedRevision !== null && visibleAnnouncedRevision <= loadedRevision) return;
318
+ // The mount request is already resolving the latest revision. Do not abort
319
+ // and replace that exact identity-equivalent read merely because the event
320
+ // tail announced a revision before the response arrived. When the request
321
+ // settles, its state update re-runs this effect and a genuinely newer
322
+ // announcement still refreshes immediately.
323
+ if (requestAbortRef.current) return;
318
324
  // A newer capture exists than the one we hold (or we hold none) — pull it.
319
325
  void refresh();
320
326
  }, [enabled, visibleAnnouncedRevision, loadedRevision, refresh]);
package/src/index.ts CHANGED
@@ -100,7 +100,7 @@ export type {
100
100
  SessionChromeSignalTone,
101
101
  } from "./components/session-chrome";
102
102
  export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./components/tooltip";
103
- export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./components/chat-composer";
103
+ export { OPEN_WORKSTREAM_CONTROL_EVENT } from "./workstream-control-event";
104
104
  export { useGoal, isGoalEvent } from "./hooks/use-goal";
105
105
  export type { UseGoalOptions, UseGoalResult } from "./hooks/use-goal";
106
106
  export { useSessionControl } from "./hooks/use-session-control";
@@ -486,6 +486,7 @@ export { cn } from "./lib/cn";
486
486
  export {
487
487
  CREDIT_EXHAUSTION_MESSAGE,
488
488
  formatBytes,
489
+ formatClockTime,
489
490
  formatRelativeTime,
490
491
  humanizeFailureReason,
491
492
  isCreditExhaustion,
package/src/lib/format.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { OpenGeniApiError } from "@opengeni/sdk";
2
2
 
3
+ /** Local finished-at stamp for message footers: "Aug 2, 3:42 PM" / locale-equivalent. */
4
+ export function formatClockTime(iso: string): string {
5
+ const then = new Date(iso);
6
+ if (Number.isNaN(then.getTime())) {
7
+ return "";
8
+ }
9
+ return then.toLocaleString(undefined, {
10
+ month: "short",
11
+ day: "numeric",
12
+ hour: "numeric",
13
+ minute: "2-digit",
14
+ });
15
+ }
16
+
3
17
  /** Compact relative time: "now", "42s", "7m", "3h", "2d", then a date. */
4
18
  export function formatRelativeTime(iso: string, now: Date = new Date()): string {
5
19
  const then = new Date(iso).getTime();
@@ -22,7 +22,9 @@ export {
22
22
  extractSessionRef,
23
23
  groupTimeline,
24
24
  sessionStatusFromEvents,
25
+ mcpToolLeaf,
25
26
  toolDisplayName,
27
+ toolMatchesLeaf,
26
28
  } from "./projection";
27
29
 
28
30
  // item types
@@ -227,6 +227,19 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
227
227
  return r.operation ? [r.operation] : [];
228
228
  }
229
229
 
230
+ /** Ops from provider `raw`, or from function-tool arguments when raw is empty. */
231
+ export function applyPatchOpsFromToolItem(item: {
232
+ raw: unknown;
233
+ arguments: unknown;
234
+ }): ApplyPatchOperation[] {
235
+ const fromRaw = applyPatchOps(item.raw);
236
+ if (fromRaw.length > 0) {
237
+ return fromRaw;
238
+ }
239
+ const args = parseToolArgs(item.arguments);
240
+ return applyPatchOps(args);
241
+ }
242
+
230
243
  /**
231
244
  * True when a tool item is an `apply_patch_call` — by its provider-native
232
245
  * `raw.type` (the live-wire source of truth) or by tool `name` (first-party
@@ -235,7 +248,11 @@ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
235
248
  export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
236
249
  const type =
237
250
  item.raw && typeof item.raw === "object" ? (item.raw as { type?: unknown }).type : undefined;
238
- return type === "apply_patch_call" || item.name === "apply_patch_call";
251
+ if (type === "apply_patch_call") {
252
+ return true;
253
+ }
254
+ const name = item.name;
255
+ return name === "apply_patch_call" || name === "apply_patch" || name.endsWith("__apply_patch");
239
256
  }
240
257
 
241
258
  /* --- secret redaction ------------------------------------------------------- */