@opengeni/react 3.1.0 → 3.3.2-canary.1

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 (73) hide show
  1. package/dist/accounts.js +87 -11
  2. package/dist/accounts.js.map +1 -1
  3. package/dist/{browser-viewer-AEFHUYZK.js → browser-viewer-MKKFRRRV.js} +3 -3
  4. package/dist/{chunk-YFKJ7VZH.js → chunk-7EAFGICR.js} +45 -11
  5. package/dist/chunk-7EAFGICR.js.map +1 -0
  6. package/dist/{chunk-W6NFJXSA.js → chunk-7KBJLTKS.js} +52 -12
  7. package/dist/chunk-7KBJLTKS.js.map +1 -0
  8. package/dist/{chunk-YJFGAFBD.js → chunk-E5KISKFG.js} +241 -97
  9. package/dist/chunk-E5KISKFG.js.map +1 -0
  10. package/dist/{chunk-MZ2G2XBA.js → chunk-IOLVN74D.js} +31 -4
  11. package/dist/chunk-IOLVN74D.js.map +1 -0
  12. package/dist/{chunk-D3UIAJKY.js → chunk-JAQ5K7W6.js} +3 -2
  13. package/dist/{chunk-D3UIAJKY.js.map → chunk-JAQ5K7W6.js.map} +1 -1
  14. package/dist/{chunk-LAKLF4PA.js → chunk-VTOREEXF.js} +8 -7
  15. package/dist/chunk-VTOREEXF.js.map +1 -0
  16. package/dist/{chunk-X4A66EWZ.js → chunk-X5S426G2.js} +38 -44
  17. package/dist/chunk-X5S426G2.js.map +1 -0
  18. package/dist/{chunk-HQVCKCLG.js → chunk-YG4QEQF5.js} +354 -255
  19. package/dist/chunk-YG4QEQF5.js.map +1 -0
  20. package/dist/{chunk-XXWH2JLC.js → chunk-YVCHAQZY.js} +2 -2
  21. package/dist/{chunk-RGALCTWO.js → chunk-ZKNTK7IA.js} +13 -9
  22. package/dist/{chunk-RGALCTWO.js.map → chunk-ZKNTK7IA.js.map} +1 -1
  23. package/dist/{chunk-OAM4WAAM.js → chunk-ZPBTI3TE.js} +2 -2
  24. package/dist/components/fleet-tile.d.ts +2 -2
  25. package/dist/components/model-policy-picker.d.ts +9 -0
  26. package/dist/components/tip-follow.d.ts +16 -0
  27. package/dist/composer.js +6 -6
  28. package/dist/hooks/use-file-attachments.d.ts +2 -0
  29. package/dist/hooks/use-session-events.d.ts +3 -3
  30. package/dist/index.js +77 -69
  31. package/dist/index.js.map +1 -1
  32. package/dist/interaction.js +2 -2
  33. package/dist/machines.js +2 -2
  34. package/dist/model-policy.d.ts +2 -1
  35. package/dist/model-policy.js +1 -1
  36. package/dist/realtime.js +47 -8
  37. package/dist/realtime.js.map +1 -1
  38. package/dist/session-ui.js +4 -4
  39. package/dist/session.js +4 -4
  40. package/dist/timeline/entrance.d.ts +13 -1
  41. package/package.json +3 -2
  42. package/src/accounts.tsx +116 -14
  43. package/src/components/composer.tsx +15 -5
  44. package/src/components/fleet-tile.tsx +4 -15
  45. package/src/components/message-timeline.tsx +237 -101
  46. package/src/components/model-policy-picker.tsx +59 -13
  47. package/src/components/tip-follow.ts +52 -0
  48. package/src/components/tooltip.tsx +38 -5
  49. package/src/hooks/use-available-models.ts +20 -14
  50. package/src/hooks/use-composer.ts +49 -3
  51. package/src/hooks/use-file-attachments.ts +28 -2
  52. package/src/hooks/use-goal.ts +31 -7
  53. package/src/hooks/use-last-started-turn-policy.ts +11 -5
  54. package/src/hooks/use-session-events.ts +186 -61
  55. package/src/hooks/use-session-lineage.ts +20 -16
  56. package/src/hooks/use-session.ts +23 -19
  57. package/src/hooks/use-workspace-sessions.ts +31 -27
  58. package/src/lib/format.ts +8 -6
  59. package/src/model-policy.ts +45 -54
  60. package/src/realtime/realtime-control.tsx +55 -6
  61. package/src/timeline/entrance.tsx +28 -3
  62. package/src/timeline/projection.ts +4 -0
  63. package/styles/compiled.css +5 -0
  64. package/dist/chunk-HQVCKCLG.js.map +0 -1
  65. package/dist/chunk-LAKLF4PA.js.map +0 -1
  66. package/dist/chunk-MZ2G2XBA.js.map +0 -1
  67. package/dist/chunk-W6NFJXSA.js.map +0 -1
  68. package/dist/chunk-X4A66EWZ.js.map +0 -1
  69. package/dist/chunk-YFKJ7VZH.js.map +0 -1
  70. package/dist/chunk-YJFGAFBD.js.map +0 -1
  71. /package/dist/{browser-viewer-AEFHUYZK.js.map → browser-viewer-MKKFRRRV.js.map} +0 -0
  72. /package/dist/{chunk-XXWH2JLC.js.map → chunk-YVCHAQZY.js.map} +0 -0
  73. /package/dist/{chunk-OAM4WAAM.js.map → chunk-ZPBTI3TE.js.map} +0 -0
@@ -13,6 +13,7 @@ import { DropdownMenu } from "radix-ui";
13
13
  import {
14
14
  useEffect,
15
15
  useMemo,
16
+ useRef,
16
17
  useState,
17
18
  type CSSProperties,
18
19
  type ReactNode,
@@ -25,6 +26,7 @@ import {
25
26
  findPickerRow,
26
27
  groupPickerRowsByBillingClass,
27
28
  labelReasoningEffort,
29
+ payerSummaryForModel,
28
30
  projectClientModelRows,
29
31
  runnableLatencyModesForModel,
30
32
  type PickerBillingClass,
@@ -67,7 +69,7 @@ export const defaultModelPolicyPickerMessages: ModelPolicyPickerMessages = {
67
69
  external: "Provider terms and limits apply",
68
70
  codex_subscription: "ChatGPT / Codex plan",
69
71
  supergrok_subscription: "SuperGrok / xAI plan",
70
- byok: "Billed to your AI Gateway",
72
+ byok: "Billed to the workspace provider account",
71
73
  },
72
74
  };
73
75
 
@@ -157,7 +159,7 @@ export function BillingClassMark(props: {
157
159
  external: "External provider",
158
160
  codex_subscription: "Codex",
159
161
  supergrok_subscription: "SuperGrok",
160
- byok: "Bring your own key",
162
+ byok: "Workspace provider account",
161
163
  };
162
164
  const label = props["aria-label"] ?? labels[props.billingClass];
163
165
  const accessibility =
@@ -194,6 +196,18 @@ function isCodexModel(model: ClientModel): boolean {
194
196
  return model.id.startsWith("codex/") || model.source === "codex";
195
197
  }
196
198
 
199
+ function billingClassForMissingSelection(modelId: string): PickerBillingClass {
200
+ if (modelId.startsWith("workspace-gateway/")) return "byok";
201
+ if (modelId.startsWith("workspace-openrouter/")) return "byok";
202
+ // A deployment OpenRouter ID does not encode its workspace-facing cost.
203
+ // Missing rows therefore use the credits-safe rail instead of falsely
204
+ // claiming that an unknown former selection was externally funded.
205
+ if (modelId.startsWith("openrouter/")) return "opengeni_credits";
206
+ if (modelId.startsWith("codex/")) return "codex_subscription";
207
+ if (modelId.startsWith("supergrok/")) return "supergrok_subscription";
208
+ return "opengeni_credits";
209
+ }
210
+
197
211
  function applyCodexOnly(
198
212
  rows: ClientPickerModelRow[],
199
213
  codexOnly: boolean,
@@ -226,7 +240,11 @@ function defaultNavState(rows: ClientPickerModelRow[], modelId: string): PickerN
226
240
  ? { level: "models", rail: onlyRail, modelId: null }
227
241
  : { level: "providers", rail: null, modelId: null };
228
242
  }
229
- return { level: "thinking", rail: selected.billingClass, modelId: selected.id };
243
+ return {
244
+ level: "thinking",
245
+ rail: selected.billingClass,
246
+ modelId: selected.id,
247
+ };
230
248
  }
231
249
 
232
250
  function usePickerNavState(
@@ -351,7 +369,11 @@ export function ModelPolicyPickerMenu(
351
369
 
352
370
  const effectiveNav =
353
371
  nav.level === "providers" && groups.length === 1
354
- ? { level: "models" as const, rail: groups[0]!.billingClass, modelId: null }
372
+ ? {
373
+ level: "models" as const,
374
+ rail: groups[0]!.billingClass,
375
+ modelId: null,
376
+ }
355
377
  : nav;
356
378
  const activeGroup =
357
379
  effectiveNav.rail === null
@@ -415,14 +437,21 @@ export function ModelPolicyPickerMenu(
415
437
  <PickerNavRow
416
438
  key={`${row.billingClass}:${row.id}`}
417
439
  label={row.label}
418
- hint={row.unavailableReason ?? undefined}
440
+ hint={row.unavailableReason ?? payerSummaryForModel(row.catalog)}
419
441
  disabled={!row.selectable}
420
442
  title={row.unavailableReason ?? undefined}
421
443
  active={row.id === props.model}
422
444
  testId={`model-picker-choice-${row.id}`}
423
445
  onClick={() => {
424
446
  if (row.selectable) {
425
- go({ level: "thinking", rail: row.billingClass, modelId: row.id }, 1);
447
+ go(
448
+ {
449
+ level: "thinking",
450
+ rail: row.billingClass,
451
+ modelId: row.id,
452
+ },
453
+ 1,
454
+ );
426
455
  }
427
456
  }}
428
457
  />
@@ -541,21 +570,41 @@ export function ModelPolicyPickerMenu(
541
570
  );
542
571
  }
543
572
 
544
- export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
573
+ /** @internal Shared state seam kept outside the Radix portal for deterministic testing. */
574
+ export function useModelPolicyPickerState(props: ModelPolicyPickerProps) {
545
575
  const [uncontrolledOpen, setUncontrolledOpen] = useState(props.defaultOpen ?? false);
546
576
  const open = props.open ?? uncontrolledOpen;
577
+ const rows = effectiveRows(props);
578
+ const [nav, setNav] = usePickerNavState(props.sessionKey, rows, props.model);
579
+ const previousControlledOpen = useRef(props.open);
580
+ useEffect(() => {
581
+ const wasOpen = previousControlledOpen.current;
582
+ previousControlledOpen.current = props.open;
583
+ if (props.open === true && wasOpen !== true) {
584
+ setNav(defaultNavState(rows, props.model));
585
+ }
586
+ // Reset only on the controlled closed-to-open edge; ordinary rerenders
587
+ // deliberately preserve the current drill-down page.
588
+ // eslint-disable-next-line react-hooks/exhaustive-deps
589
+ }, [props.open]);
547
590
  const setOpen = (next: boolean) => {
591
+ if (next) {
592
+ setNav(defaultNavState(rows, props.model));
593
+ }
548
594
  if (props.open === undefined) setUncontrolledOpen(next);
549
595
  props.onOpenChange?.(next);
550
596
  };
597
+ return { open, setOpen, rows, nav, setNav };
598
+ }
599
+
600
+ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
551
601
  const trigger = usePortalTokenSource<HTMLButtonElement>();
552
602
  const portalStyle = usePortalTokenStyle(trigger.source);
553
603
  const messages = useMemo(
554
604
  () => ({ ...defaultModelPolicyPickerMessages, ...props.messages }),
555
605
  [props.messages],
556
606
  );
557
- const rows = effectiveRows(props);
558
- const [nav, setNav] = usePickerNavState(props.sessionKey, rows, props.model);
607
+ const { open, setOpen, rows, nav, setNav } = useModelPolicyPickerState(props);
559
608
  const selected = findPickerRow(rows, props.model);
560
609
 
561
610
  if (props.loading) {
@@ -584,10 +633,7 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
584
633
  )}
585
634
  >
586
635
  <BillingClassMark
587
- billingClass={
588
- selected?.billingClass ??
589
- (props.model.startsWith("codex/") ? "codex_subscription" : "opengeni_credits")
590
- }
636
+ billingClass={selected?.billingClass ?? billingClassForMissingSelection(props.model)}
591
637
  className="text-og-fg"
592
638
  />
593
639
  <span className="og-model-policy-label-full min-w-0 truncate font-medium text-og-fg max-sm:hidden">
@@ -200,6 +200,11 @@ export function tipFollowNoteGrowth(
200
200
  if (previous <= 0) {
201
201
  return { ...state, lastHeight: height };
202
202
  }
203
+ if (height < previous && previous - height <= TIP_FOLLOW_SHRINK_EPS_PX) {
204
+ // Match viewport-shrink deadband memory: the shell retains the paired
205
+ // pre-shrink scrollTop and this state retains the corresponding height.
206
+ return state;
207
+ }
203
208
  if (height <= previous) {
204
209
  return { ...state, lastHeight: height };
205
210
  }
@@ -441,6 +446,53 @@ export function tipFollowCompensateShrink(
441
446
  return Math.max(0, Math.min(maxScroll, scrollTop - delta));
442
447
  }
443
448
 
449
+ export type TipFollowContentShrinkBaseline = {
450
+ scrollHeight: number;
451
+ scrollTop: number;
452
+ };
453
+
454
+ export type TipFollowContentShrinkObservation = {
455
+ baseline: TipFollowContentShrinkBaseline | null;
456
+ compensatedScrollTop: number | null;
457
+ };
458
+
459
+ /**
460
+ * Retain the geometry from the first sub-epsilon content-shrink frame until
461
+ * the cumulative collapse crosses the deadband. Browsers may clamp scrollTop
462
+ * after every tiny frame, so compensating from the latest DOM top would count
463
+ * those already-applied clamps twice and make the result animation-cadence
464
+ * dependent.
465
+ */
466
+ export function tipFollowObserveContentShrink(
467
+ baseline: TipFollowContentShrinkBaseline | null,
468
+ previousHeight: number,
469
+ previousScrollTop: number,
470
+ nextHeight: number,
471
+ clientHeight: number,
472
+ epsPx: number = TIP_FOLLOW_SHRINK_EPS_PX,
473
+ ): TipFollowContentShrinkObservation {
474
+ if (previousHeight <= 0 || nextHeight >= previousHeight) {
475
+ return { baseline: null, compensatedScrollTop: null };
476
+ }
477
+ const retained = baseline ?? {
478
+ scrollHeight: previousHeight,
479
+ scrollTop: previousScrollTop,
480
+ };
481
+ if (retained.scrollHeight - nextHeight <= epsPx) {
482
+ return { baseline: retained, compensatedScrollTop: null };
483
+ }
484
+ return {
485
+ baseline: null,
486
+ compensatedScrollTop: tipFollowCompensateShrink(
487
+ retained.scrollTop,
488
+ retained.scrollHeight,
489
+ nextHeight,
490
+ clientHeight,
491
+ epsPx,
492
+ ),
493
+ };
494
+ }
495
+
444
496
  /**
445
497
  * Viewport shrank (SessionChrome / composer / window). maxScroll rises by ≈Δc;
446
498
  * keep the same tip distance: scrollTop += Δc. Without this, pinned follow only
@@ -8,10 +8,19 @@
8
8
  * tips and look like a blank white bubble.
9
9
  */
10
10
  import { Tooltip as TooltipPrimitive } from "radix-ui";
11
- import { createContext, useContext, type ComponentProps, type CSSProperties } from "react";
11
+ import {
12
+ createContext,
13
+ useContext,
14
+ useLayoutEffect,
15
+ useMemo,
16
+ useRef,
17
+ useState,
18
+ type ComponentProps,
19
+ type CSSProperties,
20
+ } from "react";
12
21
 
13
22
  import { cn } from "../lib/cn";
14
- import { usePortalTokenSource, usePortalTokenStyle } from "../lib/use-portal-token-style";
23
+ import { usePortalTokenStyle } from "../lib/use-portal-token-style";
15
24
 
16
25
  type TooltipSourceContextValue = {
17
26
  source: HTMLElement | null;
@@ -28,10 +37,34 @@ function TooltipProvider({
28
37
  }
29
38
 
30
39
  function Tooltip({ children, ...props }: ComponentProps<typeof TooltipPrimitive.Root>) {
31
- const source = usePortalTokenSource<HTMLElement>();
40
+ const { onOpenChange, ...rootProps } = props;
41
+ const sourceRef = useRef<HTMLElement | null>(null);
42
+ const sourcePublishedRef = useRef(false);
43
+ const [source, setSource] = useState<HTMLElement | null>(null);
44
+ const ref = useRef((node: HTMLElement | null) => {
45
+ sourceRef.current = node;
46
+ if (sourcePublishedRef.current) setSource(node);
47
+ }).current;
48
+ useLayoutEffect(() => {
49
+ if (rootProps.open === true || rootProps.defaultOpen === true) {
50
+ sourcePublishedRef.current = true;
51
+ setSource(sourceRef.current);
52
+ }
53
+ }, [rootProps.defaultOpen, rootProps.open]);
54
+ const sourceContext = useMemo(() => ({ source, ref }), [ref, source]);
32
55
  return (
33
- <TooltipSourceContext.Provider value={source}>
34
- <TooltipPrimitive.Root data-slot="tooltip" {...props}>
56
+ <TooltipSourceContext.Provider value={sourceContext}>
57
+ <TooltipPrimitive.Root
58
+ data-slot="tooltip"
59
+ onOpenChange={(open) => {
60
+ if (open) {
61
+ sourcePublishedRef.current = true;
62
+ setSource(sourceRef.current);
63
+ }
64
+ onOpenChange?.(open);
65
+ }}
66
+ {...rootProps}
67
+ >
35
68
  {children}
36
69
  </TooltipPrimitive.Root>
37
70
  </TooltipSourceContext.Provider>
@@ -43,7 +43,10 @@ export function useAvailableModels(
43
43
  options: UseAvailableModelsOptions = {},
44
44
  ): UseAvailableModelsResult {
45
45
  const client = useOpenGeniClient(options);
46
- const load = useCallback(async () => await client.getClientConfig(), [client]);
46
+ const load = useCallback(
47
+ async (signal?: AbortSignal) => await client.getClientConfig({ signal }),
48
+ [client],
49
+ );
47
50
  const state = usePolledValue(load, {
48
51
  pollIntervalMs: options.pollIntervalMs,
49
52
  enabled: options.enabled,
@@ -62,19 +65,22 @@ export function useWorkspaceModelCatalog(
62
65
  options: UseWorkspaceModelCatalogOptions,
63
66
  ): UseWorkspaceModelCatalogResult {
64
67
  const client = useOpenGeniClient(options);
65
- const load = useCallback(async () => {
66
- if (!options.workspaceId) {
67
- return { models: [], defaultModel: null };
68
- }
69
- const [catalog, config] = await Promise.all([
70
- client.getWorkspaceModelCatalog(options.workspaceId),
71
- client.getClientConfig(),
72
- ]);
73
- return {
74
- models: catalog.models,
75
- defaultModel: config.defaultModel,
76
- };
77
- }, [client, options.workspaceId]);
68
+ const load = useCallback(
69
+ async (signal?: AbortSignal) => {
70
+ if (!options.workspaceId) {
71
+ return { models: [], defaultModel: null };
72
+ }
73
+ const [catalog, config] = await Promise.all([
74
+ client.getWorkspaceModelCatalog(options.workspaceId, { signal }),
75
+ client.getClientConfig({ signal }),
76
+ ]);
77
+ return {
78
+ models: catalog.models,
79
+ defaultModel: config.defaultModel,
80
+ };
81
+ },
82
+ [client, options.workspaceId],
83
+ );
78
84
  const state = usePolledValue(load, {
79
85
  pollIntervalMs: options.pollIntervalMs,
80
86
  enabled: options.enabled !== false && Boolean(options.workspaceId),
@@ -569,9 +569,17 @@ function reconcileOptimisticSendFromEvents(
569
569
  );
570
570
  const triggerEventId = operation.triggerEventId ?? accepted?.id ?? null;
571
571
  const turnId = operation.turnId ?? promptTurnIdForTrigger(triggerEventId, events);
572
+ const acceptedRouting = promptRoutingFromAcceptedEvent(accepted ?? null);
573
+ const destination =
574
+ acceptedRouting === "queued_for_execution"
575
+ ? "queue"
576
+ : acceptedRouting === "accepted_for_execution" || acceptedRouting === "accepted_for_steering"
577
+ ? "chat"
578
+ : operation.destination;
572
579
  const needsAdmissionUpdate =
573
580
  accepted !== undefined &&
574
581
  (operation.state !== "queued" ||
582
+ operation.destination !== destination ||
575
583
  operation.triggerEventId !== triggerEventId ||
576
584
  operation.turnId !== turnId ||
577
585
  operation.error !== undefined ||
@@ -580,6 +588,7 @@ function reconcileOptimisticSendFromEvents(
580
588
  ? {
581
589
  ...operation,
582
590
  state: "queued",
591
+ destination,
583
592
  triggerEventId,
584
593
  turnId,
585
594
  error: undefined,
@@ -697,6 +706,7 @@ export function useComposer(
697
706
  const targetGeneration = useRef(0);
698
707
  const draftReadGeneration = useRef(0);
699
708
  const draftReadInFlightRef = useRef<string | null>(null);
709
+ const draftReadAbortRef = useRef<AbortController | null>(null);
700
710
  const draftReadRetryRef = useRef<{
701
711
  targetKey: string;
702
712
  failures: number;
@@ -733,6 +743,8 @@ export function useComposer(
733
743
  if (draftReadRetryRef.current.timer !== null) {
734
744
  clearTimeout(draftReadRetryRef.current.timer);
735
745
  }
746
+ draftReadAbortRef.current?.abort();
747
+ draftReadAbortRef.current = null;
736
748
  draftReadRetryRef.current = { targetKey, failures: 0, timer: null };
737
749
  targetKeyRef.current = targetKey;
738
750
  targetGeneration.current += 1;
@@ -791,6 +803,11 @@ export function useComposer(
791
803
  if (draftReadRetryRef.current.timer !== null) {
792
804
  clearTimeout(draftReadRetryRef.current.timer);
793
805
  }
806
+ targetGeneration.current += 1;
807
+ draftReadGeneration.current += 1;
808
+ draftReadInFlightRef.current = null;
809
+ draftReadAbortRef.current?.abort();
810
+ draftReadAbortRef.current = null;
794
811
  },
795
812
  [],
796
813
  );
@@ -929,6 +946,9 @@ export function useComposer(
929
946
  draftReadInFlightRef.current = targetKey;
930
947
  const generation = targetGeneration.current;
931
948
  const readTicket = ++draftReadGeneration.current;
949
+ const requestAbort = new AbortController();
950
+ draftReadAbortRef.current?.abort();
951
+ draftReadAbortRef.current = requestAbort;
932
952
  const localAtStart = localEditRevision.current;
933
953
  const baseAtStart = draftRef.current;
934
954
  const policyAtStart = policyRef.current;
@@ -958,7 +978,9 @@ export function useComposer(
958
978
  setDraftLoading(true);
959
979
  }
960
980
  try {
961
- const fetched = await client.getComposerDraft(workspaceId, sessionId);
981
+ const fetched = await client.getComposerDraft(workspaceId, sessionId, {
982
+ signal: requestAbort.signal,
983
+ });
962
984
  if (
963
985
  generation !== targetGeneration.current ||
964
986
  targetKeyRef.current !== targetKey ||
@@ -1049,6 +1071,9 @@ export function useComposer(
1049
1071
  if (draftReadInFlightRef.current === targetKey) {
1050
1072
  draftReadInFlightRef.current = null;
1051
1073
  }
1074
+ if (draftReadAbortRef.current === requestAbort) {
1075
+ draftReadAbortRef.current = null;
1076
+ }
1052
1077
  }
1053
1078
  },
1054
1079
  [client, durableDrafts, sessionId, targetKey, workspaceId],
@@ -1463,10 +1488,13 @@ export function useComposer(
1463
1488
  ) {
1464
1489
  return;
1465
1490
  }
1491
+ const acceptedRouting =
1492
+ acceptedResult?.routing ?? promptRoutingFromAcceptedEvent(acceptedEvent);
1466
1493
  const acceptedDestination =
1467
- acceptedResult?.routing === "queued_for_execution"
1494
+ acceptedRouting === "queued_for_execution"
1468
1495
  ? "queue"
1469
- : acceptedResult?.routing === "accepted_for_execution"
1496
+ : acceptedRouting === "accepted_for_execution" ||
1497
+ acceptedRouting === "accepted_for_steering"
1470
1498
  ? "chat"
1471
1499
  : operation.destination;
1472
1500
  if (options.events === undefined && acceptedDestination === "chat") {
@@ -2743,6 +2771,24 @@ function asError(cause: unknown): Error {
2743
2771
  return cause instanceof Error ? cause : new Error(String(cause));
2744
2772
  }
2745
2773
 
2774
+ function promptRoutingFromAcceptedEvent(
2775
+ event: SessionEvent | null,
2776
+ ): "accepted_for_execution" | "queued_for_execution" | "accepted_for_steering" | null {
2777
+ if (
2778
+ typeof event?.payload !== "object" ||
2779
+ event.payload === null ||
2780
+ Array.isArray(event.payload)
2781
+ ) {
2782
+ return null;
2783
+ }
2784
+ const routing = (event.payload as Record<string, unknown>).routing;
2785
+ return routing === "accepted_for_execution" ||
2786
+ routing === "queued_for_execution" ||
2787
+ routing === "accepted_for_steering"
2788
+ ? routing
2789
+ : null;
2790
+ }
2791
+
2746
2792
  function isDraftConflictError(error: Error): boolean {
2747
2793
  const apiError = error as Partial<OpenGeniApiError>;
2748
2794
  return (
@@ -26,6 +26,8 @@ export type FileAttachment = {
26
26
  file?: FileAsset | undefined;
27
27
  /** Object-URL for an inline preview; minted for `image/*` files only. */
28
28
  previewUrl?: string | undefined;
29
+ /** Stable SDK failure code for UI behavior that must not parse error copy. */
30
+ errorCode?: "secure_context_required" | undefined;
29
31
  error?: string | undefined;
30
32
  };
31
33
 
@@ -76,6 +78,27 @@ export type UseFileAttachmentsResult = {
76
78
 
77
79
  const isImage = (file: File): boolean => file.type.startsWith("image/");
78
80
 
81
+ let fallbackAttachmentId = 0;
82
+
83
+ function createAttachmentId(): string {
84
+ const cryptoSource = globalThis.crypto;
85
+ if (typeof cryptoSource?.randomUUID === "function") return cryptoSource.randomUUID();
86
+ fallbackAttachmentId += 1;
87
+ // This id is only a browser-local React key and retry lookup, never durable
88
+ // authority. Keep attachment tracking usable when HTTP withholds randomUUID
89
+ // or Web Crypto is unavailable so the SDK's typed failure reaches the card.
90
+ return `attachment:${Date.now().toString(36)}:${fallbackAttachmentId.toString(36)}`;
91
+ }
92
+
93
+ function secureContextRequiredErrorCode(error: unknown): "secure_context_required" | undefined {
94
+ return typeof error === "object" &&
95
+ error !== null &&
96
+ "code" in error &&
97
+ error.code === "secure_context_required"
98
+ ? error.code
99
+ : undefined;
100
+ }
101
+
79
102
  /**
80
103
  * Upload-and-track state for files attached to the next message. Owns the
81
104
  * full client-side upload layer: a per-file `uploading | ready | failed`
@@ -198,6 +221,7 @@ export function useFileAttachments(
198
221
  name: asset.filename,
199
222
  contentType: asset.contentType,
200
223
  sizeBytes: asset.sizeBytes,
224
+ errorCode: undefined,
201
225
  error: undefined,
202
226
  }
203
227
  : attachment,
@@ -212,6 +236,7 @@ export function useFileAttachments(
212
236
  ? {
213
237
  ...attachment,
214
238
  status: "failed",
239
+ errorCode: secureContextRequiredErrorCode(error),
215
240
  error: error instanceof Error ? error.message : String(error),
216
241
  }
217
242
  : attachment,
@@ -225,7 +250,7 @@ export function useFileAttachments(
225
250
  const addFiles = useCallback(
226
251
  (files: Iterable<File>) => {
227
252
  for (const file of files) {
228
- const id = crypto.randomUUID();
253
+ const id = createAttachmentId();
229
254
  sources.current.set(id, file);
230
255
  const previewUrl = isImage(file) ? URL.createObjectURL(file) : undefined;
231
256
  if (previewUrl) previewUrls.current.set(id, previewUrl);
@@ -255,7 +280,7 @@ export function useFileAttachments(
255
280
  setAttachments((current) =>
256
281
  current.map((attachment) =>
257
282
  attachment.id === id
258
- ? { ...attachment, status: "uploading", error: undefined }
283
+ ? { ...attachment, status: "uploading", errorCode: undefined, error: undefined }
259
284
  : attachment,
260
285
  ),
261
286
  );
@@ -305,6 +330,7 @@ export function useFileAttachments(
305
330
  sizeBytes: file.sizeBytes,
306
331
  status: "ready",
307
332
  file,
333
+ errorCode: undefined,
308
334
  error: undefined,
309
335
  }
310
336
  : {
@@ -83,14 +83,26 @@ export function useGoal(
83
83
  const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
84
84
  const generation = useRef(0);
85
85
  const targetKeyRef = useRef<string | null>(null);
86
+ const loadAbort = useRef<AbortController | null>(null);
87
+
88
+ const retireLoads = useCallback(() => {
89
+ generation.current += 1;
90
+ loadAbort.current?.abort();
91
+ loadAbort.current = null;
92
+ }, []);
86
93
 
87
94
  const load = useCallback(async (): Promise<void> => {
88
95
  if (!sessionId) {
89
96
  return;
90
97
  }
91
98
  const ticket = ++generation.current;
99
+ loadAbort.current?.abort();
100
+ const controller = new AbortController();
101
+ loadAbort.current = controller;
92
102
  try {
93
- const fetched = await client.getGoal(workspaceId, sessionId);
103
+ const fetched = await client.getGoal(workspaceId, sessionId, {
104
+ signal: controller.signal,
105
+ });
94
106
  if (ticket === generation.current) {
95
107
  setGoal(fetched);
96
108
  setError(null);
@@ -108,6 +120,8 @@ export function useGoal(
108
120
  setError(cause instanceof Error ? cause : new Error(String(cause)));
109
121
  }
110
122
  setLoading(false);
123
+ } finally {
124
+ if (loadAbort.current === controller) loadAbort.current = null;
111
125
  }
112
126
  }, [client, workspaceId, sessionId]);
113
127
 
@@ -119,13 +133,14 @@ export function useGoal(
119
133
  setError(null);
120
134
  }
121
135
  if (!enabled || !pageLive) {
136
+ retireLoads();
122
137
  setLoading(false);
123
138
  return;
124
139
  }
125
140
  if (sharedFeed) {
126
141
  setLoading(false);
127
142
  return () => {
128
- generation.current += 1;
143
+ retireLoads();
129
144
  };
130
145
  }
131
146
  setLoading(true);
@@ -133,7 +148,7 @@ export function useGoal(
133
148
  if (pollIntervalMs === undefined || pollIntervalMs <= 0) {
134
149
  void load();
135
150
  return () => {
136
- generation.current += 1;
151
+ retireLoads();
137
152
  };
138
153
  }
139
154
  let cancelled = false;
@@ -149,9 +164,18 @@ export function useGoal(
149
164
  return () => {
150
165
  cancelled = true;
151
166
  if (timer !== null) clearTimeout(timer);
152
- generation.current += 1;
167
+ retireLoads();
153
168
  };
154
- }, [load, enabled, pageLive, workspaceId, sessionId, options.pollIntervalMs, sharedFeed]);
169
+ }, [
170
+ load,
171
+ enabled,
172
+ pageLive,
173
+ workspaceId,
174
+ sessionId,
175
+ options.pollIntervalMs,
176
+ retireLoads,
177
+ sharedFeed,
178
+ ]);
155
179
 
156
180
  const scheduleRefresh = useDebouncedCallback(() => void load());
157
181
  useSessionEventTrigger(client, workspaceId, sessionId, isGoalRefreshEvent, scheduleRefresh, {
@@ -203,11 +227,11 @@ export function useGoal(
203
227
  return true as const;
204
228
  });
205
229
  if (ok) {
206
- generation.current += 1;
230
+ retireLoads();
207
231
  setGoal(null);
208
232
  setError(null);
209
233
  }
210
- }, [client, workspaceId, sessionId, run]);
234
+ }, [client, workspaceId, sessionId, run, retireLoads]);
211
235
 
212
236
  return {
213
237
  goal,
@@ -51,11 +51,17 @@ export function useLastStartedTurnPolicy(
51
51
  useOpenGeni(options);
52
52
  const enabled = (options.enabled ?? true) && Boolean(sessionId);
53
53
 
54
- const load = useCallback(async (): Promise<LastStartedTurnPolicy | null> => {
55
- if (!sessionId) return null;
56
- const turns = await client.listTurns(workspaceId, sessionId, { latestStarted: true });
57
- return policyFromTurn(turns[0]);
58
- }, [client, workspaceId, sessionId]);
54
+ const load = useCallback(
55
+ async (signal?: AbortSignal): Promise<LastStartedTurnPolicy | null> => {
56
+ if (!sessionId) return null;
57
+ const turns = await client.listTurns(workspaceId, sessionId, {
58
+ latestStarted: true,
59
+ signal,
60
+ });
61
+ return policyFromTurn(turns[0]);
62
+ },
63
+ [client, workspaceId, sessionId],
64
+ );
59
65
 
60
66
  const { data, loading, error, refresh } = usePolledValue(load, {
61
67
  pollIntervalMs: options.pollIntervalMs,