@opengeni/react 7.6.1 → 7.7.0-canary.37111190284001

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 (145) hide show
  1. package/README.md +47 -2
  2. package/dist/{browser-viewer-5X47I6T2.js → browser-viewer-63TXMINB.js} +6 -5
  3. package/dist/{chunk-5EXGU7UF.js → chunk-55DDDVKU.js} +3 -3
  4. package/dist/{chunk-UINDK6C3.js → chunk-7CYYXWHU.js} +5 -8
  5. package/dist/chunk-7CYYXWHU.js.map +1 -0
  6. package/dist/{chunk-LZ3KHA55.js → chunk-A2L5N3EI.js} +332 -339
  7. package/dist/chunk-A2L5N3EI.js.map +1 -0
  8. package/dist/{chunk-KGENSZKS.js → chunk-AAG4R3QN.js} +4 -3
  9. package/dist/chunk-AAG4R3QN.js.map +1 -0
  10. package/dist/{chunk-Z3DISEQN.js → chunk-CWWKTNKK.js} +35 -18
  11. package/dist/chunk-CWWKTNKK.js.map +1 -0
  12. package/dist/{chunk-WZ55DVGK.js → chunk-ELCE5SXV.js} +8 -4
  13. package/dist/chunk-ELCE5SXV.js.map +1 -0
  14. package/dist/{chunk-YSMMDOOE.js → chunk-GISEVGS6.js} +10 -2
  15. package/dist/chunk-GISEVGS6.js.map +1 -0
  16. package/dist/{chunk-BI57CQUV.js → chunk-I7IWRQLN.js} +2 -2
  17. package/dist/{chunk-XA6R7DJQ.js → chunk-IQUCRAT7.js} +3 -3
  18. package/dist/{chunk-CHPKSLPN.js → chunk-NBZ64YY7.js} +4 -2
  19. package/dist/chunk-NBZ64YY7.js.map +1 -0
  20. package/dist/{chunk-DV2DBNPQ.js → chunk-Q5QBEZIQ.js} +48 -29
  21. package/dist/chunk-Q5QBEZIQ.js.map +1 -0
  22. package/dist/{chunk-FGBCYQOT.js → chunk-RDKBXMWX.js} +120 -74
  23. package/dist/chunk-RDKBXMWX.js.map +1 -0
  24. package/dist/{chunk-45GE35O7.js → chunk-TKB4SB44.js} +10 -2
  25. package/dist/chunk-TKB4SB44.js.map +1 -0
  26. package/dist/{chunk-YCGBREUV.js → chunk-TOAH2BKA.js} +6 -2
  27. package/dist/{chunk-YCGBREUV.js.map → chunk-TOAH2BKA.js.map} +1 -1
  28. package/dist/chunk-UGLEYEMP.js +33 -0
  29. package/dist/chunk-UGLEYEMP.js.map +1 -0
  30. package/dist/{chunk-UJRYEWZI.js → chunk-UTRTG6LH.js} +48 -8
  31. package/dist/chunk-UTRTG6LH.js.map +1 -0
  32. package/dist/{chunk-YRYF2GLG.js → chunk-WYVLAAJA.js} +39 -31
  33. package/dist/chunk-WYVLAAJA.js.map +1 -0
  34. package/dist/{chunk-VGIKFIO2.js → chunk-XOAMRDDK.js} +6 -3
  35. package/dist/chunk-XOAMRDDK.js.map +1 -0
  36. package/dist/{chunk-6NEZPF53.js → chunk-YN7I3STW.js} +153 -32
  37. package/dist/chunk-YN7I3STW.js.map +1 -0
  38. package/dist/{chunk-KZ73RL76.js → chunk-YQEAA6LT.js} +7 -3
  39. package/dist/chunk-YQEAA6LT.js.map +1 -0
  40. package/dist/client.d.ts +4 -1
  41. package/dist/commands/types.d.ts +2 -0
  42. package/dist/components/approval-surface.d.ts +15 -0
  43. package/dist/components/session-conversation.d.ts +3 -0
  44. package/dist/composer.js +8 -7
  45. package/dist/{computer-viewer-7GF35SNY.js → computer-viewer-X2MBRZY4.js} +5 -4
  46. package/dist/connect.js +3 -3
  47. package/dist/hooks/use-browser-session.d.ts +4 -0
  48. package/dist/hooks/use-composer.d.ts +2 -0
  49. package/dist/hooks/use-computer-session.d.ts +2 -0
  50. package/dist/hooks/use-file-attachments.d.ts +2 -0
  51. package/dist/hooks/use-session-events.d.ts +1 -1
  52. package/dist/hooks/use-workspace-sessions.d.ts +15 -5
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +28 -21
  55. package/dist/index.js.map +1 -1
  56. package/dist/interaction.d.ts +1 -1
  57. package/dist/interaction.js +6 -5
  58. package/dist/interaction.js.map +1 -1
  59. package/dist/{latest-question-3BJGKQQ6.js → latest-question-CNEZ5KAN.js} +3 -3
  60. package/dist/lib/error-message.d.ts +7 -0
  61. package/dist/lib/format.d.ts +27 -3
  62. package/dist/lib/interaction-errors.d.ts +3 -0
  63. package/dist/machines.js +5 -4
  64. package/dist/{model-policy-picker-menu-PUTA5JOB.js → model-policy-picker-menu-GN2LTB7Z.js} +4 -4
  65. package/dist/model-policy.js +1 -1
  66. package/dist/provider.d.ts +5 -1
  67. package/dist/{queue-surface-implementation-2CAQAIHM.js → queue-surface-implementation-HENWYFWA.js} +3 -2
  68. package/dist/{queue-surface-implementation-2CAQAIHM.js.map → queue-surface-implementation-HENWYFWA.js.map} +1 -1
  69. package/dist/realtime.js +2 -2
  70. package/dist/{sandbox-files-AEJTWGT4.js → sandbox-files-BBC47SLD.js} +4 -3
  71. package/dist/session-ui.d.ts +1 -1
  72. package/dist/session-ui.js +13 -12
  73. package/dist/session-ui.js.map +1 -1
  74. package/dist/session.js +7 -6
  75. package/dist/usage.js +2 -1
  76. package/dist/usage.js.map +1 -1
  77. package/package.json +3 -3
  78. package/src/client.ts +5 -1
  79. package/src/commands/registry.ts +18 -15
  80. package/src/commands/types.ts +2 -0
  81. package/src/components/approval-surface.tsx +166 -42
  82. package/src/components/browser-viewer.tsx +38 -9
  83. package/src/components/composer.tsx +9 -2
  84. package/src/components/computer-viewer.tsx +160 -30
  85. package/src/components/generated-video-player.tsx +4 -2
  86. package/src/components/human-input-form.tsx +18 -5
  87. package/src/components/message-timeline.tsx +120 -168
  88. package/src/components/model-policy-picker.tsx +2 -15
  89. package/src/components/open-geni-chat.tsx +11 -4
  90. package/src/components/queue-surface-state.tsx +3 -1
  91. package/src/components/session-chrome.tsx +5 -2
  92. package/src/components/session-commands-panel.tsx +3 -1
  93. package/src/components/session-conversation.tsx +10 -3
  94. package/src/components/session-list.tsx +7 -5
  95. package/src/components/session-mcp-capability-card.tsx +11 -1
  96. package/src/hooks/internal.ts +3 -2
  97. package/src/hooks/use-browser-session.ts +115 -79
  98. package/src/hooks/use-composer.ts +70 -31
  99. package/src/hooks/use-computer-session.ts +57 -6
  100. package/src/hooks/use-file-attachments.ts +16 -3
  101. package/src/hooks/use-goal.ts +2 -1
  102. package/src/hooks/use-session-capabilities.ts +3 -2
  103. package/src/hooks/use-session-events.ts +15 -8
  104. package/src/hooks/use-slash-commands.ts +6 -11
  105. package/src/hooks/use-turn-queue.ts +2 -1
  106. package/src/hooks/use-workspace-sessions.ts +40 -7
  107. package/src/index.ts +7 -2
  108. package/src/interaction.ts +1 -0
  109. package/src/lib/error-message.ts +37 -0
  110. package/src/lib/format.ts +73 -9
  111. package/src/lib/interaction-errors.ts +15 -0
  112. package/src/model-policy.ts +6 -1
  113. package/src/provider.tsx +12 -4
  114. package/src/session-ui.ts +1 -1
  115. package/src/timeline/projection.ts +12 -1
  116. package/styles/compiled.css +14 -30
  117. package/dist/chunk-45GE35O7.js.map +0 -1
  118. package/dist/chunk-6NEZPF53.js.map +0 -1
  119. package/dist/chunk-CHPKSLPN.js.map +0 -1
  120. package/dist/chunk-DV2DBNPQ.js.map +0 -1
  121. package/dist/chunk-FGBCYQOT.js.map +0 -1
  122. package/dist/chunk-KGENSZKS.js.map +0 -1
  123. package/dist/chunk-KZ73RL76.js.map +0 -1
  124. package/dist/chunk-LZ3KHA55.js.map +0 -1
  125. package/dist/chunk-UINDK6C3.js.map +0 -1
  126. package/dist/chunk-UJRYEWZI.js.map +0 -1
  127. package/dist/chunk-VGIKFIO2.js.map +0 -1
  128. package/dist/chunk-WZ55DVGK.js.map +0 -1
  129. package/dist/chunk-YRYF2GLG.js.map +0 -1
  130. package/dist/chunk-YSMMDOOE.js.map +0 -1
  131. package/dist/chunk-Z3DISEQN.js.map +0 -1
  132. package/dist/components/timeline-pill-geometry.d.ts +0 -8
  133. package/dist/components/timeline-pill-placement.d.ts +0 -24
  134. package/dist/timeline-pill-geometry-Z23V243M.js +0 -110
  135. package/dist/timeline-pill-geometry-Z23V243M.js.map +0 -1
  136. package/src/components/timeline-pill-geometry.ts +0 -169
  137. package/src/components/timeline-pill-placement.ts +0 -99
  138. /package/dist/{browser-viewer-5X47I6T2.js.map → browser-viewer-63TXMINB.js.map} +0 -0
  139. /package/dist/{chunk-5EXGU7UF.js.map → chunk-55DDDVKU.js.map} +0 -0
  140. /package/dist/{chunk-BI57CQUV.js.map → chunk-I7IWRQLN.js.map} +0 -0
  141. /package/dist/{chunk-XA6R7DJQ.js.map → chunk-IQUCRAT7.js.map} +0 -0
  142. /package/dist/{computer-viewer-7GF35SNY.js.map → computer-viewer-X2MBRZY4.js.map} +0 -0
  143. /package/dist/{latest-question-3BJGKQQ6.js.map → latest-question-CNEZ5KAN.js.map} +0 -0
  144. /package/dist/{model-policy-picker-menu-PUTA5JOB.js.map → model-policy-picker-menu-GN2LTB7Z.js.map} +0 -0
  145. /package/dist/{sandbox-files-AEJTWGT4.js.map → sandbox-files-BBC47SLD.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { RollingActivity } from "../timeline/rolling-activity";
2
+ import { useErrorMessage } from "../lib/error-message";
2
3
  import { formatElapsed } from "../timeline/turn-summary";
3
4
  import { ActivityNoteTextContext } from "../timeline/activity-rail";
4
5
  import { timelineGroupContainsPresentedImage } from "../timeline/presented-image";
@@ -73,7 +74,6 @@ import {
73
74
  type TimelineAnchor,
74
75
  } from "./timeline-anchor";
75
76
  import { useReadingProgress } from "./timeline-reading-progress";
76
- import { type TimelinePillPlacement, useTimelinePillPlacement } from "./timeline-pill-placement";
77
77
  import {
78
78
  animateTimelineSettlement,
79
79
  captureTimelineSettlement,
@@ -332,20 +332,20 @@ const PIN_THRESHOLD_PX = 48;
332
332
  const JUMP_TO_LATEST_CATCHUP_DEBT_PX = 240;
333
333
  /** Breathing room above the question when following stops at an answer. */
334
334
  const QUESTION_NAV_MARGIN_PX = 12;
335
- // Floating pills rest at these spots and slide sideways, in this order, when
336
- // their resting spot would cover a control (see useTimelinePillPlacement).
337
- const JUMP_PILL_PLACEMENTS: readonly TimelinePillPlacement[] = ["center", "end", "start"];
338
- const QUESTION_PILL_PLACEMENTS: readonly TimelinePillPlacement[] = ["end", "center", "start"];
339
- const JUMP_PILL_PLACEMENT_CLASS: Record<TimelinePillPlacement, string> = {
340
- center: "inset-x-0 mx-auto",
341
- end: "right-4 sm:right-6",
342
- start: "left-4 sm:left-6",
343
- };
344
- const QUESTION_PILL_PLACEMENT_CLASS: Record<TimelinePillPlacement, string> = {
345
- center: "justify-center",
346
- end: "justify-end",
347
- start: "justify-start",
348
- };
335
+ /**
336
+ * Floating timeline navigation (Back to your message, Jump to latest) shares one
337
+ * fixed anchor: centered at the bottom of the conversation, above the composer.
338
+ * The pills never measure or dodge the content beneath them, so they cannot
339
+ * wander sideways or drift vertically as rows stream in or the host resizes.
340
+ */
341
+ const NAV_PILL_CLASS =
342
+ "pointer-events-auto inline-flex max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5 text-og-control font-medium text-og-fg shadow-og-md backdrop-blur hover:border-og-border-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-og-accent pointer-coarse:min-h-11";
343
+ const NAV_PILL_MOTION = {
344
+ initial: { opacity: 0, y: 8 },
345
+ animate: { opacity: 1, y: 0 },
346
+ exit: { opacity: 0, y: 8 },
347
+ transition: { duration: 0.15, ease: "easeOut" },
348
+ } as const;
349
349
  /**
350
350
  * Prefetch older history when the top sentinel is this far from the viewport.
351
351
  * After a page loads we stay cool until the reader leaves this band (scrolls
@@ -562,6 +562,7 @@ export function MessageTimeline({
562
562
  emptyState,
563
563
  className,
564
564
  }: MessageTimelineProps) {
565
+ const formatError = useErrorMessage();
565
566
  const resolvedItems = useMemo(() => {
566
567
  const projectedItems = items ?? buildTimeline(events ?? []);
567
568
  if (!shouldRenderAuthNeeded) {
@@ -672,12 +673,12 @@ export function MessageTimeline({
672
673
  setNewerRetryPending(false);
673
674
  setNewerFailure({
674
675
  key: newerBoundaryKey,
675
- message: reason instanceof Error ? reason.message : String(reason),
676
+ message: formatError(reason),
676
677
  });
677
678
  },
678
679
  );
679
680
  },
680
- [onLoadNewer, loadingNewer, newerBoundaryKey],
681
+ [onLoadNewer, loadingNewer, newerBoundaryKey, formatError],
681
682
  );
682
683
  const previousSourceIdsRef = useRef(new Set<string>());
683
684
  const previousSourceBoundaryRef = useRef<string | undefined>(undefined);
@@ -745,22 +746,7 @@ export function MessageTimeline({
745
746
  const resizeFollowRafRef = useRef<number | null>(null);
746
747
  const questionNavFrameRef = useRef<number | null>(null);
747
748
  const [questionNav, setQuestionNav] = useState<QuestionNav | null>(null);
748
- const jumpPillRef = useRef<HTMLButtonElement | null>(null);
749
- const questionPillRef = useRef<HTMLDivElement | null>(null);
750
749
  const jumpPillShown = autoFollow && (!pinned || hasNewer || canSkipTipCatchup);
751
- const jumpPillPlacement = useTimelinePillPlacement({
752
- pillRef: jumpPillRef,
753
- scrollerRef: scrollRef,
754
- active: jumpPillShown,
755
- preference: JUMP_PILL_PLACEMENTS,
756
- });
757
- const questionPillPlacement = useTimelinePillPlacement({
758
- pillRef: questionPillRef,
759
- scrollerRef: scrollRef,
760
- active: questionNav !== null,
761
- preference: QUESTION_PILL_PLACEMENTS,
762
- allowDownwardFallback: true,
763
- });
764
750
  const firstGroupKey = allGroups[0] ? timelineGroupKey(allGroups[0]) : null;
765
751
  // Content stays invisible until the tip is hard-parked across a short
766
752
  // post-commit settle (two rAFs). That absorbs sync late layout while hidden
@@ -2264,8 +2250,7 @@ export function MessageTimeline({
2264
2250
  // (pt-16). Subtract exactly that padding so an expanded work
2265
2251
  // header pins flush to the scrollport. Pinning it lower left a
2266
2252
  // band of scrolling rows visible above the header, which then
2267
- // looked like it floated over the middle of the timeline. The
2268
- // floating question action sits below this strip instead.
2253
+ // looked like it floated over the middle of the timeline.
2269
2254
  style={{ "--og-work-header-top": "-4rem" } as CSSProperties}
2270
2255
  >
2271
2256
  {onAnnotate ? (
@@ -2394,14 +2379,7 @@ export function MessageTimeline({
2394
2379
  transition={{ duration: 0.15, ease: "easeOut" }}
2395
2380
  data-og-loading-older=""
2396
2381
  aria-live="polite"
2397
- className={cn(
2398
- "pointer-events-none absolute inset-x-0 -top-11 z-10 flex",
2399
- // The scrolling history control and fixed question
2400
- // action must never share the same pointer region.
2401
- questionNav
2402
- ? "max-w-[calc(50%-0.5rem)] justify-start"
2403
- : "justify-center",
2404
- )}
2382
+ className="pointer-events-none absolute inset-x-0 -top-11 z-10 flex justify-center"
2405
2383
  >
2406
2384
  {loadingOlder || loadingOldest ? (
2407
2385
  <span className={LOADING_CHIP_CLASS}>
@@ -2576,139 +2554,113 @@ export function MessageTimeline({
2576
2554
  </Suspense>
2577
2555
  ) : null}
2578
2556
 
2579
- <AnimatePresence>
2580
- {questionNav ? (
2581
- <motion.div
2582
- key="question-nav"
2583
- initial={{ opacity: 0, y: -6 }}
2584
- animate={{ opacity: 1, y: 0 }}
2585
- exit={{ opacity: 0, y: -6 }}
2586
- transition={{ duration: 0.15, ease: "easeOut" }}
2587
- data-og-question-nav=""
2588
- // Below the pinned work-header strip (py-1.5 row, 44px on coarse
2589
- // pointers), never over it: the header stays a full-width target.
2590
- data-og-pill-placement={questionPillPlacement.placement}
2591
- style={{ marginTop: questionPillPlacement.offsetY }}
2592
- className={cn(
2593
- "pointer-events-none absolute inset-x-0 top-11 z-10 flex px-4 sm:px-6 pointer-coarse:top-14",
2594
- QUESTION_PILL_PLACEMENT_CLASS[questionPillPlacement.placement],
2595
- )}
2596
- >
2557
+ <div
2558
+ data-og-timeline-nav=""
2559
+ // One fixed anchor for every floating navigation control: a
2560
+ // centered column at the bottom of the conversation. Newest
2561
+ // stays nearest the composer; the up-pointing action and the
2562
+ // later-activity status stack above it.
2563
+ className="pointer-events-none absolute inset-x-0 bottom-4 z-10 flex flex-col items-center gap-2 px-4 sm:px-6"
2564
+ >
2565
+ <AnimatePresence mode="popLayout">
2566
+ {loadingNewer ? (
2597
2567
  <motion.div
2598
- ref={questionPillRef}
2568
+ key="loading-newer"
2599
2569
  layout="position"
2600
- transition={{ duration: 0.18, ease: "easeOut" }}
2601
- className="pointer-events-auto inline-flex max-w-full items-center rounded-full border border-og-border bg-og-surface-3/90 text-og-control font-medium text-og-fg shadow-og-md backdrop-blur"
2570
+ {...NAV_PILL_MOTION}
2571
+ data-og-loading-newer=""
2572
+ aria-live="polite"
2573
+ className="flex max-w-full justify-center"
2602
2574
  >
2603
- <button
2604
- type="button"
2605
- data-og-jump-to-question=""
2606
- onClick={() => jumpToQuestion(questionNav.key)}
2607
- className="inline-flex min-w-0 items-center gap-1.5 whitespace-nowrap px-3 py-1.5 hover:text-og-fg pointer-coarse:min-h-11"
2608
- >
2609
- <ArrowUpIcon aria-hidden className="size-3.5" />
2610
- Back to your message
2611
- </button>
2575
+ <span className={LOADING_CHIP_CLASS}>
2576
+ <span className="og-shimmer-text">Loading later activity…</span>
2577
+ </span>
2612
2578
  </motion.div>
2613
- </motion.div>
2614
- ) : null}
2615
- </AnimatePresence>
2616
- <AnimatePresence>
2617
- {loadingNewer ? (
2618
- <motion.div
2619
- initial={{ opacity: 0, y: 6 }}
2620
- animate={{ opacity: 1, y: 0 }}
2621
- exit={{ opacity: 0, y: 6 }}
2622
- transition={{ duration: 0.15, ease: "easeOut" }}
2623
- data-og-loading-newer=""
2624
- aria-live="polite"
2625
- className="pointer-events-none absolute inset-x-0 bottom-14 z-10 flex justify-center"
2626
- >
2627
- <span className={LOADING_CHIP_CLASS}>
2628
- <span className="og-shimmer-text">Loading later activity…</span>
2629
- </span>
2630
- </motion.div>
2631
- ) : null}
2632
- </AnimatePresence>
2633
- <AnimatePresence>
2634
- {jumpPillShown ? (
2635
- <motion.button
2636
- ref={jumpPillRef}
2637
- type="button"
2638
- data-og-jump-to-latest=""
2639
- data-og-pill-placement={jumpPillPlacement.placement}
2640
- layout="position"
2641
- initial={{ opacity: 0, y: 8 }}
2642
- animate={{ opacity: 1, y: 0 }}
2643
- exit={{ opacity: 0, y: 8 }}
2644
- transition={{ duration: 0.15, ease: "easeOut" }}
2645
- onClick={() => {
2646
- // Returning to the tip explicitly releases reader-owned
2647
- // prose. Clear only this timeline's selection before the
2648
- // synchronous ownership check on the next commit.
2649
- const viewport = scrollRef.current;
2650
- const selection = viewport?.ownerDocument.getSelection();
2651
- if (
2652
- selection &&
2653
- !selection.isCollapsed &&
2654
- (viewport?.contains(selection.anchorNode) ||
2655
- viewport?.contains(selection.focusNode))
2656
- )
2657
- selection.removeAllRanges();
2658
- disclosureKeepsUnpinnedRef.current = false;
2659
- if (hasNewer) {
2660
- // Do not pin against the current history page — its bottom
2661
- // is not the tip. The pin + snap run when the tip window
2662
- // actually lands (`hasNewer` flips false).
2663
- wantPinRef.current = true;
2664
- const node = scrollRef.current;
2665
- if (onJumpToLatest) {
2666
- void Promise.resolve(onJumpToLatest()).then(
2667
- () => {
2668
- // Covers a host that flipped hasNewer before
2669
- // resolving; otherwise the tip-window commit
2670
- // consumes the flag.
2671
- const current = scrollRef.current;
2672
- if (current && wantPinRef.current && !hasNewerRef.current) {
2579
+ ) : null}
2580
+ {questionNav ? (
2581
+ <motion.button
2582
+ key="question-nav"
2583
+ layout="position"
2584
+ {...NAV_PILL_MOTION}
2585
+ type="button"
2586
+ data-og-question-nav=""
2587
+ data-og-jump-to-question=""
2588
+ onClick={() => jumpToQuestion(questionNav.key)}
2589
+ className={NAV_PILL_CLASS}
2590
+ >
2591
+ <ArrowUpIcon aria-hidden className="size-3.5 shrink-0" />
2592
+ Back to your message
2593
+ </motion.button>
2594
+ ) : null}
2595
+ {jumpPillShown ? (
2596
+ <motion.button
2597
+ key="jump-to-latest"
2598
+ layout="position"
2599
+ {...NAV_PILL_MOTION}
2600
+ type="button"
2601
+ data-og-jump-to-latest=""
2602
+ onClick={() => {
2603
+ // Returning to the tip explicitly releases reader-owned
2604
+ // prose. Clear only this timeline's selection before the
2605
+ // synchronous ownership check on the next commit.
2606
+ const viewport = scrollRef.current;
2607
+ const selection = viewport?.ownerDocument.getSelection();
2608
+ if (
2609
+ selection &&
2610
+ !selection.isCollapsed &&
2611
+ (viewport?.contains(selection.anchorNode) ||
2612
+ viewport?.contains(selection.focusNode))
2613
+ )
2614
+ selection.removeAllRanges();
2615
+ disclosureKeepsUnpinnedRef.current = false;
2616
+ if (hasNewer) {
2617
+ // Do not pin against the current history page — its bottom
2618
+ // is not the tip. The pin + snap run when the tip window
2619
+ // actually lands (`hasNewer` flips false).
2620
+ wantPinRef.current = true;
2621
+ const node = scrollRef.current;
2622
+ if (onJumpToLatest) {
2623
+ void Promise.resolve(onJumpToLatest()).then(
2624
+ () => {
2625
+ // Covers a host that flipped hasNewer before
2626
+ // resolving; otherwise the tip-window commit
2627
+ // consumes the flag.
2628
+ const current = scrollRef.current;
2629
+ if (current && wantPinRef.current && !hasNewerRef.current) {
2630
+ wantPinRef.current = false;
2631
+ applyPinned(true);
2632
+ snapToBottom(current);
2633
+ }
2634
+ },
2635
+ () => {
2636
+ // The tip reload failed (ordinary network error):
2637
+ // an armed latch would fire a surprise snap when
2638
+ // the reader later pages to the tip themselves.
2673
2639
  wantPinRef.current = false;
2674
- applyPinned(true);
2675
- snapToBottom(current);
2676
- }
2677
- },
2678
- () => {
2679
- // The tip reload failed (ordinary network error):
2680
- // an armed latch would fire a surprise snap when
2681
- // the reader later pages to the tip themselves.
2682
- wantPinRef.current = false;
2683
- },
2684
- );
2685
- } else if (node) {
2686
- // No tip reload available: jump within the in-memory
2687
- // window so the newer sentinel can page forward; the
2688
- // latch pins if the tip window eventually lands.
2640
+ },
2641
+ );
2642
+ } else if (node) {
2643
+ // No tip reload available: jump within the in-memory
2644
+ // window so the newer sentinel can page forward; the
2645
+ // latch pins if the tip window eventually lands.
2646
+ snapToBottom(node);
2647
+ }
2648
+ return;
2649
+ }
2650
+ const node = scrollRef.current;
2651
+ if (node) {
2652
+ applyPinned(true);
2689
2653
  snapToBottom(node);
2690
2654
  }
2691
- return;
2692
- }
2693
- const node = scrollRef.current;
2694
- if (node) {
2695
- applyPinned(true);
2696
- snapToBottom(node);
2697
- }
2698
- }}
2699
- className={cn(
2700
- "absolute bottom-4 w-fit",
2701
- JUMP_PILL_PLACEMENT_CLASS[jumpPillPlacement.placement],
2702
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
2703
- "text-og-control font-medium text-og-fg shadow-og-md backdrop-blur",
2704
- "hover:border-og-border-strong",
2705
- )}
2706
- >
2707
- <ArrowDownIcon className="size-3.5" />
2708
- Jump to latest
2709
- </motion.button>
2710
- ) : null}
2711
- </AnimatePresence>
2655
+ }}
2656
+ className={NAV_PILL_CLASS}
2657
+ >
2658
+ <ArrowDownIcon aria-hidden className="size-3.5 shrink-0" />
2659
+ Jump to latest
2660
+ </motion.button>
2661
+ ) : null}
2662
+ </AnimatePresence>
2663
+ </div>
2712
2664
  </div>
2713
2665
  </TimelineAnnotationSourceRootContext.Provider>
2714
2666
  </TooltipProvider>
@@ -161,19 +161,6 @@ export type ModelPolicyPickerProps = {
161
161
 
162
162
  const SLIDE_EASE = [0.22, 1, 0.36, 1] as const;
163
163
 
164
- // Solid facets keep the brand legible at the picker’s 14px icon size.
165
- function OpenGeniMark(props: SVGProps<SVGSVGElement>) {
166
- return (
167
- <svg viewBox="0 0 140 133" fill="currentColor" aria-hidden="true" {...props}>
168
- <g transform="translate(-17.5,-20.999893188476562) scale(1.75)">
169
- <g transform="translate(0,-952.36218)">
170
- <path d="m 60.7828,964.36215 27.1809,0.8834 -27.1809,25.9958 z m -1.9745,1.4513 0,26.7845 -25.2681,0 c 8.6166,-8.7334 16.8796,-17.8103 25.2681,-26.7845 z m 27.7053,3.628 3.4864,1.1989 -12.5877,7.4768 z m -68.1835,2.9656 5.5226,0 12.8654,14.0705 -5.9854,6.1204 -12.4026,0 c 9e-4,-6.7347 0,-13.4597 0,-20.1909 z m -1.9746,1.2304 0,5.8364 -6.3555,0 z m 3.363,20.9796 38.627,0 -10.7675,29.43465 z m 39.0898,4.54286 0,41.20229 -12.5878,-6.8775 c 4.1972,-11.443 8.3886,-22.879 12.5878,-34.32479 z" />
171
- </g>
172
- </g>
173
- </svg>
174
- );
175
- }
176
-
177
164
  function ChatGptMark(props: SVGProps<SVGSVGElement>) {
178
165
  return (
179
166
  <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
@@ -189,7 +176,7 @@ export function BillingClassMark(props: {
189
176
  "aria-label"?: string | undefined;
190
177
  }) {
191
178
  const labels: Record<PickerBillingClass, string> = {
192
- opengeni_credits: "Opengeni",
179
+ opengeni_credits: "Models",
193
180
  external: "External provider",
194
181
  codex_subscription: "Codex",
195
182
  supergrok_subscription: "SuperGrok",
@@ -217,7 +204,7 @@ export function BillingClassMark(props: {
217
204
  {props.presentation?.icon !== undefined ? (
218
205
  props.presentation.icon
219
206
  ) : props.billingClass === "opengeni_credits" ? (
220
- <OpenGeniMark className={mark} />
207
+ <SparklesIcon className={mark} aria-hidden />
221
208
  ) : props.billingClass === "external" ? (
222
209
  <Globe2Icon className={mark} aria-hidden />
223
210
  ) : props.billingClass === "codex_subscription" ? (
@@ -2,6 +2,7 @@ import type { OpenGeniClient } from "@opengeni/sdk";
2
2
  import { MenuIcon, SendIcon, XIcon } from "lucide-react";
3
3
  import { useCallback, useRef, useState, type CSSProperties, type FormEvent } from "react";
4
4
  import { cn } from "../lib/cn";
5
+ import { useErrorMessage } from "../lib/error-message";
5
6
  import { useOpenGeni, type ClientOverride } from "../session-context";
6
7
  import { SessionConversation, type SessionConversationProps } from "./session-conversation";
7
8
  import { SessionList, type SessionListLabels, type SessionListProps } from "./session-list";
@@ -198,7 +199,8 @@ function NewChat({
198
199
  }) {
199
200
  const [text, setText] = useState("");
200
201
  const [sending, setSending] = useState(false);
201
- const [error, setError] = useState<string | null>(null);
202
+ const [error, setError] = useState<{ cause: unknown } | null>(null);
203
+ const formatError = useErrorMessage();
202
204
  // One key per draft, so a retried send returns the same chat.
203
205
  const idempotencyKey = useRef(crypto.randomUUID());
204
206
 
@@ -214,7 +216,7 @@ function NewChat({
214
216
  setText("");
215
217
  onCreated(id);
216
218
  } catch (cause) {
217
- setError(cause instanceof Error ? cause.message : String(cause));
219
+ setError({ cause });
218
220
  } finally {
219
221
  setSending(false);
220
222
  }
@@ -223,12 +225,17 @@ function NewChat({
223
225
  return (
224
226
  <form
225
227
  onSubmit={(event) => void submit(event)}
226
- className="flex h-full flex-col justify-end gap-2 p-3"
228
+ className="box-border flex h-full flex-col justify-end gap-2 p-3"
227
229
  data-og-new-chat-composer=""
228
230
  >
229
231
  {error ? (
230
232
  <p role="alert" className="text-og-xs text-og-status-failed">
231
- {error}
233
+ {formatError(
234
+ error.cause,
235
+ error.cause instanceof Error && error.cause.message === labels.newChatUnavailable
236
+ ? labels.newChatUnavailable
237
+ : undefined,
238
+ )}
232
239
  </p>
233
240
  ) : null}
234
241
  <div className="flex items-end gap-2 rounded-lg border border-og-border bg-og-surface-1 p-2">
@@ -1,6 +1,7 @@
1
1
  import { Loader2Icon, RotateCwIcon } from "lucide-react";
2
2
 
3
3
  import type { UseTurnQueueResult } from "../hooks/use-turn-queue";
4
+ import { useErrorMessage } from "../lib/error-message";
4
5
 
5
6
  type QueueStateProps = {
6
7
  queue: UseTurnQueueResult;
@@ -60,6 +61,7 @@ export function QueueErrorAlert({
60
61
  queue,
61
62
  dividerBefore = false,
62
63
  }: QueueStateProps & { dividerBefore?: boolean }) {
64
+ const formatError = useErrorMessage();
63
65
  return (
64
66
  <div className={`${dividerBefore ? "border-t border-border" : ""} p-2`}>
65
67
  <div
@@ -74,7 +76,7 @@ export function QueueErrorAlert({
74
76
  data-testid="queue-error-message"
75
77
  dir="auto"
76
78
  >
77
- {(queue.mutationError ?? queue.error)?.message}
79
+ {formatError(queue.mutationError ?? queue.error)}
78
80
  </span>
79
81
  <button
80
82
  type="button"
@@ -73,6 +73,7 @@ import type { ComposerOptimisticMessage, ComposerState } from "../hooks/use-comp
73
73
  import type { UseGoalResult } from "../hooks/use-goal";
74
74
  import type { UseTurnQueueResult } from "../hooks/use-turn-queue";
75
75
  import { cn } from "../lib/cn";
76
+ import { useErrorMessage } from "../lib/error-message";
76
77
  import { formatClockTime } from "../lib/format";
77
78
  import { requestQueueDraftEdit } from "./queue-draft-policy";
78
79
  import { QUEUE_ITEM_CONTENT_UNAVAILABLE, queueItemContent } from "./queue-item-content";
@@ -453,6 +454,7 @@ export function SessionChrome({
453
454
  defaultActive = null,
454
455
  onActiveChange,
455
456
  }: SessionChromeProps) {
457
+ const formatError = useErrorMessage();
456
458
  const [activityRequested, setActivityOpen] = useState(
457
459
  Boolean(defaultActive && ["incoming", "agents", "commands"].includes(defaultActive)),
458
460
  );
@@ -1192,7 +1194,7 @@ export function SessionChrome({
1192
1194
  </AnimatePresence>
1193
1195
  {goal?.mutationError ? (
1194
1196
  <p role="alert" className="px-3 pb-2 text-og-xs text-og-danger">
1195
- Goal action not confirmed. {goal.mutationError.message}
1197
+ Goal action not confirmed. {formatError(goal.mutationError)}
1196
1198
  </p>
1197
1199
  ) : null}
1198
1200
  <div
@@ -1667,6 +1669,7 @@ function GoalPanel({
1667
1669
  elapsed: string | null;
1668
1670
  readOnly: boolean;
1669
1671
  }) {
1672
+ const formatError = useErrorMessage();
1670
1673
  const record = goal.goal;
1671
1674
  if (!record) return null;
1672
1675
  const canToggle = !readOnly && (record.status === "active" || record.status === "paused");
@@ -1699,7 +1702,7 @@ function GoalPanel({
1699
1702
  ) : null}
1700
1703
  {record.continuation?.lastError ? (
1701
1704
  <p className="rounded-og-sm bg-og-status-waiting/10 px-1.5 py-1 text-og-xs leading-4 text-og-status-waiting">
1702
- {record.continuation.lastError}
1705
+ {formatError(record.continuation.lastError)}
1703
1706
  </p>
1704
1707
  ) : null}
1705
1708
  <div className="flex flex-wrap items-center justify-between gap-1.5 pt-0.5">
@@ -2,6 +2,7 @@ import { ChevronRightIcon, Loader2Icon, SquareIcon } from "lucide-react";
2
2
  import { useState } from "react";
3
3
  import type { UseSessionBackgroundCommandsResult } from "../hooks/use-session-background-commands";
4
4
  import { formatClockTime } from "../lib/format";
5
+ import { useErrorMessage } from "../lib/error-message";
5
6
 
6
7
  /** The current session's live commands. Settled commands belong to the timeline. */
7
8
  export function SessionCommandsPanel({
@@ -11,6 +12,7 @@ export function SessionCommandsPanel({
11
12
  commands: UseSessionBackgroundCommandsResult;
12
13
  readOnly?: boolean;
13
14
  }) {
15
+ const formatError = useErrorMessage();
14
16
  const [pending, setPending] = useState<string | null>(null);
15
17
  const [error, setError] = useState<string | null>(null);
16
18
  const active = state.commands.filter(
@@ -22,7 +24,7 @@ export function SessionCommandsPanel({
22
24
  try {
23
25
  await state.cancel(id);
24
26
  } catch (cause) {
25
- setError(cause instanceof Error ? cause.message : "Stop was not confirmed. Try again.");
27
+ setError(formatError(cause));
26
28
  } finally {
27
29
  setPending(null);
28
30
  }
@@ -12,7 +12,7 @@ import {
12
12
  import type { SiteSnapshotClient } from "./artifacts/chat-interactive-block";
13
13
  import { useOpenGeni, type ClientOverride } from "../session-context";
14
14
  import { useWorkspaceModelCatalog } from "../hooks/use-available-models";
15
- import { ModelPolicyPicker } from "./model-policy-picker";
15
+ import { ModelPolicyPicker, type ModelPolicyPickerProps } from "./model-policy-picker";
16
16
  import { useSessionEvents } from "../hooks/use-session-events";
17
17
  import { useSession } from "../hooks/use-session";
18
18
  import { useTurnQueue } from "../hooks/use-turn-queue";
@@ -37,6 +37,7 @@ import {
37
37
  import type { UserMessageDisclosureLabels } from "./user-message-body";
38
38
  import { conversationTimeline } from "../conversation-timeline";
39
39
  import { cn } from "../lib/cn";
40
+ import { useErrorMessage } from "../lib/error-message";
40
41
 
41
42
  export type SessionConversationProps = ClientOverride & {
42
43
  sessionId: string;
@@ -80,6 +81,8 @@ export type SessionConversationProps = ClientOverride & {
80
81
  * reports `modelSelection: false` (a host proxy that fixes the model policy).
81
82
  */
82
83
  modelPicker?: boolean | undefined;
84
+ /** Model-picker appearance only; visibility, policy and delivery remain owned here. */
85
+ modelPickerProps?: Pick<ModelPolicyPickerProps, "groupPresentation" | "messages"> | undefined;
83
86
  /** Localized actions for already-sent user-message disclosure. */
84
87
  userMessageDisclosureLabels?: UserMessageDisclosureLabels | undefined;
85
88
  loadSkillReview?: HumanInputSurfaceProps["loadSkillReview"];
@@ -110,6 +113,7 @@ function Conversation({
110
113
  allowanceExhaustedLabels,
111
114
  attachments: attachmentsRequested = true,
112
115
  modelPicker,
116
+ modelPickerProps,
113
117
  userMessageDisclosureLabels,
114
118
  loadSkillReview,
115
119
  client,
@@ -120,6 +124,7 @@ function Conversation({
120
124
  }: SessionConversationProps) {
121
125
  const scope = { client, workspaceId };
122
126
  const context = useOpenGeni(scope);
127
+ const formatError = useErrorMessage();
123
128
  const config = useClientConfigFlags(context.client);
124
129
  const showModelPicker = modelPicker ?? config.modelSelection;
125
130
  const catalog = useWorkspaceModelCatalog({
@@ -203,7 +208,7 @@ function Conversation({
203
208
  style={{ height }}
204
209
  data-og-conversation=""
205
210
  >
206
- {error && <p role="alert">{error.message}</p>}
211
+ {error && <p role="alert">{formatError(error)}</p>}
207
212
  <MessageTimeline
208
213
  renderMessageText={renderMessageText}
209
214
  resolveLink={links}
@@ -265,7 +270,7 @@ function Conversation({
265
270
  await human.respond(id, response);
266
271
  }}
267
272
  respondingRequestId={human.respondingRequestId}
268
- error={human.mutationError?.message}
273
+ error={human.mutationError ? formatError(human.mutationError) : null}
269
274
  autoFocus={false}
270
275
  />
271
276
  {terminal ? (
@@ -291,6 +296,8 @@ function Conversation({
291
296
  composerProps?.controlsStart ??
292
297
  (showModelPicker && composer.policy && (
293
298
  <ModelPolicyPicker
299
+ groupPresentation={modelPickerProps?.groupPresentation}
300
+ messages={modelPickerProps?.messages}
294
301
  rows={catalog.rows}
295
302
  model={composer.policy.model}
296
303
  effort={composer.policy.reasoningEffort}