@opengeni/react 2.2.0-canary.0 → 2.3.0-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.
@@ -371,6 +371,11 @@ export function MessageTimeline({
371
371
  // leaves that band (scrolls down / sentinel exits) — never from scrolling
372
372
  // further toward y=0 (that was the batch-top load loop).
373
373
  const olderLoadGateRef = useRef<"armed" | "cooling">("armed");
374
+ // A collapsed history tail can be shorter than the viewport. In that state
375
+ // there is no upward scroll range, so reader intent can never arm the top
376
+ // sentinel. Request one older page per loaded window until history either
377
+ // fills the viewport or the host reports that no older rows remain.
378
+ const underfillLoadWindowRef = useRef<string | null>(null);
374
379
  const resizeFollowRafRef = useRef<number | null>(null);
375
380
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
376
381
  // Content stays invisible until the tip is hard-parked across a short
@@ -447,6 +452,8 @@ export function MessageTimeline({
447
452
  const groupKeyByItemIdRef = useRef<Map<string, string>>(new Map());
448
453
  const groupOffsetByKeyRef = useRef<Map<string, number>>(new Map());
449
454
  const firstItemId = resolvedItems[0]?.id ?? null;
455
+ const lastItemId = resolvedItems.at(-1)?.id ?? null;
456
+ const loadedWindowKey = JSON.stringify([firstItemId, lastItemId, resolvedItems.length]);
450
457
  // Bulk paints (the initial tail window, a prepended older window — detected
451
458
  // by the first group key changing) must not run per-row entrance animations.
452
459
  const firstKeyChangedForBulk =
@@ -722,6 +729,39 @@ export function MessageTimeline({
722
729
  [beginUserMessageDisclosureChange],
723
730
  );
724
731
 
732
+ const requestOlderIfUnderfilled = useCallback(
733
+ (node: HTMLElement) => {
734
+ if (
735
+ underfillLoadWindowRef.current !== null &&
736
+ underfillLoadWindowRef.current !== loadedWindowKey
737
+ ) {
738
+ // The requested page landed. Ordinary sentinel prefetch may own the
739
+ // next approach to the top once this window has a usable scroll range.
740
+ olderLoadGateRef.current = "armed";
741
+ }
742
+ // clientHeight=0 is pre-layout/headless, not evidence that the rendered
743
+ // history underfills a real viewport.
744
+ if (
745
+ node.clientHeight <= 1 ||
746
+ maxScrollOf(node) > 1 ||
747
+ !hasOlder ||
748
+ loadingOlder ||
749
+ !onLoadOlder ||
750
+ underfillLoadWindowRef.current === loadedWindowKey
751
+ ) {
752
+ return;
753
+ }
754
+ underfillLoadWindowRef.current = loadedWindowKey;
755
+ // If the reader had already armed ordinary prefetch, do not let its
756
+ // sentinel duplicate this request while the same short page is loading.
757
+ if (olderPrefetchArmedRef.current) {
758
+ olderLoadGateRef.current = "cooling";
759
+ }
760
+ onLoadOlder();
761
+ },
762
+ [hasOlder, loadedWindowKey, loadingOlder, onLoadOlder],
763
+ );
764
+
725
765
  const driveFollowRef = useRef<(node: HTMLElement, now?: number) => void>(() => undefined);
726
766
  const driveFollow = useCallback(
727
767
  (node: HTMLElement, nowMs?: number) => {
@@ -1018,10 +1058,21 @@ export function MessageTimeline({
1018
1058
  foldMemoryRef.current.clear();
1019
1059
  userMessageDisclosureMemoryRef.current.clear();
1020
1060
  disclosureKeepsUnpinnedRef.current = false;
1061
+ underfillLoadWindowRef.current = null;
1021
1062
  seenActivityIdsRef.current.clear();
1022
1063
  applyPinned(true);
1023
1064
  }, [allGroups.length, revealed, applyPinned]);
1024
1065
 
1066
+ // Parent commits cover the initial/history-loading cases. Disclosure state
1067
+ // changes are child-local, so the ResizeObserver below owns dynamic collapse
1068
+ // and expansion after mount.
1069
+ useEffect(() => {
1070
+ const node = scrollRef.current;
1071
+ if (node) {
1072
+ requestOlderIfUnderfilled(node);
1073
+ }
1074
+ });
1075
+
1025
1076
  // Clear the bulk-paint marker a frame after it renders, so rows appended
1026
1077
  // live (streams, new turns) animate exactly as before.
1027
1078
  useLayoutEffect(() => {
@@ -1119,6 +1170,7 @@ export function MessageTimeline({
1119
1170
  if (!current) {
1120
1171
  return;
1121
1172
  }
1173
+ requestOlderIfUnderfilled(current);
1122
1174
  if (!autoFollow || !pinnedRef.current || hasNewerRef.current) {
1123
1175
  return;
1124
1176
  }
@@ -1141,7 +1193,7 @@ export function MessageTimeline({
1141
1193
  resizeFollowRafRef.current = null;
1142
1194
  }
1143
1195
  };
1144
- }, [autoFollow, driveFollow, snapToBottom]);
1196
+ }, [autoFollow, driveFollow, requestOlderIfUnderfilled, snapToBottom]);
1145
1197
 
1146
1198
  // Entering a non-tip history window: drop any live pin so the page bottom
1147
1199
  // cannot re-stick follow across loadNewer. Leaving it (the tip window
@@ -397,6 +397,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
397
397
 
398
398
  case "agent.message.completed": {
399
399
  const text = stringValue(payload.text);
400
+ const phase = assistantMessagePhase(payload.phase);
400
401
  // Reconcile the most recent same-turn agent message — even when
401
402
  // activity (tool calls, reasoning) landed after its deltas — so the
402
403
  // completed text never duplicates the streamed one.
@@ -420,6 +421,9 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
420
421
  open.text = text || open.text;
421
422
  }
422
423
  open.streaming = false;
424
+ // A phase-less settlement mirror (the worker's final-output receipt)
425
+ // must not erase the provider-declared phase captured by the SDK item.
426
+ if (phase) open.phase = phase;
423
427
  // Completion time is what the footer shows ("finished at"); keep the
424
428
  // first-delta stamp only until this event arrives.
425
429
  open.occurredAt = event.occurredAt;
@@ -451,6 +455,7 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
451
455
  id: event.id,
452
456
  turnId,
453
457
  text,
458
+ ...(phase ? { phase } : {}),
454
459
  streaming: false,
455
460
  occurredAt: event.occurredAt,
456
461
  annotationSource: {
@@ -1675,7 +1680,12 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
1675
1680
  }
1676
1681
 
1677
1682
  const finalMessage = extractFinalAgentMessage(collected, turnEnd);
1678
- const body = finalMessage ? collected.slice(0, -1) : collected;
1683
+ const fallbackMessage =
1684
+ finalMessage || hasOrdinaryFinalAgentMessage(collected, turnEnd)
1685
+ ? null
1686
+ : extractLatestCompletedCommentary(collected, turnEnd);
1687
+ const visibleMessage = finalMessage ?? fallbackMessage;
1688
+ const body = visibleMessage ? collected.filter((group) => group !== visibleMessage) : collected;
1679
1689
  if (body.length === 0) {
1680
1690
  return;
1681
1691
  }
@@ -1704,7 +1714,7 @@ function foldSettledTurn(groups: TimelineGroup[], turnEnd: TurnEndItem): void {
1704
1714
  groups.splice(
1705
1715
  startIndex,
1706
1716
  collected.length,
1707
- ...(finalMessage ? [turnGroup, finalMessage] : [turnGroup]),
1717
+ ...(visibleMessage ? [turnGroup, visibleMessage] : [turnGroup]),
1708
1718
  );
1709
1719
  }
1710
1720
 
@@ -1761,6 +1771,43 @@ function extractFinalAgentMessage(
1761
1771
  return tail;
1762
1772
  }
1763
1773
 
1774
+ function hasOrdinaryFinalAgentMessage(groups: TimelineGroup[], turnEnd: TurnEndItem): boolean {
1775
+ return groups.some((group) => {
1776
+ if (group.kind !== "item" || group.item.kind !== "agent-message") return false;
1777
+ const message = group.item;
1778
+ return (
1779
+ !message.streaming &&
1780
+ message.phase !== "commentary" &&
1781
+ message.text.trim().length > 0 &&
1782
+ belongsToTurn(message, turnEnd.turnId)
1783
+ );
1784
+ });
1785
+ }
1786
+
1787
+ function extractLatestCompletedCommentary(
1788
+ groups: TimelineGroup[],
1789
+ turnEnd: TurnEndItem,
1790
+ ): Extract<TimelineGroup, { kind: "item" }> | null {
1791
+ for (let index = groups.length - 1; index >= 0; index -= 1) {
1792
+ const group = groups[index];
1793
+ if (group?.kind !== "item" || group.item.kind !== "agent-message") continue;
1794
+ const message = group.item;
1795
+ if (
1796
+ !message.streaming &&
1797
+ message.phase === "commentary" &&
1798
+ message.text.trim().length > 0 &&
1799
+ belongsToTurn(message, turnEnd.turnId)
1800
+ ) {
1801
+ return group;
1802
+ }
1803
+ }
1804
+ return null;
1805
+ }
1806
+
1807
+ function belongsToTurn(message: AgentMessageItem, turnId: string | null): boolean {
1808
+ return !message.turnId || !turnId || message.turnId === turnId;
1809
+ }
1810
+
1764
1811
  function groupStartedAt(group: TimelineGroup | undefined): string | undefined {
1765
1812
  if (!group) {
1766
1813
  return undefined;
@@ -1783,6 +1830,10 @@ function stringValue(value: unknown): string {
1783
1830
  return typeof value === "string" ? value : "";
1784
1831
  }
1785
1832
 
1833
+ function assistantMessagePhase(value: unknown): AgentMessageItem["phase"] {
1834
+ return value === "commentary" || value === "final_answer" ? value : undefined;
1835
+ }
1836
+
1786
1837
  function numberOrNull(value: unknown): number | null {
1787
1838
  return typeof value === "number" && Number.isFinite(value) ? value : null;
1788
1839
  }
@@ -88,6 +88,8 @@ export type AgentMessageItem = {
88
88
  id: string;
89
89
  turnId: string | null;
90
90
  text: string;
91
+ /** Provider-declared assistant channel. Absent on legacy and non-Responses events. */
92
+ phase?: "commentary" | "final_answer" | undefined;
91
93
  /** Still receiving deltas (no completed/turn-end seen yet). */
92
94
  streaming: boolean;
93
95
  occurredAt: string;