@opengeni/react 7.6.1 → 7.7.0-canary.37090756480001

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 (142) hide show
  1. package/README.md +19 -2
  2. package/dist/{browser-viewer-5X47I6T2.js → browser-viewer-63TXMINB.js} +6 -5
  3. package/dist/{chunk-UINDK6C3.js → chunk-2QUSQVF2.js} +3 -3
  4. package/dist/{chunk-CHPKSLPN.js → chunk-6ZSWSH4I.js} +3 -1
  5. package/dist/chunk-6ZSWSH4I.js.map +1 -0
  6. package/dist/{chunk-KGENSZKS.js → chunk-AAG4R3QN.js} +4 -3
  7. package/dist/chunk-AAG4R3QN.js.map +1 -0
  8. package/dist/{chunk-Z3DISEQN.js → chunk-CWWKTNKK.js} +35 -18
  9. package/dist/chunk-CWWKTNKK.js.map +1 -0
  10. package/dist/{chunk-5EXGU7UF.js → chunk-DOJWJQ3F.js} +3 -3
  11. package/dist/{chunk-WZ55DVGK.js → chunk-ELCE5SXV.js} +8 -4
  12. package/dist/chunk-ELCE5SXV.js.map +1 -0
  13. package/dist/{chunk-YSMMDOOE.js → chunk-GISEVGS6.js} +10 -2
  14. package/dist/chunk-GISEVGS6.js.map +1 -0
  15. package/dist/{chunk-YRYF2GLG.js → chunk-HNFCAQ5R.js} +39 -31
  16. package/dist/chunk-HNFCAQ5R.js.map +1 -0
  17. package/dist/{chunk-BI57CQUV.js → chunk-I7IWRQLN.js} +2 -2
  18. package/dist/{chunk-XA6R7DJQ.js → chunk-IQUCRAT7.js} +3 -3
  19. package/dist/{chunk-DV2DBNPQ.js → chunk-Q5QBEZIQ.js} +48 -29
  20. package/dist/chunk-Q5QBEZIQ.js.map +1 -0
  21. package/dist/{chunk-FGBCYQOT.js → chunk-RDKBXMWX.js} +120 -74
  22. package/dist/chunk-RDKBXMWX.js.map +1 -0
  23. package/dist/{chunk-45GE35O7.js → chunk-TKB4SB44.js} +10 -2
  24. package/dist/chunk-TKB4SB44.js.map +1 -0
  25. package/dist/{chunk-YCGBREUV.js → chunk-TOAH2BKA.js} +6 -2
  26. package/dist/{chunk-YCGBREUV.js.map → chunk-TOAH2BKA.js.map} +1 -1
  27. package/dist/chunk-UGLEYEMP.js +33 -0
  28. package/dist/chunk-UGLEYEMP.js.map +1 -0
  29. package/dist/{chunk-UJRYEWZI.js → chunk-UTRTG6LH.js} +48 -8
  30. package/dist/chunk-UTRTG6LH.js.map +1 -0
  31. package/dist/{chunk-VGIKFIO2.js → chunk-XOAMRDDK.js} +6 -3
  32. package/dist/chunk-XOAMRDDK.js.map +1 -0
  33. package/dist/{chunk-6NEZPF53.js → chunk-YN7I3STW.js} +153 -32
  34. package/dist/chunk-YN7I3STW.js.map +1 -0
  35. package/dist/{chunk-KZ73RL76.js → chunk-YQEAA6LT.js} +7 -3
  36. package/dist/chunk-YQEAA6LT.js.map +1 -0
  37. package/dist/{chunk-LZ3KHA55.js → chunk-ZYALTAEQ.js} +329 -339
  38. package/dist/chunk-ZYALTAEQ.js.map +1 -0
  39. package/dist/client.d.ts +4 -1
  40. package/dist/commands/types.d.ts +2 -0
  41. package/dist/components/approval-surface.d.ts +15 -0
  42. package/dist/composer.js +8 -7
  43. package/dist/{computer-viewer-7GF35SNY.js → computer-viewer-X2MBRZY4.js} +5 -4
  44. package/dist/connect.js +3 -3
  45. package/dist/hooks/use-browser-session.d.ts +4 -0
  46. package/dist/hooks/use-composer.d.ts +2 -0
  47. package/dist/hooks/use-computer-session.d.ts +2 -0
  48. package/dist/hooks/use-file-attachments.d.ts +2 -0
  49. package/dist/hooks/use-session-events.d.ts +1 -1
  50. package/dist/hooks/use-workspace-sessions.d.ts +15 -5
  51. package/dist/index.d.ts +2 -2
  52. package/dist/index.js +28 -21
  53. package/dist/index.js.map +1 -1
  54. package/dist/interaction.d.ts +1 -1
  55. package/dist/interaction.js +6 -5
  56. package/dist/interaction.js.map +1 -1
  57. package/dist/{latest-question-3BJGKQQ6.js → latest-question-CNEZ5KAN.js} +3 -3
  58. package/dist/lib/error-message.d.ts +7 -0
  59. package/dist/lib/format.d.ts +27 -3
  60. package/dist/lib/interaction-errors.d.ts +3 -0
  61. package/dist/machines.js +5 -4
  62. package/dist/{model-policy-picker-menu-PUTA5JOB.js → model-policy-picker-menu-JSA64TNN.js} +4 -4
  63. package/dist/model-policy.js +1 -1
  64. package/dist/provider.d.ts +5 -1
  65. package/dist/{queue-surface-implementation-2CAQAIHM.js → queue-surface-implementation-HENWYFWA.js} +3 -2
  66. package/dist/{queue-surface-implementation-2CAQAIHM.js.map → queue-surface-implementation-HENWYFWA.js.map} +1 -1
  67. package/dist/realtime.js +2 -2
  68. package/dist/{sandbox-files-AEJTWGT4.js → sandbox-files-BBC47SLD.js} +4 -3
  69. package/dist/session-ui.d.ts +1 -1
  70. package/dist/session-ui.js +13 -12
  71. package/dist/session-ui.js.map +1 -1
  72. package/dist/session.js +7 -6
  73. package/dist/usage.js +2 -1
  74. package/dist/usage.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/client.ts +5 -1
  77. package/src/commands/registry.ts +18 -15
  78. package/src/commands/types.ts +2 -0
  79. package/src/components/approval-surface.tsx +166 -42
  80. package/src/components/browser-viewer.tsx +38 -9
  81. package/src/components/composer.tsx +9 -2
  82. package/src/components/computer-viewer.tsx +160 -30
  83. package/src/components/generated-video-player.tsx +4 -2
  84. package/src/components/human-input-form.tsx +18 -5
  85. package/src/components/message-timeline.tsx +120 -168
  86. package/src/components/open-geni-chat.tsx +11 -4
  87. package/src/components/queue-surface-state.tsx +3 -1
  88. package/src/components/session-chrome.tsx +5 -2
  89. package/src/components/session-commands-panel.tsx +3 -1
  90. package/src/components/session-conversation.tsx +4 -2
  91. package/src/components/session-list.tsx +7 -5
  92. package/src/components/session-mcp-capability-card.tsx +11 -1
  93. package/src/hooks/internal.ts +3 -2
  94. package/src/hooks/use-browser-session.ts +115 -79
  95. package/src/hooks/use-composer.ts +70 -31
  96. package/src/hooks/use-computer-session.ts +57 -6
  97. package/src/hooks/use-file-attachments.ts +16 -3
  98. package/src/hooks/use-goal.ts +2 -1
  99. package/src/hooks/use-session-capabilities.ts +3 -2
  100. package/src/hooks/use-session-events.ts +15 -8
  101. package/src/hooks/use-slash-commands.ts +6 -11
  102. package/src/hooks/use-turn-queue.ts +2 -1
  103. package/src/hooks/use-workspace-sessions.ts +40 -7
  104. package/src/index.ts +7 -2
  105. package/src/interaction.ts +1 -0
  106. package/src/lib/error-message.ts +37 -0
  107. package/src/lib/format.ts +73 -9
  108. package/src/lib/interaction-errors.ts +15 -0
  109. package/src/model-policy.ts +5 -0
  110. package/src/provider.tsx +12 -4
  111. package/src/session-ui.ts +1 -1
  112. package/src/timeline/projection.ts +12 -1
  113. package/styles/compiled.css +14 -30
  114. package/dist/chunk-45GE35O7.js.map +0 -1
  115. package/dist/chunk-6NEZPF53.js.map +0 -1
  116. package/dist/chunk-CHPKSLPN.js.map +0 -1
  117. package/dist/chunk-DV2DBNPQ.js.map +0 -1
  118. package/dist/chunk-FGBCYQOT.js.map +0 -1
  119. package/dist/chunk-KGENSZKS.js.map +0 -1
  120. package/dist/chunk-KZ73RL76.js.map +0 -1
  121. package/dist/chunk-LZ3KHA55.js.map +0 -1
  122. package/dist/chunk-UJRYEWZI.js.map +0 -1
  123. package/dist/chunk-VGIKFIO2.js.map +0 -1
  124. package/dist/chunk-WZ55DVGK.js.map +0 -1
  125. package/dist/chunk-YRYF2GLG.js.map +0 -1
  126. package/dist/chunk-YSMMDOOE.js.map +0 -1
  127. package/dist/chunk-Z3DISEQN.js.map +0 -1
  128. package/dist/components/timeline-pill-geometry.d.ts +0 -8
  129. package/dist/components/timeline-pill-placement.d.ts +0 -24
  130. package/dist/timeline-pill-geometry-Z23V243M.js +0 -110
  131. package/dist/timeline-pill-geometry-Z23V243M.js.map +0 -1
  132. package/src/components/timeline-pill-geometry.ts +0 -169
  133. package/src/components/timeline-pill-placement.ts +0 -99
  134. /package/dist/{browser-viewer-5X47I6T2.js.map → browser-viewer-63TXMINB.js.map} +0 -0
  135. /package/dist/{chunk-UINDK6C3.js.map → chunk-2QUSQVF2.js.map} +0 -0
  136. /package/dist/{chunk-5EXGU7UF.js.map → chunk-DOJWJQ3F.js.map} +0 -0
  137. /package/dist/{chunk-BI57CQUV.js.map → chunk-I7IWRQLN.js.map} +0 -0
  138. /package/dist/{chunk-XA6R7DJQ.js.map → chunk-IQUCRAT7.js.map} +0 -0
  139. /package/dist/{computer-viewer-7GF35SNY.js.map → computer-viewer-X2MBRZY4.js.map} +0 -0
  140. /package/dist/{latest-question-3BJGKQQ6.js.map → latest-question-CNEZ5KAN.js.map} +0 -0
  141. /package/dist/{model-policy-picker-menu-PUTA5JOB.js.map → model-policy-picker-menu-JSA64TNN.js.map} +0 -0
  142. /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>
@@ -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
  }
@@ -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;
@@ -120,6 +121,7 @@ function Conversation({
120
121
  }: SessionConversationProps) {
121
122
  const scope = { client, workspaceId };
122
123
  const context = useOpenGeni(scope);
124
+ const formatError = useErrorMessage();
123
125
  const config = useClientConfigFlags(context.client);
124
126
  const showModelPicker = modelPicker ?? config.modelSelection;
125
127
  const catalog = useWorkspaceModelCatalog({
@@ -203,7 +205,7 @@ function Conversation({
203
205
  style={{ height }}
204
206
  data-og-conversation=""
205
207
  >
206
- {error && <p role="alert">{error.message}</p>}
208
+ {error && <p role="alert">{formatError(error)}</p>}
207
209
  <MessageTimeline
208
210
  renderMessageText={renderMessageText}
209
211
  resolveLink={links}
@@ -265,7 +267,7 @@ function Conversation({
265
267
  await human.respond(id, response);
266
268
  }}
267
269
  respondingRequestId={human.respondingRequestId}
268
- error={human.mutationError?.message}
270
+ error={human.mutationError ? formatError(human.mutationError) : null}
269
271
  autoFocus={false}
270
272
  />
271
273
  {terminal ? (
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, type FormEvent } from "react";
4
4
  import { useWorkspaceSessions } from "../hooks/use-workspace-sessions";
5
5
  import { cn } from "../lib/cn";
6
6
  import { formatRelativeTime } from "../lib/format";
7
+ import { useErrorMessage } from "../lib/error-message";
7
8
  import { useOpenGeni, type ClientOverride } from "../session-context";
8
9
 
9
10
  export type SessionListLabels = {
@@ -90,7 +91,8 @@ export function SessionList({
90
91
  }, [refresh, refreshKey]);
91
92
  const [editing, setEditing] = useState<{ id: string; title: string } | null>(null);
92
93
  const [busy, setBusy] = useState<string | null>(null);
93
- const [error, setError] = useState<string | null>(null);
94
+ const [error, setError] = useState<{ cause: unknown } | null>(null);
95
+ const formatError = useErrorMessage();
94
96
  const archiveClient = context.client as unknown as ArchiveClient;
95
97
  const canArchive = archive && typeof archiveClient.updateSessionArchive === "function";
96
98
 
@@ -101,7 +103,7 @@ export function SessionList({
101
103
  await action();
102
104
  await list.refresh();
103
105
  } catch (cause) {
104
- setError(cause instanceof Error ? cause.message : String(cause));
106
+ setError({ cause });
105
107
  } finally {
106
108
  setBusy(null);
107
109
  }
@@ -148,16 +150,16 @@ export function SessionList({
148
150
  </button>
149
151
  ) : null}
150
152
  </div>
151
- {error ? (
153
+ {error || list.error ? (
152
154
  <p role="alert" className="px-2 text-og-xs text-og-status-failed">
153
- {error}
155
+ {formatError(error ? error.cause : list.error)}
154
156
  </p>
155
157
  ) : null}
156
158
  <ul className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto px-1 pb-2">
157
159
  {list.loading && sessions.length === 0 ? (
158
160
  <li className="px-2 py-2 text-og-xs text-og-fg-subtle">{labels.loading}</li>
159
161
  ) : null}
160
- {!list.loading && sessions.length === 0 ? (
162
+ {!list.loading && !list.error && sessions.length === 0 ? (
161
163
  <li className="px-2 py-2 text-og-xs text-og-fg-subtle">{labels.empty}</li>
162
164
  ) : null}
163
165
  {sessions.map((session) => {
@@ -127,7 +127,17 @@ function ScopedCard({
127
127
  ]);
128
128
  if (!invocation || invocation.signal.aborted || lifetime.current !== invocation || !active())
129
129
  return null;
130
- const resolved = catalog.items.find((entry) => entry.id === capabilityId);
130
+ let resolved = catalog.items.find((entry) => entry.id === capabilityId);
131
+ // Custom catalog entries may not declare authentication. Discover it from
132
+ // the live catalog's endpoint, never from recommendation/host copy.
133
+ if (resolved?.kind === "mcp" && !resolved.enabled && !resolved.authKind) {
134
+ const endpoint = resolved.mcpUrl ?? resolved.endpointUrl;
135
+ if (endpoint) {
136
+ const inspected = await client.inspectMcpAuthentication(workspaceId, endpoint);
137
+ if (invocation.signal.aborted || lifetime.current !== invocation || !active()) return null;
138
+ if (inspected.kind === "oauth2") resolved = { ...resolved, authKind: "oauth2" };
139
+ }
140
+ }
131
141
  if (!resolved || resolved.kind !== "mcp" || resolved.authKind !== "oauth2")
132
142
  throw new Error(
133
143
  "This recommendation is not an available OAuth MCP integration. Review the current connection catalog.",
@@ -1,6 +1,7 @@
1
1
  import type { SessionEvent } from "@opengeni/sdk";
2
2
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
3
3
  import type { EmbeddedSessionEventClientLike } from "../client";
4
+ import { normalizeError } from "../lib/error-message";
4
5
 
5
6
  const HIDDEN_STREAM_GRACE_MS = 2_000;
6
7
 
@@ -116,7 +117,7 @@ export function usePolledValue<T>(
116
117
  activeLoadRef.current === load &&
117
118
  !requestAbort.signal.aborted
118
119
  ) {
119
- setError(cause instanceof Error ? cause : new Error(String(cause)));
120
+ setError(normalizeError(cause));
120
121
  setLoading(false);
121
122
  }
122
123
  } finally {
@@ -258,7 +259,7 @@ export function useMutationRunner(identity: unknown = undefined): MutationState
258
259
  generation.current === ownedGeneration &&
259
260
  Object.is(identityRef.current, ownedIdentity)
260
261
  ) {
261
- setMutationError(cause instanceof Error ? cause : new Error(String(cause)));
262
+ setMutationError(normalizeError(cause));
262
263
  }
263
264
  return null;
264
265
  } finally {