@opengeni/react 3.0.0-canary.0 → 3.3.2-canary.0

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 (63) hide show
  1. package/dist/accounts.js +87 -11
  2. package/dist/accounts.js.map +1 -1
  3. package/dist/{browser-viewer-AEFHUYZK.js → browser-viewer-MKKFRRRV.js} +3 -3
  4. package/dist/{chunk-W6NFJXSA.js → chunk-2DZVYHVO.js} +20 -4
  5. package/dist/chunk-2DZVYHVO.js.map +1 -0
  6. package/dist/{chunk-YJFGAFBD.js → chunk-3AGBGTRT.js} +226 -95
  7. package/dist/chunk-3AGBGTRT.js.map +1 -0
  8. package/dist/{chunk-YFKJ7VZH.js → chunk-7EAFGICR.js} +45 -11
  9. package/dist/chunk-7EAFGICR.js.map +1 -0
  10. package/dist/{chunk-MZ2G2XBA.js → chunk-IOLVN74D.js} +31 -4
  11. package/dist/chunk-IOLVN74D.js.map +1 -0
  12. package/dist/{chunk-D3UIAJKY.js → chunk-JAQ5K7W6.js} +3 -2
  13. package/dist/{chunk-D3UIAJKY.js.map → chunk-JAQ5K7W6.js.map} +1 -1
  14. package/dist/{chunk-LAKLF4PA.js → chunk-VTOREEXF.js} +8 -7
  15. package/dist/chunk-VTOREEXF.js.map +1 -0
  16. package/dist/{chunk-RGALCTWO.js → chunk-XYRDDSXF.js} +4 -4
  17. package/dist/{chunk-HQVCKCLG.js → chunk-YG4QEQF5.js} +354 -255
  18. package/dist/chunk-YG4QEQF5.js.map +1 -0
  19. package/dist/{chunk-XXWH2JLC.js → chunk-YVCHAQZY.js} +2 -2
  20. package/dist/{chunk-OAM4WAAM.js → chunk-ZPBTI3TE.js} +2 -2
  21. package/dist/components/fleet-tile.d.ts +2 -2
  22. package/dist/components/model-policy-picker.d.ts +9 -0
  23. package/dist/components/tip-follow.d.ts +16 -0
  24. package/dist/composer.js +5 -5
  25. package/dist/hooks/use-session-events.d.ts +3 -3
  26. package/dist/index.js +76 -68
  27. package/dist/index.js.map +1 -1
  28. package/dist/interaction.js +2 -2
  29. package/dist/machines.js +2 -2
  30. package/dist/realtime.js +46 -7
  31. package/dist/realtime.js.map +1 -1
  32. package/dist/session-ui.js +4 -4
  33. package/dist/session.js +4 -4
  34. package/dist/timeline/entrance.d.ts +13 -1
  35. package/package.json +3 -2
  36. package/src/accounts.tsx +116 -14
  37. package/src/components/fleet-tile.tsx +4 -15
  38. package/src/components/message-timeline.tsx +237 -101
  39. package/src/components/model-policy-picker.tsx +24 -3
  40. package/src/components/tip-follow.ts +52 -0
  41. package/src/components/tooltip.tsx +38 -5
  42. package/src/hooks/use-available-models.ts +20 -14
  43. package/src/hooks/use-composer.ts +49 -3
  44. package/src/hooks/use-goal.ts +31 -7
  45. package/src/hooks/use-last-started-turn-policy.ts +11 -5
  46. package/src/hooks/use-session-events.ts +186 -61
  47. package/src/hooks/use-session-lineage.ts +20 -16
  48. package/src/hooks/use-session.ts +23 -19
  49. package/src/hooks/use-workspace-sessions.ts +31 -27
  50. package/src/lib/format.ts +8 -6
  51. package/src/realtime/realtime-control.tsx +55 -6
  52. package/src/timeline/entrance.tsx +28 -3
  53. package/src/timeline/projection.ts +4 -0
  54. package/dist/chunk-HQVCKCLG.js.map +0 -1
  55. package/dist/chunk-LAKLF4PA.js.map +0 -1
  56. package/dist/chunk-MZ2G2XBA.js.map +0 -1
  57. package/dist/chunk-W6NFJXSA.js.map +0 -1
  58. package/dist/chunk-YFKJ7VZH.js.map +0 -1
  59. package/dist/chunk-YJFGAFBD.js.map +0 -1
  60. /package/dist/{browser-viewer-AEFHUYZK.js.map → browser-viewer-MKKFRRRV.js.map} +0 -0
  61. /package/dist/{chunk-RGALCTWO.js.map → chunk-XYRDDSXF.js.map} +0 -0
  62. /package/dist/{chunk-XXWH2JLC.js.map → chunk-YVCHAQZY.js.map} +0 -0
  63. /package/dist/{chunk-OAM4WAAM.js.map → chunk-ZPBTI3TE.js.map} +0 -0
@@ -4,6 +4,7 @@ import type {
4
4
  SessionEvent,
5
5
  SessionStatus,
6
6
  } from "@opengeni/sdk";
7
+ import { dequal } from "dequal/lite";
7
8
  import {
8
9
  ArrowDownIcon,
9
10
  ArrowRightIcon,
@@ -57,12 +58,13 @@ import {
57
58
  createTipFollowState,
58
59
  readerScrollUpPx,
59
60
  tipFollowCancel,
60
- tipFollowCompensateShrink,
61
61
  tipFollowCompensateViewportShrink,
62
+ tipFollowObserveContentShrink,
62
63
  tipFollowStep,
63
64
  supportsScrollEndEvent,
64
65
  TIP_FOLLOW_READER_UP_EPS_PX,
65
66
  TIP_FOLLOW_SHRINK_EPS_PX,
67
+ type TipFollowContentShrinkBaseline,
66
68
  type TipFollowState,
67
69
  } from "./tip-follow";
68
70
  import {
@@ -433,7 +435,6 @@ export function MessageTimeline({
433
435
  }
434
436
  return sources;
435
437
  }, [resolvedItems]);
436
- const groups = useStableTimelineGroupKeys(allGroups);
437
438
  const scrollRef = useRef<HTMLDivElement | null>(null);
438
439
  const topSentinelRef = useRef<HTMLDivElement | null>(null);
439
440
  const bottomSentinelRef = useRef<HTMLDivElement | null>(null);
@@ -506,6 +507,8 @@ export function MessageTimeline({
506
507
  * jumps (Vimium) settle via scrollend while the camera is idle.
507
508
  */
508
509
  const readerIntentArmRef = useRef(false);
510
+ /** Gesture-start geometry; cumulative tiny pointer scrolls share one budget. */
511
+ const readerIntentStartRef = useRef<{ scrollTop: number; maxScroll: number } | null>(null);
509
512
  /**
510
513
  * Count of camera/snap scrollTop writes whose scroll echoes are not yet
511
514
  * consumed. A boolean was wrong when the browser coalesced two writes into
@@ -533,7 +536,7 @@ export function MessageTimeline({
533
536
  const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
534
537
  const seenActivityIdsRef = useRef<Set<string>>(new Set());
535
538
  const firstItemGroupKeyRef = useRef<string | null>(null);
536
- const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
539
+ const firstItemGroupOffsetTopRef = useRef<number | null>(null);
537
540
  const firstItemContentTopRef = useRef<number | null>(null);
538
541
  const firstItemId = resolvedItems[0]?.id ?? null;
539
542
  // Pagination ownership follows the oldest committed input, before host
@@ -553,6 +556,7 @@ export function MessageTimeline({
553
556
  previousBulkFirstKeyRef.current !== undefined &&
554
557
  previousBulkFirstKeyRef.current !== firstGroupKey;
555
558
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
559
+ const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
556
560
 
557
561
  const applyCanSkipTipCatchup = useCallback((value: boolean) => {
558
562
  if (canSkipTipCatchupRef.current !== value) {
@@ -592,6 +596,7 @@ export function MessageTimeline({
592
596
  // Pure tip-follow camera. Pin intent uses clamp conservation, not timers.
593
597
  const followRef = useRef<TipFollowState>(createTipFollowState());
594
598
  const followFrameRef = useRef<number | null>(null);
599
+ const contentShrinkBaselineRef = useRef<TipFollowContentShrinkBaseline | null>(null);
595
600
 
596
601
  const syncScrollBaseline = useCallback((node: HTMLElement) => {
597
602
  lastScrollTopRef.current = node.scrollTop;
@@ -628,7 +633,13 @@ export function MessageTimeline({
628
633
  cancelLeaveFallback();
629
634
  }, [cancelLeaveFallback]);
630
635
 
636
+ const clearReaderIntent = useCallback(() => {
637
+ readerIntentArmRef.current = false;
638
+ readerIntentStartRef.current = null;
639
+ }, []);
640
+
631
641
  const stopFollow = useCallback(() => {
642
+ contentShrinkBaselineRef.current = null;
632
643
  followRef.current = tipFollowCancel(followRef.current);
633
644
  if (followFrameRef.current != null) {
634
645
  cancelFrame(followFrameRef.current);
@@ -646,7 +657,7 @@ export function MessageTimeline({
646
657
  if (node && maxScrollOf(node) <= 1) {
647
658
  return;
648
659
  }
649
- readerIntentArmRef.current = false;
660
+ clearReaderIntent();
650
661
  clearPendingReaderLeave();
651
662
  stopFollow();
652
663
  applyPinned(false);
@@ -658,7 +669,7 @@ export function MessageTimeline({
658
669
  setOlderPrefetchArmed(true);
659
670
  }
660
671
  },
661
- [autoFollow, applyPinned, clearPendingReaderLeave, stopFollow],
672
+ [autoFollow, applyPinned, clearPendingReaderLeave, clearReaderIntent, stopFollow],
662
673
  );
663
674
 
664
675
  /**
@@ -731,6 +742,7 @@ export function MessageTimeline({
731
742
  button: number;
732
743
  pointerType: string;
733
744
  target: EventTarget | null;
745
+ currentTarget: EventTarget | null;
734
746
  }) => {
735
747
  // Primary button / touch / pen only. Ignore right-click etc.
736
748
  if (event.button && event.pointerType === "mouse") {
@@ -745,7 +757,12 @@ export function MessageTimeline({
745
757
  return;
746
758
  }
747
759
  disclosureKeepsUnpinnedRef.current = false;
760
+ const node =
761
+ event.currentTarget instanceof HTMLElement ? event.currentTarget : scrollRef.current;
748
762
  readerIntentArmRef.current = true;
763
+ readerIntentStartRef.current = node
764
+ ? { scrollTop: node.scrollTop, maxScroll: maxScrollOf(node) }
765
+ : null;
749
766
  };
750
767
 
751
768
  const onKeyDown = (event: { key: string; currentTarget: EventTarget | null }) => {
@@ -769,6 +786,7 @@ export function MessageTimeline({
769
786
 
770
787
  const snapToBottom = useCallback(
771
788
  (node: HTMLElement) => {
789
+ clearReaderIntent();
772
790
  stopFollow();
773
791
  cancelLeaveFallback();
774
792
  writeScrollTop(node, Math.max(0, node.scrollHeight - node.clientHeight));
@@ -781,7 +799,14 @@ export function MessageTimeline({
781
799
  };
782
800
  applyCanSkipTipCatchup(false);
783
801
  },
784
- [applyCanSkipTipCatchup, cancelLeaveFallback, stopFollow, syncScrollBaseline, writeScrollTop],
802
+ [
803
+ applyCanSkipTipCatchup,
804
+ cancelLeaveFallback,
805
+ clearReaderIntent,
806
+ stopFollow,
807
+ syncScrollBaseline,
808
+ writeScrollTop,
809
+ ],
785
810
  );
786
811
 
787
812
  const beginUserMessageDisclosureChange = useCallback(
@@ -842,6 +867,36 @@ export function MessageTimeline({
842
867
  }),
843
868
  [beginUserMessageDisclosureChange],
844
869
  );
870
+ const timelineGroupEntryContext = useMemo<TimelineGroupEntryContext>(
871
+ () => ({
872
+ userMessageDisclosureContext,
873
+ behavior: {
874
+ renderMessageText,
875
+ onOpenSession,
876
+ onMemoryClick,
877
+ onReconnect,
878
+ resolveProviderLogo,
879
+ toolRegistry,
880
+ loadRetainedScreenshot,
881
+ loadRetainedArtifact,
882
+ loadVideoArtifactPlayback,
883
+ turnSummary,
884
+ },
885
+ }),
886
+ [
887
+ loadRetainedArtifact,
888
+ loadRetainedScreenshot,
889
+ loadVideoArtifactPlayback,
890
+ onMemoryClick,
891
+ onOpenSession,
892
+ onReconnect,
893
+ renderMessageText,
894
+ resolveProviderLogo,
895
+ toolRegistry,
896
+ turnSummary,
897
+ userMessageDisclosureContext,
898
+ ],
899
+ );
845
900
 
846
901
  const requestOlderIfUnderfilled = useCallback(
847
902
  (node: HTMLElement, retry?: OlderLoadAttempt) => {
@@ -919,19 +974,41 @@ export function MessageTimeline({
919
974
  : typeof performance !== "undefined"
920
975
  ? performance.now()
921
976
  : Date.now();
922
- const previousHeight = followRef.current.lastHeight;
977
+ let previousHeight = followRef.current.lastHeight;
978
+ const previousObservedHeight = lastScrollHeightRef.current;
979
+ if (
980
+ contentShrinkBaselineRef.current &&
981
+ previousObservedHeight > 0 &&
982
+ node.scrollHeight > previousObservedHeight
983
+ ) {
984
+ // A reversal ends the collapse sequence. If it remains below the held
985
+ // baseline, adopt the recovered height; if it grew beyond the baseline,
986
+ // leave that baseline for tipFollowStep to observe as real growth.
987
+ contentShrinkBaselineRef.current = null;
988
+ if (node.scrollHeight < previousHeight) {
989
+ followRef.current = {
990
+ ...followRef.current,
991
+ lastHeight: node.scrollHeight,
992
+ cameraTop: null,
993
+ };
994
+ previousHeight = node.scrollHeight;
995
+ }
996
+ }
997
+ const shrinkObservation = tipFollowObserveContentShrink(
998
+ contentShrinkBaselineRef.current,
999
+ previousHeight,
1000
+ lastScrollTopRef.current,
1001
+ node.scrollHeight,
1002
+ node.clientHeight,
1003
+ );
1004
+ contentShrinkBaselineRef.current = shrinkObservation.baseline;
923
1005
  // Settle-collapse: compensate Δh from the pre-shrink baseline (browser
924
1006
  // may already have clamped — don't double-subtract). Keep the follow rAF
925
1007
  // alive so when collapse ends (or stream resumes) we ease instead of a
926
1008
  // hard stop → flick. Do NOT tip-ease on the same frame as a real shrink
927
1009
  // (that fight was the top-of-viewport flicker).
928
- if (previousHeight > 0 && node.scrollHeight < previousHeight - TIP_FOLLOW_SHRINK_EPS_PX) {
929
- let nextTop = tipFollowCompensateShrink(
930
- lastScrollTopRef.current,
931
- previousHeight,
932
- node.scrollHeight,
933
- node.clientHeight,
934
- );
1010
+ if (shrinkObservation.compensatedScrollTop !== null) {
1011
+ let nextTop = shrinkObservation.compensatedScrollTop;
935
1012
  // A chrome/composer dock can land on the same frame as a settle-fold
936
1013
  // (the "turn blocked" moment). Compensate BOTH in one write — adopting
937
1014
  // the shrunk clientHeight below without gluing left the chrome height
@@ -967,15 +1044,6 @@ export function MessageTimeline({
967
1044
  }
968
1045
  return;
969
1046
  }
970
- // Sub-eps height noise: adopt height without moving the camera. Do NOT
971
- // adopt clientHeight here — tipFollowStep (next line) owns that baseline
972
- // and must still see a same-frame viewport shrink to glue it.
973
- if (previousHeight > 0 && node.scrollHeight < previousHeight) {
974
- followRef.current = {
975
- ...followRef.current,
976
- lastHeight: node.scrollHeight,
977
- };
978
- }
979
1047
  const result = tipFollowStep(followRef.current, {
980
1048
  scrollTop: node.scrollTop,
981
1049
  scrollHeight: node.scrollHeight,
@@ -1027,7 +1095,7 @@ export function MessageTimeline({
1027
1095
  }
1028
1096
  const previousFirstItemId = previousFirstItemIdRef.current;
1029
1097
  const previousFirstItemGroupKey = firstItemGroupKeyRef.current;
1030
- const previousGroupOffsetByKey = groupOffsetByKeyRef.current;
1098
+ const previousFirstItemGroupOffsetTop = firstItemGroupOffsetTopRef.current;
1031
1099
  const previousItemContentTop = firstItemContentTopRef.current;
1032
1100
  const previousScrollTop = lastScrollTopRef.current;
1033
1101
  const previousMaxScroll = lastMaxScrollRef.current;
@@ -1060,14 +1128,16 @@ export function MessageTimeline({
1060
1128
  }
1061
1129
  }
1062
1130
  const anchorKey = previousFirstItemGroupKey;
1063
- const previousAnchorTop =
1064
- anchorKey != null ? previousGroupOffsetByKey.get(anchorKey) : undefined;
1065
1131
  const anchorEl =
1066
1132
  anchorKey != null
1067
1133
  ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(anchorKey)}"]`)
1068
1134
  : null;
1069
- if (delta == null && anchorEl instanceof HTMLElement && previousAnchorTop !== undefined) {
1070
- const moved = Math.round(anchorEl.offsetTop - previousAnchorTop);
1135
+ if (
1136
+ delta == null &&
1137
+ anchorEl instanceof HTMLElement &&
1138
+ previousFirstItemGroupOffsetTop != null
1139
+ ) {
1140
+ const moved = Math.round(anchorEl.offsetTop - previousFirstItemGroupOffsetTop);
1071
1141
  if (moved) {
1072
1142
  delta = moved;
1073
1143
  }
@@ -1170,14 +1240,12 @@ export function MessageTimeline({
1170
1240
  hasNewer ||
1171
1241
  firstItemContentTopRef.current == null;
1172
1242
  if (needOffsets) {
1173
- const nextOffsetByKey = new Map<string, number>();
1174
- for (const { key } of groups) {
1175
- const el = node.querySelector(`[data-og-group-key="${cssEscapeAttribute(key)}"]`);
1176
- if (el instanceof HTMLElement) {
1177
- nextOffsetByKey.set(key, el.offsetTop);
1178
- }
1179
- }
1180
- groupOffsetByKeyRef.current = nextOffsetByKey;
1243
+ const committedFirstGroupKey = firstItemGroupKeyRef.current;
1244
+ const firstGroupEl = committedFirstGroupKey
1245
+ ? node.querySelector(`[data-og-group-key="${cssEscapeAttribute(committedFirstGroupKey)}"]`)
1246
+ : null;
1247
+ firstItemGroupOffsetTopRef.current =
1248
+ firstGroupEl instanceof HTMLElement ? firstGroupEl.offsetTop : null;
1181
1249
  const firstItemEl = firstItemId
1182
1250
  ? node.querySelector(`[data-og-item="${cssEscapeAttribute(firstItemId)}"]`)
1183
1251
  : null;
@@ -1279,14 +1347,16 @@ export function MessageTimeline({
1279
1347
  lastScrollHeightRef.current = 0;
1280
1348
  lastClientHeightRef.current = 0;
1281
1349
  firstItemGroupKeyRef.current = null;
1282
- groupOffsetByKeyRef.current = new Map();
1350
+ firstItemGroupOffsetTopRef.current = null;
1283
1351
  firstItemContentTopRef.current = null;
1284
1352
  foldMemoryRef.current.clear();
1285
1353
  userMessageDisclosureMemoryRef.current.clear();
1286
1354
  disclosureKeepsUnpinnedRef.current = false;
1355
+ clearReaderIntent();
1356
+ contentShrinkBaselineRef.current = null;
1287
1357
  seenActivityIdsRef.current.clear();
1288
1358
  applyPinned(true);
1289
- }, [allGroups.length, revealed, applyPinned]);
1359
+ }, [allGroups.length, revealed, applyPinned, clearReaderIntent]);
1290
1360
 
1291
1361
  // Parent commits cover the initial/history-loading cases. Disclosure state
1292
1362
  // changes are child-local, so the ResizeObserver below owns dynamic collapse
@@ -1503,6 +1573,16 @@ export function MessageTimeline({
1503
1573
  const readerUp = readerScrollUpPx(previousTop, nextTop, previousMaxScroll, nextMaxScroll);
1504
1574
  const maxFell = nextMaxScroll < previousMaxScroll - 1;
1505
1575
  const readerArmed = readerIntentArmRef.current;
1576
+ const readerIntentStart = readerIntentStartRef.current;
1577
+ const cumulativeReaderUp =
1578
+ readerArmed && readerIntentStart
1579
+ ? readerScrollUpPx(
1580
+ readerIntentStart.scrollTop,
1581
+ nextTop,
1582
+ readerIntentStart.maxScroll,
1583
+ nextMaxScroll,
1584
+ )
1585
+ : readerUp;
1506
1586
  const heightShrunk =
1507
1587
  followRef.current.lastHeight > 0 &&
1508
1588
  nextHeight < followRef.current.lastHeight - TIP_FOLLOW_SHRINK_EPS_PX;
@@ -1528,7 +1608,7 @@ export function MessageTimeline({
1528
1608
  // Fold / composer content shrink: compensate before baseline sync so
1529
1609
  // driveFollow still sees the pre-shrink scrollTop (avoid double-subtract).
1530
1610
  if (heightShrunk || maxFell || viewportShrunk) {
1531
- readerIntentArmRef.current = false;
1611
+ clearReaderIntent();
1532
1612
  clearPendingReaderLeave();
1533
1613
  driveFollow(node);
1534
1614
  return;
@@ -1550,8 +1630,8 @@ export function MessageTimeline({
1550
1630
  clearPendingReaderLeave();
1551
1631
  }
1552
1632
  // Pointer-dragged scroll-up away from tip. Layout churn never arms this.
1553
- if (readerArmed && readerUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
1554
- readerIntentArmRef.current = false;
1633
+ if (readerArmed && cumulativeReaderUp > TIP_FOLLOW_READER_UP_EPS_PX && !nearBottomPinned) {
1634
+ clearReaderIntent();
1555
1635
  releasePinFromReader(node);
1556
1636
  rearmOlderPrefetchAfterLeavingTop(node);
1557
1637
  return;
@@ -1633,7 +1713,7 @@ export function MessageTimeline({
1633
1713
  <FoldMemoryProvider value={foldMemoryRef.current}>
1634
1714
  <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
1635
1715
  <TimelineComputeLabelProvider value={computeLabel ?? null}>
1636
- <EntranceAnimationProvider value={!bulkRender}>
1716
+ <EntranceAnimationProvider value={false}>
1637
1717
  <TooltipProvider delayDuration={400}>
1638
1718
  <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
1639
1719
  {onAnnotate ? (
@@ -1686,56 +1766,19 @@ export function MessageTimeline({
1686
1766
  className="pointer-events-none absolute inset-x-0 top-0 h-px"
1687
1767
  />
1688
1768
  ) : null}
1689
- {groups.map(({ group, key }, index) => {
1690
- const next = groups[index + 1]?.group;
1691
- const contextCompactionCount =
1692
- group.kind === "turn"
1693
- ? (group.contextCompactionCount ?? 0)
1694
- : group.kind === "activity" &&
1695
- next?.kind === "item" &&
1696
- next.item.kind === "context-compaction" &&
1697
- next.item.phase === "compacted"
1698
- ? 1
1699
- : 0;
1769
+ {groups.map(({ group, key, entranceEnabled }, index) => {
1700
1770
  return (
1701
- <div key={key} data-og-timeline-group-anchor="" data-og-group-key={key}>
1702
- <TimelineGroupRenderBoundary
1703
- resetKeys={[
1704
- group,
1705
- renderMessageText,
1706
- onOpenSession,
1707
- onMemoryClick,
1708
- onReconnect,
1709
- resolveProviderLogo,
1710
- toolRegistry,
1711
- loadRetainedScreenshot,
1712
- loadRetainedArtifact,
1713
- loadVideoArtifactPlayback,
1714
- turnSummary,
1715
- ]}
1716
- >
1717
- <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
1718
- <TimelineGroupView
1719
- group={group}
1720
- renderMessageText={renderMessageText}
1721
- onOpenSession={onOpenSession}
1722
- onMemoryClick={onMemoryClick}
1723
- onReconnect={onReconnect}
1724
- resolveProviderLogo={resolveProviderLogo}
1725
- toolRegistry={toolRegistry}
1726
- loadRetainedScreenshot={loadRetainedScreenshot}
1727
- loadRetainedArtifact={loadRetainedArtifact}
1728
- loadVideoArtifactPlayback={loadVideoArtifactPlayback}
1729
- turnSummary={turnSummary}
1730
- foldLiveCluster={isAgentProgress(next)}
1731
- trailingAgentText={trailingAgentTextAfterTurn(group, next)}
1732
- contextCompactionCount={
1733
- contextCompactionCount > 0 ? contextCompactionCount : undefined
1734
- }
1735
- />
1736
- </UserMessageDisclosureProvider>
1737
- </TimelineGroupRenderBoundary>
1738
- </div>
1771
+ <TimelineGroupEntry
1772
+ key={key}
1773
+ groupKey={key}
1774
+ group={group}
1775
+ nextGroup={groups[index + 1]?.group}
1776
+ entranceEnabled={entranceEnabled}
1777
+ liveEntranceEnabled={
1778
+ group.kind === "activity" ? !bulkRender : undefined
1779
+ }
1780
+ context={timelineGroupEntryContext}
1781
+ />
1739
1782
  );
1740
1783
  })}
1741
1784
  {groups.length > 0 && trailingState ? (
@@ -1927,8 +1970,20 @@ export function MessageTimeline({
1927
1970
  type KeyedTimelineGroup = {
1928
1971
  group: TimelineGroup;
1929
1972
  key: string;
1973
+ entranceEnabled: boolean;
1930
1974
  };
1931
1975
 
1976
+ function timelineGroupsRenderEqual(previous: TimelineGroup, next: TimelineGroup): boolean {
1977
+ try {
1978
+ return dequal(previous, next);
1979
+ } catch {
1980
+ // Consumer-owned `unknown` payloads may be proxies/getters. Equality is an
1981
+ // optimization only; a hostile comparator surface must fall back to the
1982
+ // authoritative new group rather than taking down the timeline render.
1983
+ return false;
1984
+ }
1985
+ }
1986
+
1932
1987
  /**
1933
1988
  * Projection can legitimately change a group's content-derived key while
1934
1989
  * retaining its existing rows. The common pagination case is an older activity
@@ -1937,7 +1992,10 @@ type KeyedTimelineGroup = {
1937
1992
  * committed groups by their durable item IDs so both the React key and the
1938
1993
  * progressive-window anchor survive either change.
1939
1994
  */
1940
- function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGroup[] {
1995
+ function useStableTimelineGroupKeys(
1996
+ allGroups: TimelineGroup[],
1997
+ entranceEnabled: boolean,
1998
+ ): KeyedTimelineGroup[] {
1941
1999
  const previousRef = useRef<KeyedTimelineGroup[]>([]);
1942
2000
  const keyedGroups = useMemo(() => {
1943
2001
  const previousByItemId = new Map<string, KeyedTimelineGroup>();
@@ -1950,29 +2008,36 @@ function useStableTimelineGroupKeys(allGroups: TimelineGroup[]): KeyedTimelineGr
1950
2008
  const usedKeys = new Set<string>();
1951
2009
  return allGroups.map((group, index) => {
1952
2010
  const itemIds = timelineGroupItemIds(group);
1953
- let retainedKey: string | undefined;
2011
+ let retainedGroup: KeyedTimelineGroup | undefined;
1954
2012
  for (const itemId of itemIds) {
1955
2013
  const previous = previousByItemId.get(itemId);
1956
2014
  // Retain only same-kind matches. Activity → turn wrap must NOT keep the
1957
2015
  // activity chip's React key: that reused a collapsed TurnSummary and
1958
2016
  // skipped the settle beat (insta-collapse / content flash).
1959
2017
  if (previous && previous.group.kind === group.kind && !usedKeys.has(previous.key)) {
1960
- retainedKey = previous.key;
2018
+ retainedGroup = previous;
1961
2019
  break;
1962
2020
  }
1963
2021
  }
1964
2022
 
1965
2023
  const canonicalKey = timelineGroupKey(group);
1966
- let key = retainedKey ?? canonicalKey;
2024
+ let key = retainedGroup?.key ?? canonicalKey;
1967
2025
  let collision = 0;
1968
2026
  while (usedKeys.has(key)) {
1969
2027
  key = `${canonicalKey}:${index}:${collision}`;
1970
2028
  collision += 1;
1971
2029
  }
1972
2030
  usedKeys.add(key);
1973
- return { group, key };
2031
+ return {
2032
+ group:
2033
+ retainedGroup && timelineGroupsRenderEqual(retainedGroup.group, group)
2034
+ ? retainedGroup.group
2035
+ : group,
2036
+ key,
2037
+ entranceEnabled: retainedGroup?.entranceEnabled ?? entranceEnabled,
2038
+ };
1974
2039
  });
1975
- }, [allGroups]);
2040
+ }, [allGroups, entranceEnabled]);
1976
2041
 
1977
2042
  useLayoutEffect(() => {
1978
2043
  previousRef.current = keyedGroups;
@@ -2065,11 +2130,82 @@ class TimelineGroupRenderBoundary extends Component<
2065
2130
  }
2066
2131
  }
2067
2132
 
2133
+ type TimelineGroupEntryProps = {
2134
+ groupKey: string;
2135
+ group: TimelineGroup;
2136
+ nextGroup?: TimelineGroup | undefined;
2137
+ entranceEnabled: boolean;
2138
+ liveEntranceEnabled?: boolean | undefined;
2139
+ context: TimelineGroupEntryContext;
2140
+ };
2141
+
2142
+ type TimelineGroupEntryContext = {
2143
+ userMessageDisclosureContext: UserMessageDisclosureContextValue;
2144
+ behavior: TimelineGroupBehaviorProps;
2145
+ };
2146
+
2147
+ type TimelineGroupBehaviorProps = {
2148
+ renderMessageText: MessageTimelineProps["renderMessageText"];
2149
+ onOpenSession: MessageTimelineProps["onOpenSession"];
2150
+ onMemoryClick: MessageTimelineProps["onMemoryClick"];
2151
+ onReconnect: MessageTimelineProps["onReconnect"];
2152
+ resolveProviderLogo: MessageTimelineProps["resolveProviderLogo"];
2153
+ toolRegistry: ToolRegistry;
2154
+ loadRetainedScreenshot: MessageTimelineProps["loadRetainedScreenshot"];
2155
+ loadRetainedArtifact: MessageTimelineProps["loadRetainedArtifact"];
2156
+ loadVideoArtifactPlayback: MessageTimelineProps["loadVideoArtifactPlayback"];
2157
+ turnSummary: MessageTimelineProps["turnSummary"];
2158
+ };
2159
+
2160
+ /**
2161
+ * Keep the complete settled-row shell behind one shallow memo boundary. A
2162
+ * prepend still reconciles the keyed list, but render-equivalent suffix groups
2163
+ * skip their providers, error boundaries, disclosure wrappers, and row trees.
2164
+ */
2165
+ const TimelineGroupEntry = memo(function TimelineGroupEntry({
2166
+ groupKey,
2167
+ group,
2168
+ nextGroup,
2169
+ entranceEnabled,
2170
+ liveEntranceEnabled,
2171
+ context,
2172
+ }: TimelineGroupEntryProps) {
2173
+ const { behavior, userMessageDisclosureContext } = context;
2174
+ const contextCompactionCount =
2175
+ group.kind === "turn"
2176
+ ? (group.contextCompactionCount ?? 0)
2177
+ : group.kind === "activity" &&
2178
+ nextGroup?.kind === "item" &&
2179
+ nextGroup.item.kind === "context-compaction" &&
2180
+ nextGroup.item.phase === "compacted"
2181
+ ? 1
2182
+ : 0;
2183
+ return (
2184
+ <div data-og-timeline-group-anchor="" data-og-group-key={groupKey}>
2185
+ <EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
2186
+ <TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
2187
+ <UserMessageDisclosureProvider value={userMessageDisclosureContext}>
2188
+ <TimelineGroupView
2189
+ {...behavior}
2190
+ group={group}
2191
+ foldLiveCluster={isAgentProgress(nextGroup)}
2192
+ trailingAgentText={trailingAgentTextAfterTurn(group, nextGroup)}
2193
+ contextCompactionCount={
2194
+ contextCompactionCount > 0 ? contextCompactionCount : undefined
2195
+ }
2196
+ />
2197
+ </UserMessageDisclosureProvider>
2198
+ </TimelineGroupRenderBoundary>
2199
+ </EntranceAnimationProvider>
2200
+ </div>
2201
+ );
2202
+ });
2203
+
2068
2204
  // The full loaded window stays mounted, so settled history rows must be cheap
2069
- // on every commit: projection reuses group objects for unchanged groups, so
2070
- // memo skips them. Live projection creates a new group object, and
2071
- // behavior/callback changes are separate props, so ordinary streaming and host
2072
- // updates still invalidate immediately.
2205
+ // on every commit: the stable-key projection reuses render-equivalent group
2206
+ // objects, so memo skips them. Changed projection content receives a new group
2207
+ // object, and behavior/callback changes are separate props, so ordinary
2208
+ // streaming and host updates still invalidate immediately.
2073
2209
  const TimelineGroupView = memo(function TimelineGroupView({
2074
2210
  group,
2075
2211
  renderMessageText,
@@ -13,6 +13,7 @@ import { DropdownMenu } from "radix-ui";
13
13
  import {
14
14
  useEffect,
15
15
  useMemo,
16
+ useRef,
16
17
  useState,
17
18
  type CSSProperties,
18
19
  type ReactNode,
@@ -541,21 +542,41 @@ export function ModelPolicyPickerMenu(
541
542
  );
542
543
  }
543
544
 
544
- export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
545
+ /** @internal Shared state seam kept outside the Radix portal for deterministic testing. */
546
+ export function useModelPolicyPickerState(props: ModelPolicyPickerProps) {
545
547
  const [uncontrolledOpen, setUncontrolledOpen] = useState(props.defaultOpen ?? false);
546
548
  const open = props.open ?? uncontrolledOpen;
549
+ const rows = effectiveRows(props);
550
+ const [nav, setNav] = usePickerNavState(props.sessionKey, rows, props.model);
551
+ const previousControlledOpen = useRef(props.open);
552
+ useEffect(() => {
553
+ const wasOpen = previousControlledOpen.current;
554
+ previousControlledOpen.current = props.open;
555
+ if (props.open === true && wasOpen !== true) {
556
+ setNav(defaultNavState(rows, props.model));
557
+ }
558
+ // Reset only on the controlled closed-to-open edge; ordinary rerenders
559
+ // deliberately preserve the current drill-down page.
560
+ // eslint-disable-next-line react-hooks/exhaustive-deps
561
+ }, [props.open]);
547
562
  const setOpen = (next: boolean) => {
563
+ if (next) {
564
+ setNav(defaultNavState(rows, props.model));
565
+ }
548
566
  if (props.open === undefined) setUncontrolledOpen(next);
549
567
  props.onOpenChange?.(next);
550
568
  };
569
+ return { open, setOpen, rows, nav, setNav };
570
+ }
571
+
572
+ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
551
573
  const trigger = usePortalTokenSource<HTMLButtonElement>();
552
574
  const portalStyle = usePortalTokenStyle(trigger.source);
553
575
  const messages = useMemo(
554
576
  () => ({ ...defaultModelPolicyPickerMessages, ...props.messages }),
555
577
  [props.messages],
556
578
  );
557
- const rows = effectiveRows(props);
558
- const [nav, setNav] = usePickerNavState(props.sessionKey, rows, props.model);
579
+ const { open, setOpen, rows, nav, setNav } = useModelPolicyPickerState(props);
559
580
  const selected = findPickerRow(rows, props.model);
560
581
 
561
582
  if (props.loading) {