@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.
- package/dist/accounts.js +87 -11
- package/dist/accounts.js.map +1 -1
- package/dist/{browser-viewer-AEFHUYZK.js → browser-viewer-MKKFRRRV.js} +3 -3
- package/dist/{chunk-YFKJ7VZH.js → chunk-7EAFGICR.js} +45 -11
- package/dist/chunk-7EAFGICR.js.map +1 -0
- package/dist/{chunk-W6NFJXSA.js → chunk-7KBJLTKS.js} +52 -12
- package/dist/chunk-7KBJLTKS.js.map +1 -0
- package/dist/{chunk-YJFGAFBD.js → chunk-E5KISKFG.js} +241 -97
- package/dist/chunk-E5KISKFG.js.map +1 -0
- package/dist/{chunk-MZ2G2XBA.js → chunk-IOLVN74D.js} +31 -4
- package/dist/chunk-IOLVN74D.js.map +1 -0
- package/dist/{chunk-D3UIAJKY.js → chunk-JAQ5K7W6.js} +3 -2
- package/dist/{chunk-D3UIAJKY.js.map → chunk-JAQ5K7W6.js.map} +1 -1
- package/dist/{chunk-LAKLF4PA.js → chunk-VTOREEXF.js} +8 -7
- package/dist/chunk-VTOREEXF.js.map +1 -0
- package/dist/{chunk-X4A66EWZ.js → chunk-X5S426G2.js} +38 -44
- package/dist/chunk-X5S426G2.js.map +1 -0
- package/dist/{chunk-HQVCKCLG.js → chunk-YG4QEQF5.js} +354 -255
- package/dist/chunk-YG4QEQF5.js.map +1 -0
- package/dist/{chunk-XXWH2JLC.js → chunk-YVCHAQZY.js} +2 -2
- package/dist/{chunk-RGALCTWO.js → chunk-ZKNTK7IA.js} +13 -9
- package/dist/{chunk-RGALCTWO.js.map → chunk-ZKNTK7IA.js.map} +1 -1
- package/dist/{chunk-OAM4WAAM.js → chunk-ZPBTI3TE.js} +2 -2
- package/dist/components/fleet-tile.d.ts +2 -2
- package/dist/components/model-policy-picker.d.ts +9 -0
- package/dist/components/tip-follow.d.ts +16 -0
- package/dist/composer.js +6 -6
- package/dist/hooks/use-file-attachments.d.ts +2 -0
- package/dist/hooks/use-session-events.d.ts +3 -3
- package/dist/index.js +77 -69
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +2 -2
- package/dist/machines.js +2 -2
- package/dist/model-policy.d.ts +2 -1
- package/dist/model-policy.js +1 -1
- package/dist/realtime.js +47 -8
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +4 -4
- package/dist/session.js +4 -4
- package/dist/timeline/entrance.d.ts +13 -1
- package/package.json +3 -2
- package/src/accounts.tsx +116 -14
- package/src/components/composer.tsx +15 -5
- package/src/components/fleet-tile.tsx +4 -15
- package/src/components/message-timeline.tsx +237 -101
- package/src/components/model-policy-picker.tsx +59 -13
- package/src/components/tip-follow.ts +52 -0
- package/src/components/tooltip.tsx +38 -5
- package/src/hooks/use-available-models.ts +20 -14
- package/src/hooks/use-composer.ts +49 -3
- package/src/hooks/use-file-attachments.ts +28 -2
- package/src/hooks/use-goal.ts +31 -7
- package/src/hooks/use-last-started-turn-policy.ts +11 -5
- package/src/hooks/use-session-events.ts +186 -61
- package/src/hooks/use-session-lineage.ts +20 -16
- package/src/hooks/use-session.ts +23 -19
- package/src/hooks/use-workspace-sessions.ts +31 -27
- package/src/lib/format.ts +8 -6
- package/src/model-policy.ts +45 -54
- package/src/realtime/realtime-control.tsx +55 -6
- package/src/timeline/entrance.tsx +28 -3
- package/src/timeline/projection.ts +4 -0
- package/styles/compiled.css +5 -0
- package/dist/chunk-HQVCKCLG.js.map +0 -1
- package/dist/chunk-LAKLF4PA.js.map +0 -1
- package/dist/chunk-MZ2G2XBA.js.map +0 -1
- package/dist/chunk-W6NFJXSA.js.map +0 -1
- package/dist/chunk-X4A66EWZ.js.map +0 -1
- package/dist/chunk-YFKJ7VZH.js.map +0 -1
- package/dist/chunk-YJFGAFBD.js.map +0 -1
- /package/dist/{browser-viewer-AEFHUYZK.js.map → browser-viewer-MKKFRRRV.js.map} +0 -0
- /package/dist/{chunk-XXWH2JLC.js.map → chunk-YVCHAQZY.js.map} +0 -0
- /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
|
|
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: "
|
|
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 {
|
|
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
|
-
? {
|
|
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 ??
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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 {
|
|
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
|
|
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={
|
|
34
|
-
<TooltipPrimitive.Root
|
|
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(
|
|
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(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
1494
|
+
acceptedRouting === "queued_for_execution"
|
|
1468
1495
|
? "queue"
|
|
1469
|
-
:
|
|
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 =
|
|
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
|
: {
|
package/src/hooks/use-goal.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
+
retireLoads();
|
|
153
168
|
};
|
|
154
|
-
}, [
|
|
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
|
-
|
|
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(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|