@opengeni/react 1.0.2-canary.37191796234001 → 1.0.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 (75) hide show
  1. package/README.md +15 -6
  2. package/dist/artifacts.js +1 -1
  3. package/dist/{browser-viewer-GTR5XQ7X.js → browser-viewer-SHYKQRNX.js} +4 -4
  4. package/dist/{chunk-CR7U7HAR.js → chunk-26ZNDDLZ.js} +3 -3
  5. package/dist/{chunk-ELCE5SXV.js → chunk-3CTRFMAK.js} +2 -2
  6. package/dist/{chunk-XOAMRDDK.js → chunk-742CZ3L3.js} +2 -2
  7. package/dist/{chunk-H2NJQORO.js → chunk-DO544OSV.js} +7 -1
  8. package/dist/chunk-DO544OSV.js.map +1 -0
  9. package/dist/{chunk-IQUCRAT7.js → chunk-EESD3PY3.js} +2 -2
  10. package/dist/{chunk-23J2ZNXT.js → chunk-EN3YTHKR.js} +3 -3
  11. package/dist/{chunk-JFNEV4LU.js → chunk-GYVRFZDY.js} +3 -3
  12. package/dist/{chunk-YWFG7ZX7.js → chunk-HDJWDK7W.js} +3 -3
  13. package/dist/{chunk-NZUI76EM.js → chunk-J6DBS4T3.js} +2 -2
  14. package/dist/{chunk-SJZDKVW2.js → chunk-JIQYUGVL.js} +4 -4
  15. package/dist/{chunk-XUW2UWQM.js → chunk-RLUSC2WA.js} +3 -3
  16. package/dist/{chunk-XUW2UWQM.js.map → chunk-RLUSC2WA.js.map} +1 -1
  17. package/dist/{chunk-Q5QBEZIQ.js → chunk-RSI7XYXL.js} +2 -2
  18. package/dist/{chunk-NEXLLTP4.js → chunk-TKOTKRWY.js} +4 -4
  19. package/dist/{chunk-NEXLLTP4.js.map → chunk-TKOTKRWY.js.map} +1 -1
  20. package/dist/{chunk-TMJRG4YC.js → chunk-VV5E43LD.js} +432 -180
  21. package/dist/chunk-VV5E43LD.js.map +1 -0
  22. package/dist/{chunk-FFA2OVOV.js → chunk-WGXVEJO5.js} +3 -3
  23. package/dist/{chunk-E3G7SAYZ.js → chunk-YM2DE7BQ.js} +2 -2
  24. package/dist/components/open-geni-chat.d.ts +6 -3
  25. package/dist/components/session-conversation.d.ts +6 -3
  26. package/dist/components/session-proxy-scope.d.ts +21 -0
  27. package/dist/composer.js +6 -6
  28. package/dist/{computer-viewer-ENHVAZ2Q.js → computer-viewer-6SRGWRUX.js} +4 -4
  29. package/dist/index.js +17 -17
  30. package/dist/index.js.map +1 -1
  31. package/dist/interaction.js +4 -4
  32. package/dist/{latest-question-755OW5DS.js → latest-question-HTRIS4KJ.js} +2 -2
  33. package/dist/lib/host-theme.d.ts +7 -0
  34. package/dist/machines.js +3 -3
  35. package/dist/{model-policy-picker-menu-PWRI7R2X.js → model-policy-picker-menu-M54ISBMN.js} +4 -4
  36. package/dist/model-policy.js +1 -1
  37. package/dist/realtime.js +3 -3
  38. package/dist/{sandbox-files-BBC47SLD.js → sandbox-files-GORZOUUJ.js} +4 -4
  39. package/dist/session-ui.js +9 -9
  40. package/dist/session.js +5 -5
  41. package/dist/timeline/turn-summary.d.ts +3 -1
  42. package/dist/timeline/types.d.ts +6 -0
  43. package/dist/usage.js +1 -1
  44. package/package.json +3 -3
  45. package/src/components/message-timeline.tsx +704 -450
  46. package/src/components/open-geni-chat.tsx +56 -37
  47. package/src/components/session-conversation.tsx +87 -73
  48. package/src/components/session-proxy-scope.tsx +92 -0
  49. package/src/hooks/use-session-background-commands.ts +2 -2
  50. package/src/hooks/use-video-artifact-playback.ts +1 -1
  51. package/src/lib/host-theme.ts +15 -2
  52. package/src/model-policy.ts +2 -2
  53. package/src/session-context.ts +3 -3
  54. package/src/timeline/projection.ts +9 -0
  55. package/src/timeline/turn-summary.tsx +10 -0
  56. package/src/timeline/types.ts +6 -0
  57. package/dist/chunk-H2NJQORO.js.map +0 -1
  58. package/dist/chunk-TMJRG4YC.js.map +0 -1
  59. /package/dist/{browser-viewer-GTR5XQ7X.js.map → browser-viewer-SHYKQRNX.js.map} +0 -0
  60. /package/dist/{chunk-CR7U7HAR.js.map → chunk-26ZNDDLZ.js.map} +0 -0
  61. /package/dist/{chunk-ELCE5SXV.js.map → chunk-3CTRFMAK.js.map} +0 -0
  62. /package/dist/{chunk-XOAMRDDK.js.map → chunk-742CZ3L3.js.map} +0 -0
  63. /package/dist/{chunk-IQUCRAT7.js.map → chunk-EESD3PY3.js.map} +0 -0
  64. /package/dist/{chunk-23J2ZNXT.js.map → chunk-EN3YTHKR.js.map} +0 -0
  65. /package/dist/{chunk-JFNEV4LU.js.map → chunk-GYVRFZDY.js.map} +0 -0
  66. /package/dist/{chunk-YWFG7ZX7.js.map → chunk-HDJWDK7W.js.map} +0 -0
  67. /package/dist/{chunk-NZUI76EM.js.map → chunk-J6DBS4T3.js.map} +0 -0
  68. /package/dist/{chunk-SJZDKVW2.js.map → chunk-JIQYUGVL.js.map} +0 -0
  69. /package/dist/{chunk-Q5QBEZIQ.js.map → chunk-RSI7XYXL.js.map} +0 -0
  70. /package/dist/{chunk-FFA2OVOV.js.map → chunk-WGXVEJO5.js.map} +0 -0
  71. /package/dist/{chunk-E3G7SAYZ.js.map → chunk-YM2DE7BQ.js.map} +0 -0
  72. /package/dist/{computer-viewer-ENHVAZ2Q.js.map → computer-viewer-6SRGWRUX.js.map} +0 -0
  73. /package/dist/{latest-question-755OW5DS.js.map → latest-question-HTRIS4KJ.js.map} +0 -0
  74. /package/dist/{model-policy-picker-menu-PWRI7R2X.js.map → model-policy-picker-menu-M54ISBMN.js.map} +0 -0
  75. /package/dist/{sandbox-files-BBC47SLD.js.map → sandbox-files-GORZOUUJ.js.map} +0 -0
@@ -46,9 +46,11 @@ import { Collapsible } from "radix-ui";
46
46
  import {
47
47
  Component,
48
48
  Suspense,
49
+ createContext,
49
50
  lazy,
50
51
  memo,
51
52
  useCallback,
53
+ useContext,
52
54
  useEffect,
53
55
  useLayoutEffect,
54
56
  useMemo,
@@ -371,6 +373,14 @@ const WAITING_PILL_CLASS =
371
373
  "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting";
372
374
  const LOADING_CHIP_CLASS =
373
375
  "pointer-events-none inline-flex items-center rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1 text-og-control font-medium shadow-og-md backdrop-blur";
376
+ /** How long live progress notes take to fold into (or out of) their work row. */
377
+ const LIVE_NOTE_FOLD_MS = 220;
378
+ /** The timeline list's `gap-5`; a folded note gives it back with `-mt-5`. */
379
+ const LIVE_NOTE_GAP_PX = 20;
380
+ /** Longest a bottom-space loan waits for an opening work list to grow into it. */
381
+ const LIVE_NOTE_RESERVE_MS = 700;
382
+ /** Lets a live work disclosure report whether it is open (see `reportLiveFold`). */
383
+ const LiveNoteFoldContext = createContext<((workId: string, open: boolean) => void) | null>(null);
374
384
 
375
385
  // State: 0 underfill retry, 1 prefetch pending, 2 compatibility-settled,
376
386
  // 3 explicit no-progress waiting to become an underfill retry.
@@ -853,6 +863,191 @@ export function MessageTimeline({
853
863
  // deliberate chip remounts (activity→turn wrap, nested key flips) so a fold
854
864
  // that already settled closed — or that the reader closed — never reopens.
855
865
  const foldMemoryRef = useRef<Map<string, FoldRestingState>>(new Map());
866
+ // Live work disclosures that are open. While open, that turn's progress
867
+ // notes read inside the disclosure, so their copies above it fold away.
868
+ const [openLiveFolds, setOpenLiveFolds] = useState<ReadonlySet<string>>(() => new Set());
869
+ const openLiveFoldsRef = useRef<ReadonlySet<string>>(openLiveFolds);
870
+ const liveNoteIdsByWorkRef = useRef<ReadonlyMap<string, readonly string[]>>(new Map());
871
+ const liveFoldMotionRef = useRef<{ frame: number | null; finish: () => void } | null>(null);
872
+ // The bottom-space loan currently on the scroller, by owner.
873
+ const liveFoldReserveRef = useRef<object | null>(null);
874
+ // writeScrollTop is declared further down; the fold reaches it through this.
875
+ const writeScrollTopRef = useRef<(node: HTMLElement, top: number) => void>(() => {});
876
+ const reportLiveFold = useCallback((workId: string, open: boolean) => {
877
+ const current = openLiveFoldsRef.current;
878
+ if (current.has(workId) === open) return;
879
+ const next = new Set(current);
880
+ if (open) next.add(workId);
881
+ else next.delete(workId);
882
+ openLiveFoldsRef.current = next;
883
+ liveFoldMotionRef.current?.finish();
884
+ const scroller = scrollRef.current;
885
+ const rows = (liveNoteIdsByWorkRef.current.get(workId) ?? []).flatMap((id) => {
886
+ const row = scroller?.querySelector<HTMLElement>(
887
+ `[data-og-live-note-id="${cssEscapeAttribute(id)}"]`,
888
+ );
889
+ return row ? [row] : [];
890
+ });
891
+ if (!scroller || rows.length === 0) {
892
+ setOpenLiveFolds(next);
893
+ return;
894
+ }
895
+ const header = scroller.querySelector<HTMLElement>(
896
+ `[data-og-work-id="${cssEscapeAttribute(workId)}"] [data-og-work-header="outer"]`,
897
+ );
898
+ const viewTop = scroller.getBoundingClientRect().top;
899
+ const headerTop = header?.getBoundingClientRect().top;
900
+ if (
901
+ !open &&
902
+ header &&
903
+ headerTop !== undefined &&
904
+ (headerTop < viewTop || headerTop > viewTop + scroller.clientHeight)
905
+ ) {
906
+ // Closed from the row's sticky header after reading down the list: the
907
+ // row is no longer where the reader is. Like any big collapse, land on
908
+ // it in one step — the notes return above and the closed row rests at
909
+ // the bottom edge — rather than animating content the reader can't see.
910
+ setOpenLiveFolds(next);
911
+ requestFrame(() =>
912
+ requestFrame(() => {
913
+ const row = header.closest<HTMLElement>("[data-og-timeline-group-anchor]");
914
+ if (!row?.isConnected) return;
915
+ const box = row.getBoundingClientRect();
916
+ const top = scroller.getBoundingClientRect().top;
917
+ const bottom = top + scroller.clientHeight;
918
+ // + the scroller's pb-6, as at the true end.
919
+ if (box.bottom > bottom || box.top < top)
920
+ writeScrollTopRef.current(scroller, scroller.scrollTop + box.bottom - bottom + 24);
921
+ scheduleQuestionNavRef.current();
922
+ }),
923
+ );
924
+ return;
925
+ }
926
+ // Opening a live row near the bottom drops its one-line preview while its
927
+ // list grows from nothing; closing shrinks the list before the notes have
928
+ // grown back. For that moment the page is shorter than where the reader
929
+ // is, so the browser would clamp the scroll and everything would drop.
930
+ // This runs before that frame paints: keep the reader's position and lend
931
+ // one screen of space at the bottom (the shortfall keeps changing through
932
+ // the commit) until the content has grown back into it.
933
+ const readerTop = lastScrollTopRef.current;
934
+ const reserve = readerTop > 0.5 ? scroller.clientHeight : 0;
935
+ const loan = {};
936
+ if (reserve > 0) {
937
+ liveFoldReserveRef.current = loan;
938
+ scroller.style.paddingBottom = `calc(1.5rem + ${reserve}px)`;
939
+ if (Math.abs(scroller.scrollTop - readerTop) > 0.5)
940
+ writeScrollTopRef.current(scroller, readerTop);
941
+ }
942
+ const releaseReserve = (deadline: number) => {
943
+ // Only this motion's loan; a newer toggle may have replaced it.
944
+ if (reserve === 0 || liveFoldReserveRef.current !== loan) return;
945
+ const needed = scroller.scrollTop + scroller.clientHeight - (scroller.scrollHeight - reserve);
946
+ if (needed > 0.5 && performance.now() < deadline && scroller.isConnected) {
947
+ requestFrame(() => releaseReserve(deadline));
948
+ return;
949
+ }
950
+ liveFoldReserveRef.current = null;
951
+ scroller.style.removeProperty("padding-bottom");
952
+ };
953
+ // The notes fold into (or out of) the work row in one short motion. The
954
+ // row glides to where it should rest: opening, into the first note's place
955
+ // (or the top edge, when that note was above view) so its list starts just
956
+ // beneath; closing, down below its returning notes but never past the
957
+ // bottom edge. When everything is in view that is exactly what happens on
958
+ // its own; otherwise the scroll is written in the same frame as the
959
+ // heights, so it cannot lag. Native scroll anchoring is suspended for the
960
+ // motion; inline styles pin the current look across the state commit, end
961
+ // exactly at the classes' resting geometry, and are then removed.
962
+ const workRow = header?.closest<HTMLElement>("[data-og-timeline-group-anchor]") ?? null;
963
+ const rowStart = workRow?.getBoundingClientRect().top;
964
+ const viewBottom = viewTop + scroller.clientHeight;
965
+ let landing =
966
+ open && rowStart !== undefined
967
+ ? Math.max(rows[0]!.getBoundingClientRect().top, viewTop)
968
+ : null;
969
+ const heights = rows.map((row) => (open ? row.offsetHeight : 0));
970
+ const apply = (progress: number) => {
971
+ const shown = open ? 1 - progress : progress;
972
+ rows.forEach((note, index) => {
973
+ // The list gap travels inside the row as padding (margin cancels it),
974
+ // so a shrinking row never overlaps the one above it mid-motion.
975
+ note.style.marginTop = `${-LIVE_NOTE_GAP_PX}px`;
976
+ note.style.paddingTop = `${LIVE_NOTE_GAP_PX * shown}px`;
977
+ note.style.height = `${(heights[index]! + LIVE_NOTE_GAP_PX) * shown}px`;
978
+ note.style.opacity = String(shown);
979
+ });
980
+ if (workRow && rowStart !== undefined && landing !== null) {
981
+ const drift =
982
+ workRow.getBoundingClientRect().top - (rowStart + (landing - rowStart) * progress);
983
+ if (Math.abs(drift) > 0.5) writeScrollTopRef.current(scroller, scroller.scrollTop + drift);
984
+ }
985
+ };
986
+ for (const row of rows) {
987
+ row.style.boxSizing = "border-box";
988
+ row.style.overflow = "hidden";
989
+ row.style.overflowAnchor = "none";
990
+ }
991
+ scroller.style.overflowAnchor = "none";
992
+ apply(0);
993
+ const fold: { frame: number | null; finish: () => void } = {
994
+ frame: null,
995
+ finish: () => {
996
+ if (fold.frame != null) cancelFrame(fold.frame);
997
+ for (const row of rows) {
998
+ for (const property of [
999
+ "height",
1000
+ "margin-top",
1001
+ "padding-top",
1002
+ "opacity",
1003
+ "overflow",
1004
+ "overflow-anchor",
1005
+ "box-sizing",
1006
+ ])
1007
+ row.style.removeProperty(property);
1008
+ }
1009
+ scroller.style.removeProperty("overflow-anchor");
1010
+ releaseReserve(performance.now() + LIVE_NOTE_RESERVE_MS);
1011
+ // The row may now be pinned to the top without any scroll: re-place
1012
+ // the up arrow (it sits below a pinned work header).
1013
+ scheduleQuestionNavRef.current();
1014
+ if (liveFoldMotionRef.current === fold) liveFoldMotionRef.current = null;
1015
+ },
1016
+ };
1017
+ liveFoldMotionRef.current = fold;
1018
+ const duration = prefersReducedMotion() ? 0 : LIVE_NOTE_FOLD_MS;
1019
+ let began: number | null = null;
1020
+ const step = (now: number) => {
1021
+ fold.frame = null;
1022
+ if (!rows.every((row) => row.isConnected)) return fold.finish();
1023
+ if (!open && began === null) {
1024
+ // Unfolding: the natural heights are known only once the folded
1025
+ // class is gone; the inline styles still hold them at zero.
1026
+ rows.forEach((note, index) => (heights[index] = note.scrollHeight));
1027
+ if (workRow && rowStart !== undefined) {
1028
+ const returning = heights.reduce((sum, height) => sum + height + LIVE_NOTE_GAP_PX, 0);
1029
+ // The closed row is its header plus one preview line (its list is
1030
+ // still collapsing now, so the row's own height is not final yet).
1031
+ const closedRow = 2 * (header?.getBoundingClientRect().height ?? 0);
1032
+ const lowest = viewBottom - closedRow - 24;
1033
+ landing = Math.min(rowStart + returning, Math.max(lowest, rowStart));
1034
+ }
1035
+ }
1036
+ began ??= now;
1037
+ const linear = duration === 0 ? 1 : Math.min(1, (now - began) / duration);
1038
+ apply(1 - (1 - linear) ** 3);
1039
+ if (linear < 1) fold.frame = requestFrame(step);
1040
+ else fold.finish();
1041
+ };
1042
+ fold.frame = requestFrame(step);
1043
+ setOpenLiveFolds(next);
1044
+ }, []);
1045
+ useEffect(
1046
+ () => () => {
1047
+ liveFoldMotionRef.current?.finish();
1048
+ },
1049
+ [],
1050
+ );
856
1051
  const userMessageDisclosureMemoryRef = useRef<Map<string, boolean>>(new Map());
857
1052
  const seenActivityIdsRef = useRef<Set<string>>(new Set());
858
1053
  const firstItemGroupKeyRef = useRef<string | null>(null);
@@ -877,6 +1072,20 @@ export function MessageTimeline({
877
1072
  previousBulkFirstKeyRef.current !== firstGroupKey;
878
1073
  const bulkRender = allGroups.length > 0 && (bulkActive || firstKeyChangedForBulk);
879
1074
  const groups = useStableTimelineGroupKeys(allGroups, !bulkRender);
1075
+ // Each live progress note → whether its turn's work disclosure is open.
1076
+ const [liveNoteFolds, liveNoteIdsByWork] = useMemo(() => {
1077
+ const folds = new Map<string, boolean>();
1078
+ const byWork = new Map<string, readonly string[]>();
1079
+ for (const { group } of groups) {
1080
+ if (group.kind !== "activity" || !group.work?.liveNoteIds) continue;
1081
+ byWork.set(group.id, group.work.liveNoteIds);
1082
+ for (const id of group.work.liveNoteIds) folds.set(id, openLiveFolds.has(group.id));
1083
+ }
1084
+ return [folds, byWork] as const;
1085
+ }, [groups, openLiveFolds]);
1086
+ useLayoutEffect(() => {
1087
+ liveNoteIdsByWorkRef.current = liveNoteIdsByWork;
1088
+ }, [liveNoteIdsByWork]);
880
1089
  const turnsWithOutput = useMemo(
881
1090
  () =>
882
1091
  new Set(
@@ -957,6 +1166,7 @@ export function MessageTimeline({
957
1166
  programmaticScrollRef.current -= 1;
958
1167
  }
959
1168
  }, []);
1169
+ writeScrollTopRef.current = writeScrollTop;
960
1170
 
961
1171
  const cancelLeaveFallback = useCallback(() => {
962
1172
  if (leaveFallbackRafRef.current != null) {
@@ -2294,439 +2504,452 @@ export function MessageTimeline({
2294
2504
  >
2295
2505
  <LightboxProvider>
2296
2506
  <FoldMemoryProvider value={foldMemoryRef.current}>
2297
- <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2298
- <TimelineComputeLabelProvider value={computeLabel ?? null}>
2299
- <EntranceAnimationProvider value={false}>
2300
- <TooltipProvider delayDuration={400}>
2301
- <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2302
- <div
2303
- className={cn("og-root relative flex min-h-0 flex-col", className)}
2304
- // Sticky insets start at the scroller's padded content edge
2305
- // (pt-16). Subtract exactly that padding so an expanded work
2306
- // header pins flush to the scrollport. Pinning it lower left a
2307
- // band of scrolling rows visible above the header, which then
2308
- // looked like it floated over the middle of the timeline.
2309
- style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2310
- >
2311
- {onAnnotate ? (
2312
- <Suspense fallback={null}>
2313
- <TimelineAnnotationSelection
2314
- rootRef={scrollRef}
2315
- sources={annotationSources}
2316
- onAnnotate={onAnnotate}
2317
- />
2318
- </Suspense>
2319
- ) : null}
2320
- {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2321
- Unpinned: native scroll anchoring holds the reader's place. */}
2507
+ <LiveNoteFoldContext.Provider value={reportLiveFold}>
2508
+ <SeenActivityIdsProvider value={seenActivityIdsRef.current}>
2509
+ <TimelineComputeLabelProvider value={computeLabel ?? null}>
2510
+ <EntranceAnimationProvider value={false}>
2511
+ <TooltipProvider delayDuration={400}>
2512
+ <TimelineAnnotationSourceRootContext.Provider value={scrollRef}>
2322
2513
  <div
2323
- ref={scrollRef}
2324
- data-og-timeline-scroller=""
2325
- data-og-bottom-follow={autoFollow && pinned && !hasNewer ? "true" : "false"}
2326
- tabIndex={-1}
2327
- onScroll={onScroll}
2328
- onScrollEnd={onScrollEnd}
2329
- onWheel={onWheel}
2330
- onTouchStart={(event) => {
2331
- stopSettlement();
2332
- const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2333
- touchPositionRef.current = touch
2334
- ? { x: touch.clientX, y: touch.clientY }
2335
- : null;
2336
- }}
2337
- onTouchMove={(event) => {
2338
- const touch = event.touches[0];
2339
- const previous = touchPositionRef.current;
2340
- if (!touch || !previous || event.touches.length !== 1) {
2341
- touchPositionRef.current = null;
2342
- return;
2514
+ className={cn("og-root relative flex min-h-0 flex-col", className)}
2515
+ // Sticky insets start at the scroller's padded content edge
2516
+ // (pt-16). Subtract exactly that padding so an expanded work
2517
+ // header pins flush to the scrollport. Pinning it lower left a
2518
+ // band of scrolling rows visible above the header, which then
2519
+ // looked like it floated over the middle of the timeline.
2520
+ style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2521
+ >
2522
+ {onAnnotate ? (
2523
+ <Suspense fallback={null}>
2524
+ <TimelineAnnotationSelection
2525
+ rootRef={scrollRef}
2526
+ sources={annotationSources}
2527
+ onAnnotate={onAnnotate}
2528
+ />
2529
+ </Suspense>
2530
+ ) : null}
2531
+ {/* Pinned: anchoring off so the tip-follow camera owns the motion.
2532
+ Unpinned: native scroll anchoring holds the reader's place. */}
2533
+ <div
2534
+ ref={scrollRef}
2535
+ data-og-timeline-scroller=""
2536
+ data-og-bottom-follow={
2537
+ autoFollow && pinned && !hasNewer ? "true" : "false"
2343
2538
  }
2344
- const deltaX = previous.x - touch.clientX;
2345
- const deltaY = previous.y - touch.clientY;
2346
- if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2347
- touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2348
- onWheel({
2349
- deltaX,
2350
- deltaY,
2351
- target: event.target,
2352
- currentTarget: event.currentTarget,
2353
- });
2354
- }}
2355
- onTouchEnd={() => {
2356
- touchPositionRef.current = null;
2357
- }}
2358
- onTouchCancel={() => {
2359
- touchPositionRef.current = null;
2360
- }}
2361
- onClickCapture={(event) => {
2362
- const target =
2363
- event.target instanceof Element
2364
- ? event.target.closest("button[aria-expanded]")
2539
+ tabIndex={-1}
2540
+ onScroll={onScroll}
2541
+ onScrollEnd={onScrollEnd}
2542
+ onWheel={onWheel}
2543
+ onTouchStart={(event) => {
2544
+ stopSettlement();
2545
+ const touch = event.touches.length === 1 ? event.touches[0] : undefined;
2546
+ touchPositionRef.current = touch
2547
+ ? { x: touch.clientX, y: touch.clientY }
2365
2548
  : null;
2366
- if (target) {
2367
- if (target.hasAttribute("data-og-work-header")) {
2368
- releaseProgress(
2369
- target
2370
- .closest("[data-og-group-key]")
2371
- ?.getAttribute("data-og-group-key") ?? null,
2372
- );
2549
+ }}
2550
+ onTouchMove={(event) => {
2551
+ const touch = event.touches[0];
2552
+ const previous = touchPositionRef.current;
2553
+ if (!touch || !previous || event.touches.length !== 1) {
2554
+ touchPositionRef.current = null;
2555
+ return;
2373
2556
  }
2374
- releasePinFromReader();
2375
- disclosureKeepsUnpinnedRef.current = true;
2376
- }
2377
- }}
2378
- onFocusCapture={(event) => {
2379
- if (readableTurns && event.target !== event.currentTarget) {
2380
- stopSettlement();
2381
- releasePinFromReader();
2382
- disclosureKeepsUnpinnedRef.current = true;
2383
- }
2384
- }}
2385
- onPointerDown={onPointerDown}
2386
- onKeyDown={onKeyDown}
2387
- style={
2388
- groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2389
- }
2390
- className={cn(
2391
- // tabIndex=-1 is programmatic only — never paint a focus ring on
2392
- // the whole scroller (click + Shift used to flash a blue outline).
2393
- "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2394
- autoFollow && pinned && !hasNewer
2395
- ? "[overflow-anchor:none]"
2396
- : "[overflow-anchor:auto]",
2397
- )}
2398
- >
2399
- <TimelineBeforeLayout
2400
- capture={() => {
2401
- if (readableTurns && timelineHasReader(scrollRef.current)) {
2557
+ const deltaX = previous.x - touch.clientX;
2558
+ const deltaY = previous.y - touch.clientY;
2559
+ if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4) return;
2560
+ touchPositionRef.current = { x: touch.clientX, y: touch.clientY };
2561
+ onWheel({
2562
+ deltaX,
2563
+ deltaY,
2564
+ target: event.target,
2565
+ currentTarget: event.currentTarget,
2566
+ });
2567
+ }}
2568
+ onTouchEnd={() => {
2569
+ touchPositionRef.current = null;
2570
+ }}
2571
+ onTouchCancel={() => {
2572
+ touchPositionRef.current = null;
2573
+ }}
2574
+ onClickCapture={(event) => {
2575
+ const target =
2576
+ event.target instanceof Element
2577
+ ? event.target.closest("button[aria-expanded]")
2578
+ : null;
2579
+ if (target) {
2580
+ if (target.hasAttribute("data-og-work-header")) {
2581
+ releaseProgress(
2582
+ target
2583
+ .closest("[data-og-group-key]")
2584
+ ?.getAttribute("data-og-group-key") ?? null,
2585
+ );
2586
+ }
2402
2587
  releasePinFromReader();
2588
+ disclosureKeepsUnpinnedRef.current = true;
2589
+ }
2590
+ }}
2591
+ onFocusCapture={(event) => {
2592
+ if (readableTurns && event.target !== event.currentTarget) {
2593
+ stopSettlement();
2594
+ releasePinFromReader();
2595
+ disclosureKeepsUnpinnedRef.current = true;
2403
2596
  }
2404
- readingAnchorRef.current =
2405
- !pinnedRef.current && scrollRef.current
2406
- ? captureTimelineAnchor(scrollRef.current)
2407
- : null;
2408
- settlementRef.current =
2409
- readableTurns &&
2410
- autoFollow &&
2411
- pinnedRef.current &&
2412
- revealedRef.current &&
2413
- !hasNewer &&
2414
- !pendingReaderLeaveRef.current &&
2415
- !disclosureKeepsUnpinnedRef.current &&
2416
- !prefersReducedMotion() &&
2417
- scrollRef.current
2418
- ? captureTimelineSettlement(scrollRef.current, groups)
2419
- : null;
2420
2597
  }}
2598
+ onPointerDown={onPointerDown}
2599
+ onKeyDown={onKeyDown}
2600
+ style={
2601
+ groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined
2602
+ }
2603
+ className={cn(
2604
+ // tabIndex=-1 is programmatic only — never paint a focus ring on
2605
+ // the whole scroller (click + Shift used to flash a blue outline).
2606
+ "min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 pt-16 pb-6 sm:px-6 outline-hidden",
2607
+ autoFollow && pinned && !hasNewer
2608
+ ? "[overflow-anchor:none]"
2609
+ : "[overflow-anchor:auto]",
2610
+ )}
2421
2611
  >
2422
- <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2423
- <AnimatePresence>
2424
- {loadingOlder ||
2425
- loadingOldest ||
2426
- (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2427
- underfillRetryReady ? (
2428
- // Reserved top gutter: controls scroll with history and cannot
2429
- // cover a disclosure. Visibility never changes content height.
2430
- <motion.div
2431
- initial={{ opacity: 0, y: -6 }}
2432
- animate={{ opacity: 1, y: 0 }}
2433
- exit={{ opacity: 0, y: -6 }}
2434
- transition={{ duration: 0.15, ease: "easeOut" }}
2435
- data-og-loading-older=""
2436
- aria-live="polite"
2437
- className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2438
- >
2439
- {loadingOlder || loadingOldest ? (
2440
- <span className={LOADING_CHIP_CLASS}>
2441
- <span className="og-shimmer-text">
2442
- {loadingOldest
2443
- ? "Jumping to start…"
2444
- : "Loading earlier activity…"}
2612
+ <TimelineBeforeLayout
2613
+ capture={() => {
2614
+ if (readableTurns && timelineHasReader(scrollRef.current)) {
2615
+ releasePinFromReader();
2616
+ }
2617
+ readingAnchorRef.current =
2618
+ !pinnedRef.current && scrollRef.current
2619
+ ? captureTimelineAnchor(scrollRef.current)
2620
+ : null;
2621
+ settlementRef.current =
2622
+ readableTurns &&
2623
+ autoFollow &&
2624
+ pinnedRef.current &&
2625
+ revealedRef.current &&
2626
+ !hasNewer &&
2627
+ !pendingReaderLeaveRef.current &&
2628
+ !disclosureKeepsUnpinnedRef.current &&
2629
+ !prefersReducedMotion() &&
2630
+ scrollRef.current
2631
+ ? captureTimelineSettlement(scrollRef.current, groups)
2632
+ : null;
2633
+ }}
2634
+ >
2635
+ <div className="relative mx-auto flex w-full max-w-3xl flex-col gap-5">
2636
+ <AnimatePresence>
2637
+ {loadingOlder ||
2638
+ loadingOldest ||
2639
+ (hasOlder && onJumpToStart && olderPrefetchArmed) ||
2640
+ underfillRetryReady ? (
2641
+ // Reserved top gutter: controls scroll with history and cannot
2642
+ // cover a disclosure. Visibility never changes content height.
2643
+ <motion.div
2644
+ initial={{ opacity: 0, y: -6 }}
2645
+ animate={{ opacity: 1, y: 0 }}
2646
+ exit={{ opacity: 0, y: -6 }}
2647
+ transition={{ duration: 0.15, ease: "easeOut" }}
2648
+ data-og-loading-older=""
2649
+ aria-live="polite"
2650
+ className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2651
+ >
2652
+ {loadingOlder || loadingOldest ? (
2653
+ <span className={LOADING_CHIP_CLASS}>
2654
+ <span className="og-shimmer-text">
2655
+ {loadingOldest
2656
+ ? "Jumping to start…"
2657
+ : "Loading earlier activity…"}
2658
+ </span>
2445
2659
  </span>
2446
- </span>
2447
- ) : null}
2448
- {hasOlder &&
2449
- !loadingOlder &&
2450
- !loadingOldest &&
2451
- (underfillRetryReady || onJumpToStart) ? (
2452
- <button
2453
- type="button"
2454
- data-og-retry={underfillRetryReady || undefined}
2455
- data-og-jump-to-start={!underfillRetryReady || undefined}
2456
- onClick={() => {
2457
- const node = scrollRef.current;
2458
- if (underfillRetryReady) {
2459
- if (node && underfillRetryReadyRef.current) {
2460
- // AnimatePresence retains this handler during
2461
- // exit. Current authorization plus the exact
2462
- // attempt check prevent stale dispatch.
2463
- requestOlderIfUnderfilled(
2464
- node,
2465
- underfillSettledAttempt!,
2466
- );
2467
- }
2468
- return;
2469
- }
2470
- applyPinned(false);
2471
- pendingJumpToStartRef.current = true;
2472
- const seq = ++jumpToStartSeqRef.current;
2473
- void Promise.resolve(onJumpToStart!()).then(
2474
- () => {
2475
- // The commit that swaps in the oldest window consumes
2476
- // the flag against the new DOM; this write covers the
2477
- // already-committed order and the no-window-change
2478
- // case (jumping within the current window).
2479
- const scroller = scrollRef.current ?? node;
2480
- if (scroller) {
2481
- scroller.scrollTop = 0;
2660
+ ) : null}
2661
+ {hasOlder &&
2662
+ !loadingOlder &&
2663
+ !loadingOldest &&
2664
+ (underfillRetryReady || onJumpToStart) ? (
2665
+ <button
2666
+ type="button"
2667
+ data-og-retry={underfillRetryReady || undefined}
2668
+ data-og-jump-to-start={!underfillRetryReady || undefined}
2669
+ onClick={() => {
2670
+ const node = scrollRef.current;
2671
+ if (underfillRetryReady) {
2672
+ if (node && underfillRetryReadyRef.current) {
2673
+ // AnimatePresence retains this handler during
2674
+ // exit. Current authorization plus the exact
2675
+ // attempt check prevent stale dispatch.
2676
+ requestOlderIfUnderfilled(
2677
+ node,
2678
+ underfillSettledAttempt!,
2679
+ );
2482
2680
  }
2483
- // A host may resolve without ever changing the
2484
- // window (already on the oldest page). Any swap
2485
- // commit runs its layout effect before the next
2486
- // frame, so a flag still armed by then is the
2487
- // no-change case — clear it, or a LATER prepend
2488
- // would spuriously jump the reader to the top.
2489
- requestFrame(() => {
2681
+ return;
2682
+ }
2683
+ applyPinned(false);
2684
+ pendingJumpToStartRef.current = true;
2685
+ const seq = ++jumpToStartSeqRef.current;
2686
+ void Promise.resolve(onJumpToStart!()).then(
2687
+ () => {
2688
+ // The commit that swaps in the oldest window consumes
2689
+ // the flag against the new DOM; this write covers the
2690
+ // already-committed order and the no-window-change
2691
+ // case (jumping within the current window).
2692
+ const scroller = scrollRef.current ?? node;
2693
+ if (scroller) {
2694
+ scroller.scrollTop = 0;
2695
+ }
2696
+ // A host may resolve without ever changing the
2697
+ // window (already on the oldest page). Any swap
2698
+ // commit runs its layout effect before the next
2699
+ // frame, so a flag still armed by then is the
2700
+ // no-change case — clear it, or a LATER prepend
2701
+ // would spuriously jump the reader to the top.
2702
+ requestFrame(() => {
2703
+ if (jumpToStartSeqRef.current === seq) {
2704
+ pendingJumpToStartRef.current = false;
2705
+ }
2706
+ });
2707
+ },
2708
+ () => {
2490
2709
  if (jumpToStartSeqRef.current === seq) {
2491
2710
  pendingJumpToStartRef.current = false;
2492
2711
  }
2493
- });
2494
- },
2495
- () => {
2496
- if (jumpToStartSeqRef.current === seq) {
2497
- pendingJumpToStartRef.current = false;
2498
- }
2499
- },
2500
- );
2501
- }}
2502
- className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2503
- >
2504
- {underfillRetryReady
2505
- ? underfillSettledAttempt?.[2]?.tailPreserved
2506
- ? "Load earlier activity"
2507
- : "Retry earlier activity"
2508
- : "Jump to start"}
2509
- </button>
2510
- ) : null}
2511
- </motion.div>
2712
+ },
2713
+ );
2714
+ }}
2715
+ className="pointer-events-auto rounded-full border border-og-border px-3 py-1.5 text-og-control"
2716
+ >
2717
+ {underfillRetryReady
2718
+ ? underfillSettledAttempt?.[2]?.tailPreserved
2719
+ ? "Load earlier activity"
2720
+ : "Retry earlier activity"
2721
+ : "Jump to start"}
2722
+ </button>
2723
+ ) : null}
2724
+ </motion.div>
2725
+ ) : null}
2726
+ </AnimatePresence>
2727
+ {!groups.length
2728
+ ? (emptyState ?? (
2729
+ <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2730
+ No activity yet.
2731
+ </p>
2732
+ ))
2733
+ : null}
2734
+ {hasOlder && olderPrefetchArmed ? (
2735
+ // Overlaid, not a layout row: mounting/unmounting the sentinel
2736
+ // must never shift content (that shift was itself a wobble).
2737
+ // End at the scroll origin above the pt-16 gutter so the
2738
+ // observer and scrollTop cooldown share the same 400px band.
2739
+ <div
2740
+ ref={topSentinelRef}
2741
+ data-og-top-sentinel=""
2742
+ data-og-timeline-chrome=""
2743
+ aria-hidden="true"
2744
+ className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2745
+ />
2512
2746
  ) : null}
2513
- </AnimatePresence>
2514
- {!groups.length
2515
- ? (emptyState ?? (
2516
- <p className="py-10 text-center text-og-menu text-og-fg-subtle">
2517
- No activity yet.
2518
- </p>
2519
- ))
2520
- : null}
2521
- {hasOlder && olderPrefetchArmed ? (
2522
- // Overlaid, not a layout row: mounting/unmounting the sentinel
2523
- // must never shift content (that shift was itself a wobble).
2524
- // End at the scroll origin above the pt-16 gutter so the
2525
- // observer and scrollTop cooldown share the same 400px band.
2526
- <div
2527
- ref={topSentinelRef}
2528
- data-og-top-sentinel=""
2529
- data-og-timeline-chrome=""
2530
- aria-hidden="true"
2531
- className="pointer-events-none absolute inset-x-0 -top-16 h-px -translate-y-full"
2532
- />
2533
- ) : null}
2534
- {groups.map(({ group, key, entranceEnabled }, index) => {
2535
- return (
2536
- <TimelineSearchRevealContext.Provider
2537
- key={key}
2538
- value={
2539
- searchItem &&
2540
- timelineGroupItemIds(group).includes(searchItem.id)
2541
- ? searchRevealKey
2542
- : null
2543
- }
2544
- >
2545
- <TimelineGroupEntry
2546
- groupKey={key}
2547
- group={group}
2548
- nextGroup={groups[index + 1]?.group}
2549
- startupDismissed={
2550
- group.kind === "activity" &&
2551
- group.items.some(
2552
- (item) => item.turnId && turnsWithOutput.has(item.turnId),
2553
- )
2747
+ {groups.map(({ group, key, entranceEnabled }, index) => {
2748
+ return (
2749
+ <TimelineSearchRevealContext.Provider
2750
+ key={key}
2751
+ value={
2752
+ searchItem &&
2753
+ timelineGroupItemIds(group).includes(searchItem.id)
2754
+ ? searchRevealKey
2755
+ : null
2554
2756
  }
2555
- entranceEnabled={entranceEnabled}
2556
- liveEntranceEnabled={
2557
- group.kind === "activity" ? !bulkRender : undefined
2558
- }
2559
- context={timelineGroupEntryContext}
2560
- />
2561
- </TimelineSearchRevealContext.Provider>
2562
- );
2563
- })}
2564
- {groups.length > 0 && trailingState ? (
2565
- <div data-og-timeline-trailing-state="">{trailingState}</div>
2566
- ) : null}
2567
- {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2568
- <div
2569
- data-og-newer-error=""
2570
- className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2571
- >
2572
- <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2573
- Couldn’t load later activity. {newerFailure.message}
2574
- </p>
2575
- <button
2576
- ref={newerRetryButtonRef}
2577
- type="button"
2578
- data-og-retry-newer=""
2579
- className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
2580
- aria-disabled={loadingNewer || newerRetryPending}
2581
- aria-busy={newerRetryPending}
2582
- onClick={() => requestNewer(true)}
2757
+ >
2758
+ <TimelineGroupEntry
2759
+ groupKey={key}
2760
+ group={group}
2761
+ nextGroup={groups[index + 1]?.group}
2762
+ startupDismissed={
2763
+ group.kind === "activity" &&
2764
+ group.items.some(
2765
+ (item) => item.turnId && turnsWithOutput.has(item.turnId),
2766
+ )
2767
+ }
2768
+ entranceEnabled={entranceEnabled}
2769
+ liveEntranceEnabled={
2770
+ group.kind === "activity" ? !bulkRender : undefined
2771
+ }
2772
+ noteFolded={
2773
+ group.kind === "item"
2774
+ ? liveNoteFolds.get(group.item.id)
2775
+ : undefined
2776
+ }
2777
+ context={timelineGroupEntryContext}
2778
+ />
2779
+ </TimelineSearchRevealContext.Provider>
2780
+ );
2781
+ })}
2782
+ {groups.length > 0 && trailingState ? (
2783
+ <div data-og-timeline-trailing-state="">{trailingState}</div>
2784
+ ) : null}
2785
+ {hasNewer && newerFailure?.key === newerBoundaryKey ? (
2786
+ <div
2787
+ data-og-newer-error=""
2788
+ className="flex flex-col items-center gap-2 px-4 py-3 text-center text-og-menu text-og-fg-muted"
2583
2789
  >
2584
- {newerRetryPending
2585
- ? "Retrying later activity…"
2586
- : "Retry later activity"}
2587
- </button>
2588
- </div>
2790
+ <p role="status" className="max-w-prose [overflow-wrap:anywhere]">
2791
+ Couldn’t load later activity. {newerFailure.message}
2792
+ </p>
2793
+ <button
2794
+ ref={newerRetryButtonRef}
2795
+ type="button"
2796
+ data-og-retry-newer=""
2797
+ className="min-h-11 rounded-og-md border border-og-border px-3 py-2 text-og-fg hover:bg-og-surface-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent"
2798
+ aria-disabled={loadingNewer || newerRetryPending}
2799
+ aria-busy={newerRetryPending}
2800
+ onClick={() => requestNewer(true)}
2801
+ >
2802
+ {newerRetryPending
2803
+ ? "Retrying later activity…"
2804
+ : "Retry later activity"}
2805
+ </button>
2806
+ </div>
2807
+ ) : null}
2808
+ {hasNewer ? (
2809
+ <div
2810
+ ref={bottomSentinelRef}
2811
+ data-og-bottom-sentinel=""
2812
+ data-og-timeline-chrome=""
2813
+ aria-hidden="true"
2814
+ className="h-px w-full"
2815
+ />
2816
+ ) : null}
2817
+ </div>
2818
+ </TimelineBeforeLayout>
2819
+ </div>
2820
+ {draftAnnotations && draftAnnotations.length > 0 ? (
2821
+ <Suspense fallback={null}>
2822
+ <TimelineAnnotationMarkers
2823
+ rootRef={scrollRef}
2824
+ annotations={draftAnnotations}
2825
+ onSelect={onDraftAnnotationSelect}
2826
+ />
2827
+ </Suspense>
2828
+ ) : null}
2829
+
2830
+ <div
2831
+ data-og-timeline-nav="top"
2832
+ className={cn(
2833
+ "pointer-events-none absolute inset-x-0 z-10 flex justify-center",
2834
+ questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
2835
+ )}
2836
+ >
2837
+ <AnimatePresence>
2838
+ {questionNav ? (
2839
+ <motion.button
2840
+ key="question-nav"
2841
+ {...NAV_FADE}
2842
+ type="button"
2843
+ data-og-question-nav=""
2844
+ data-og-jump-to-question=""
2845
+ title="Back to your message"
2846
+ onClick={() => jumpToQuestion(questionNav.key)}
2847
+ className={NAV_BUTTON_CLASS}
2848
+ >
2849
+ <ArrowUpIcon aria-hidden className="size-4" />
2850
+ <span className="sr-only">Back to your message</span>
2851
+ </motion.button>
2589
2852
  ) : null}
2590
- {hasNewer ? (
2591
- <div
2592
- ref={bottomSentinelRef}
2593
- data-og-bottom-sentinel=""
2594
- data-og-timeline-chrome=""
2595
- aria-hidden="true"
2596
- className="h-px w-full"
2597
- />
2853
+ </AnimatePresence>
2854
+ </div>
2855
+ <div
2856
+ data-og-timeline-nav="bottom"
2857
+ className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
2858
+ >
2859
+ <AnimatePresence>
2860
+ {loadingNewer ? (
2861
+ <motion.div
2862
+ key="loading-newer"
2863
+ {...NAV_FADE}
2864
+ data-og-loading-newer=""
2865
+ aria-live="polite"
2866
+ className="flex max-w-full justify-center"
2867
+ >
2868
+ <span className={LOADING_CHIP_CLASS}>
2869
+ <span className="og-shimmer-text">Loading later activity…</span>
2870
+ </span>
2871
+ </motion.div>
2598
2872
  ) : null}
2599
- </div>
2600
- </TimelineBeforeLayout>
2601
- </div>
2602
- {draftAnnotations && draftAnnotations.length > 0 ? (
2603
- <Suspense fallback={null}>
2604
- <TimelineAnnotationMarkers
2605
- rootRef={scrollRef}
2606
- annotations={draftAnnotations}
2607
- onSelect={onDraftAnnotationSelect}
2608
- />
2609
- </Suspense>
2610
- ) : null}
2611
-
2612
- <div
2613
- data-og-timeline-nav="top"
2614
- className={cn(
2615
- "pointer-events-none absolute inset-x-0 z-10 flex justify-center",
2616
- questionNav?.belowHeader ? "top-10 pointer-coarse:top-13" : "top-3",
2617
- )}
2618
- >
2619
- <AnimatePresence>
2620
- {questionNav ? (
2621
- <motion.button
2622
- key="question-nav"
2623
- {...NAV_FADE}
2624
- type="button"
2625
- data-og-question-nav=""
2626
- data-og-jump-to-question=""
2627
- title="Back to your message"
2628
- onClick={() => jumpToQuestion(questionNav.key)}
2629
- className={NAV_BUTTON_CLASS}
2630
- >
2631
- <ArrowUpIcon aria-hidden className="size-4" />
2632
- <span className="sr-only">Back to your message</span>
2633
- </motion.button>
2634
- ) : null}
2635
- </AnimatePresence>
2636
- </div>
2637
- <div
2638
- data-og-timeline-nav="bottom"
2639
- className="pointer-events-none absolute inset-x-0 bottom-3 z-10 flex flex-col items-center gap-2"
2640
- >
2641
- <AnimatePresence>
2642
- {loadingNewer ? (
2643
- <motion.div
2644
- key="loading-newer"
2645
- {...NAV_FADE}
2646
- data-og-loading-newer=""
2647
- aria-live="polite"
2648
- className="flex max-w-full justify-center"
2649
- >
2650
- <span className={LOADING_CHIP_CLASS}>
2651
- <span className="og-shimmer-text">Loading later activity…</span>
2652
- </span>
2653
- </motion.div>
2654
- ) : null}
2655
- {jumpPillShown ? (
2656
- <motion.button
2657
- key="jump-to-latest"
2658
- {...NAV_FADE}
2659
- type="button"
2660
- data-og-jump-to-latest=""
2661
- title="Jump to latest"
2662
- onClick={() => {
2663
- // Returning to the tip explicitly releases reader-owned
2664
- // prose. Clear only this timeline's selection before the
2665
- // synchronous ownership check on the next commit.
2666
- const viewport = scrollRef.current;
2667
- const selection = viewport?.ownerDocument.getSelection();
2668
- if (
2669
- selection &&
2670
- !selection.isCollapsed &&
2671
- (viewport?.contains(selection.anchorNode) ||
2672
- viewport?.contains(selection.focusNode))
2673
- )
2674
- selection.removeAllRanges();
2675
- disclosureKeepsUnpinnedRef.current = false;
2676
- if (hasNewer) {
2677
- // Do not pin against the current history page — its bottom
2678
- // is not the tip. The pin + snap run when the tip window
2679
- // actually lands (`hasNewer` flips false).
2680
- wantPinRef.current = true;
2681
- const node = scrollRef.current;
2682
- if (onJumpToLatest) {
2683
- void Promise.resolve(onJumpToLatest()).then(
2684
- () => {
2685
- // Covers a host that flipped hasNewer before
2686
- // resolving; otherwise the tip-window commit
2687
- // consumes the flag.
2688
- const current = scrollRef.current;
2689
- if (current && wantPinRef.current && !hasNewerRef.current) {
2873
+ {jumpPillShown ? (
2874
+ <motion.button
2875
+ key="jump-to-latest"
2876
+ {...NAV_FADE}
2877
+ type="button"
2878
+ data-og-jump-to-latest=""
2879
+ title="Jump to latest"
2880
+ onClick={() => {
2881
+ // Returning to the tip explicitly releases reader-owned
2882
+ // prose. Clear only this timeline's selection before the
2883
+ // synchronous ownership check on the next commit.
2884
+ const viewport = scrollRef.current;
2885
+ const selection = viewport?.ownerDocument.getSelection();
2886
+ if (
2887
+ selection &&
2888
+ !selection.isCollapsed &&
2889
+ (viewport?.contains(selection.anchorNode) ||
2890
+ viewport?.contains(selection.focusNode))
2891
+ )
2892
+ selection.removeAllRanges();
2893
+ disclosureKeepsUnpinnedRef.current = false;
2894
+ if (hasNewer) {
2895
+ // Do not pin against the current history page — its bottom
2896
+ // is not the tip. The pin + snap run when the tip window
2897
+ // actually lands (`hasNewer` flips false).
2898
+ wantPinRef.current = true;
2899
+ const node = scrollRef.current;
2900
+ if (onJumpToLatest) {
2901
+ void Promise.resolve(onJumpToLatest()).then(
2902
+ () => {
2903
+ // Covers a host that flipped hasNewer before
2904
+ // resolving; otherwise the tip-window commit
2905
+ // consumes the flag.
2906
+ const current = scrollRef.current;
2907
+ if (
2908
+ current &&
2909
+ wantPinRef.current &&
2910
+ !hasNewerRef.current
2911
+ ) {
2912
+ wantPinRef.current = false;
2913
+ applyPinned(true);
2914
+ snapToBottom(current);
2915
+ }
2916
+ },
2917
+ () => {
2918
+ // The tip reload failed (ordinary network error):
2919
+ // an armed latch would fire a surprise snap when
2920
+ // the reader later pages to the tip themselves.
2690
2921
  wantPinRef.current = false;
2691
- applyPinned(true);
2692
- snapToBottom(current);
2693
- }
2694
- },
2695
- () => {
2696
- // The tip reload failed (ordinary network error):
2697
- // an armed latch would fire a surprise snap when
2698
- // the reader later pages to the tip themselves.
2699
- wantPinRef.current = false;
2700
- },
2701
- );
2702
- } else if (node) {
2703
- // No tip reload available: jump within the in-memory
2704
- // window so the newer sentinel can page forward; the
2705
- // latch pins if the tip window eventually lands.
2922
+ },
2923
+ );
2924
+ } else if (node) {
2925
+ // No tip reload available: jump within the in-memory
2926
+ // window so the newer sentinel can page forward; the
2927
+ // latch pins if the tip window eventually lands.
2928
+ snapToBottom(node);
2929
+ }
2930
+ return;
2931
+ }
2932
+ const node = scrollRef.current;
2933
+ if (node) {
2934
+ applyPinned(true);
2706
2935
  snapToBottom(node);
2707
2936
  }
2708
- return;
2709
- }
2710
- const node = scrollRef.current;
2711
- if (node) {
2712
- applyPinned(true);
2713
- snapToBottom(node);
2714
- }
2715
- }}
2716
- className={NAV_BUTTON_CLASS}
2717
- >
2718
- <ArrowDownIcon aria-hidden className="size-4" />
2719
- <span className="sr-only">Jump to latest</span>
2720
- </motion.button>
2721
- ) : null}
2722
- </AnimatePresence>
2937
+ }}
2938
+ className={NAV_BUTTON_CLASS}
2939
+ >
2940
+ <ArrowDownIcon aria-hidden className="size-4" />
2941
+ <span className="sr-only">Jump to latest</span>
2942
+ </motion.button>
2943
+ ) : null}
2944
+ </AnimatePresence>
2945
+ </div>
2723
2946
  </div>
2724
- </div>
2725
- </TimelineAnnotationSourceRootContext.Provider>
2726
- </TooltipProvider>
2727
- </EntranceAnimationProvider>
2728
- </TimelineComputeLabelProvider>
2729
- </SeenActivityIdsProvider>
2947
+ </TimelineAnnotationSourceRootContext.Provider>
2948
+ </TooltipProvider>
2949
+ </EntranceAnimationProvider>
2950
+ </TimelineComputeLabelProvider>
2951
+ </SeenActivityIdsProvider>
2952
+ </LiveNoteFoldContext.Provider>
2730
2953
  </FoldMemoryProvider>
2731
2954
  </LightboxProvider>
2732
2955
  </AllowancePresentationProvider>
@@ -2769,7 +2992,11 @@ function useStableTimelineGroupKeys(
2769
2992
  const previousByItemId = new Map<string, KeyedTimelineGroup>();
2770
2993
  for (const previous of previousRef.current) {
2771
2994
  for (const itemId of timelineGroupItemIds(previous.group)) {
2772
- previousByItemId.set(itemId, previous);
2995
+ // A live progress note is both its own row and listed in its work
2996
+ // history. Its own row owns the id, so the note keeps its identity.
2997
+ if (previous.group.kind === "item" || !previousByItemId.has(itemId)) {
2998
+ previousByItemId.set(itemId, previous);
2999
+ }
2773
3000
  }
2774
3001
  }
2775
3002
 
@@ -2922,6 +3149,11 @@ type TimelineGroupEntryProps = {
2922
3149
  startupDismissed: boolean;
2923
3150
  entranceEnabled: boolean;
2924
3151
  liveEntranceEnabled?: boolean | undefined;
3152
+ /**
3153
+ * Present for a live progress note: true while its turn's work disclosure is
3154
+ * open (the note then reads inside it), false while it reads here.
3155
+ */
3156
+ noteFolded?: boolean | undefined;
2925
3157
  context: TimelineGroupEntryContext;
2926
3158
  };
2927
3159
 
@@ -2960,6 +3192,7 @@ const TimelineGroupEntry = memo(function TimelineGroupEntry({
2960
3192
  startupDismissed,
2961
3193
  entranceEnabled,
2962
3194
  liveEntranceEnabled,
3195
+ noteFolded,
2963
3196
  context,
2964
3197
  }: TimelineGroupEntryProps) {
2965
3198
  const reducedMotion = useReducedMotion();
@@ -2994,6 +3227,17 @@ const TimelineGroupEntry = memo(function TimelineGroupEntry({
2994
3227
  group.kind === "item" && group.item.kind === "user-message" ? "" : undefined
2995
3228
  }
2996
3229
  tabIndex={group.kind === "item" && group.item.kind === "user-message" ? -1 : undefined}
3230
+ // A live progress note reads inside its open work row instead; folded,
3231
+ // it takes no space and also gives back the list's gap-5 above it.
3232
+ data-og-live-note={noteFolded === undefined ? undefined : noteFolded ? "folded" : "shown"}
3233
+ data-og-live-note-id={
3234
+ noteFolded !== undefined && group.kind === "item" ? group.item.id : undefined
3235
+ }
3236
+ aria-hidden={noteFolded || undefined}
3237
+ inert={noteFolded || undefined}
3238
+ className={
3239
+ noteFolded ? "-mt-5 h-0 overflow-hidden opacity-0 [overflow-anchor:none]" : undefined
3240
+ }
2997
3241
  >
2998
3242
  <EntranceAnimationProvider value={entranceEnabled} liveValue={liveEntranceEnabled}>
2999
3243
  <TimelineGroupRenderBoundary resetKeys={[group, behavior]}>
@@ -3111,6 +3355,7 @@ const TimelineGroupView = memo(function TimelineGroupView({
3111
3355
  previousSingleActivity.current = work.length === 1 ? work[0] : undefined;
3112
3356
  }, [group]);
3113
3357
  const foldMemory = useFoldMemory();
3358
+ const reportLiveFold = useContext(LiveNoteFoldContext);
3114
3359
  // Settled (or live-fold) activity clusters get a chip. Inside an expanded
3115
3360
  // turn that is the second layer — quiet nested chips under the outer turn
3116
3361
  // summary when contiguous activity naturally clusters (≥2 only).
@@ -3192,44 +3437,53 @@ const TimelineGroupView = memo(function TimelineGroupView({
3192
3437
  ),
3193
3438
  };
3194
3439
  return (
3195
- <TurnSummary
3196
- key="work"
3197
- items={group.items}
3198
- status={status}
3199
- outcome={group.outcome}
3200
- failureText={group.failureText}
3201
- foldKey={group.id}
3202
- defaultOpen={
3203
- containsPresentedImage ||
3204
- group.outcome === "failed" ||
3205
- phases.some((item) => item.status === "failed" || item.status === "cancelled")
3206
- ? true
3207
- : undefined
3208
- }
3209
- facets={turnSummary?.facets}
3210
- contextCompactionCount={compactedLandmarkCount(group.work.details)}
3211
- >
3212
- <TurnRailFrame compact>
3213
- {renderFoldedGroups(
3214
- group.work.details,
3215
- {
3216
- renderMessageActions,
3217
- renderMessageText,
3218
- onOpenSession,
3219
- onMemoryClick,
3220
- onReconnect,
3221
- renderAuthNeeded,
3222
- resolveProviderLogo,
3223
- toolRegistry,
3224
- loadRetainedScreenshot,
3225
- loadRetainedArtifact,
3226
- loadVideoArtifactPlayback,
3227
- turnSummary,
3228
- },
3229
- true,
3230
- )}
3231
- </TurnRailFrame>
3232
- </TurnSummary>
3440
+ // Marks this work row for the live-note fold (list keys can be carried
3441
+ // over from an earlier row, so they are not the work id).
3442
+ <div className="contents" data-og-work-id={group.id}>
3443
+ <TurnSummary
3444
+ key="work"
3445
+ items={group.items}
3446
+ status={status}
3447
+ outcome={group.outcome}
3448
+ failureText={group.failureText}
3449
+ foldKey={group.id}
3450
+ defaultOpen={
3451
+ containsPresentedImage ||
3452
+ group.outcome === "failed" ||
3453
+ phases.some((item) => item.status === "failed" || item.status === "cancelled")
3454
+ ? true
3455
+ : undefined
3456
+ }
3457
+ facets={turnSummary?.facets}
3458
+ contextCompactionCount={compactedLandmarkCount(group.work.details)}
3459
+ onOpenStateChange={
3460
+ group.work.liveNoteIds?.length && reportLiveFold
3461
+ ? (open) => reportLiveFold(group.id, open)
3462
+ : undefined
3463
+ }
3464
+ >
3465
+ <TurnRailFrame compact>
3466
+ {renderFoldedGroups(
3467
+ group.work.details,
3468
+ {
3469
+ renderMessageActions,
3470
+ renderMessageText,
3471
+ onOpenSession,
3472
+ onMemoryClick,
3473
+ onReconnect,
3474
+ renderAuthNeeded,
3475
+ resolveProviderLogo,
3476
+ toolRegistry,
3477
+ loadRetainedScreenshot,
3478
+ loadRetainedArtifact,
3479
+ loadVideoArtifactPlayback,
3480
+ turnSummary,
3481
+ },
3482
+ true,
3483
+ )}
3484
+ </TurnRailFrame>
3485
+ </TurnSummary>
3486
+ </div>
3233
3487
  );
3234
3488
  }
3235
3489
  // Preparation is one quiet surface, not a fold with eight technical steps.