@opengeni/react 0.36.1 → 0.40.0

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 (85) hide show
  1. package/README.md +92 -2
  2. package/dist/{chunk-J2RVJ6JX.js → chunk-4IJCL7YO.js} +13 -2
  3. package/dist/chunk-4IJCL7YO.js.map +1 -0
  4. package/dist/{chunk-7CJOAW3V.js → chunk-EB67J347.js} +1 -135
  5. package/dist/chunk-EB67J347.js.map +1 -0
  6. package/dist/chunk-EKZH6IDG.js +141 -0
  7. package/dist/chunk-EKZH6IDG.js.map +1 -0
  8. package/dist/{chunk-OKKMZDQF.js → chunk-JALF5FI3.js} +9 -7
  9. package/dist/{chunk-OKKMZDQF.js.map → chunk-JALF5FI3.js.map} +1 -1
  10. package/dist/{chunk-Z5FV355Z.js → chunk-KR2SK5GJ.js} +1382 -724
  11. package/dist/chunk-KR2SK5GJ.js.map +1 -0
  12. package/dist/{chunk-N4XEBE23.js → chunk-OMCFRWHL.js} +22 -13
  13. package/dist/{chunk-N4XEBE23.js.map → chunk-OMCFRWHL.js.map} +1 -1
  14. package/dist/{chunk-FT2TXNGZ.js → chunk-Q2NCKWTK.js} +183 -18
  15. package/dist/chunk-Q2NCKWTK.js.map +1 -0
  16. package/dist/{chunk-22RM4GMO.js → chunk-SJKT4TKW.js} +26 -2
  17. package/dist/chunk-SJKT4TKW.js.map +1 -0
  18. package/dist/{chunk-ALA3UGWB.js → chunk-SXIQK54Z.js} +8 -6
  19. package/dist/{chunk-ALA3UGWB.js.map → chunk-SXIQK54Z.js.map} +1 -1
  20. package/dist/chunk-WZT5G5OR.js +433 -0
  21. package/dist/chunk-WZT5G5OR.js.map +1 -0
  22. package/dist/client.d.ts +2 -0
  23. package/dist/components/chat-composer.d.ts +7 -1
  24. package/dist/components/human-input-form.d.ts +16 -6
  25. package/dist/components/human-input-surface.d.ts +19 -0
  26. package/dist/components/machine-input-display.d.ts +12 -0
  27. package/dist/components/message-timeline.d.ts +6 -1
  28. package/dist/components/model-policy-picker.d.ts +78 -0
  29. package/dist/components/session-chrome.d.ts +2 -1
  30. package/dist/components/tip-follow.d.ts +30 -0
  31. package/dist/composer.d.ts +2 -0
  32. package/dist/composer.js +22 -5
  33. package/dist/hooks/use-composer.d.ts +10 -1
  34. package/dist/index.d.ts +7 -1
  35. package/dist/index.js +2261 -2230
  36. package/dist/index.js.map +1 -1
  37. package/dist/machines.js +4 -3
  38. package/dist/model-policy.d.ts +12 -4
  39. package/dist/model-policy.js +5 -1
  40. package/dist/realtime/dropdown-menu.d.ts +16 -0
  41. package/dist/realtime/realtime-control.d.ts +126 -0
  42. package/dist/realtime.d.ts +11 -0
  43. package/dist/realtime.js +1023 -0
  44. package/dist/realtime.js.map +1 -0
  45. package/dist/session-context.d.ts +7 -1
  46. package/dist/session-ui.d.ts +2 -0
  47. package/dist/session-ui.js +6 -4
  48. package/dist/session.js +5 -4
  49. package/dist/timeline/compute-label.d.ts +2 -0
  50. package/dist/timeline/index.d.ts +1 -0
  51. package/dist/timeline/types.d.ts +5 -0
  52. package/package.json +7 -2
  53. package/src/client.ts +13 -0
  54. package/src/components/chat-composer.tsx +19 -5
  55. package/src/components/composer-transcription-control.tsx +2 -2
  56. package/src/components/composer.tsx +14 -5
  57. package/src/components/human-input-form.tsx +506 -186
  58. package/src/components/human-input-surface.tsx +80 -0
  59. package/src/components/machine-input-display.ts +83 -0
  60. package/src/components/message-timeline.tsx +348 -308
  61. package/src/components/model-policy-picker.tsx +587 -0
  62. package/src/components/sandbox-files.tsx +1 -1
  63. package/src/components/session-chrome.tsx +141 -15
  64. package/src/components/tip-follow.ts +127 -6
  65. package/src/composer.ts +13 -0
  66. package/src/hooks/use-composer.ts +229 -29
  67. package/src/index.ts +19 -0
  68. package/src/model-policy.ts +51 -7
  69. package/src/realtime/dropdown-menu.tsx +123 -0
  70. package/src/realtime/realtime-control.tsx +1178 -0
  71. package/src/realtime.ts +27 -0
  72. package/src/session-context.ts +16 -0
  73. package/src/session-ui.ts +2 -0
  74. package/src/timeline/compute-label.tsx +18 -0
  75. package/src/timeline/index.ts +3 -0
  76. package/src/timeline/projection.ts +21 -1
  77. package/src/timeline/tool-renderers.tsx +232 -43
  78. package/src/timeline/types.ts +5 -0
  79. package/styles/index.css +61 -0
  80. package/styles/tokens.css +2 -0
  81. package/dist/chunk-22RM4GMO.js.map +0 -1
  82. package/dist/chunk-7CJOAW3V.js.map +0 -1
  83. package/dist/chunk-FT2TXNGZ.js.map +0 -1
  84. package/dist/chunk-J2RVJ6JX.js.map +0 -1
  85. package/dist/chunk-Z5FV355Z.js.map +0 -1
@@ -0,0 +1,587 @@
1
+ import type { ClientModel, LatencyMode, ReasoningEffort } from "@opengeni/sdk";
2
+ import {
3
+ CheckIcon,
4
+ ChevronDownIcon,
5
+ ChevronLeftIcon,
6
+ ChevronRightIcon,
7
+ KeyRoundIcon,
8
+ ZapIcon,
9
+ } from "lucide-react";
10
+ import { motion, useReducedMotion } from "motion/react";
11
+ import { DropdownMenu } from "radix-ui";
12
+ import { useEffect, useMemo, useState, type ReactNode, type SVGProps } from "react";
13
+ import { cn } from "../lib/cn";
14
+ import {
15
+ coerceReasoningEffortForModel,
16
+ effortOptionsForModel,
17
+ findPickerRow,
18
+ groupPickerRowsByBillingClass,
19
+ labelReasoningEffort,
20
+ projectClientModelRows,
21
+ runnableLatencyModesForModel,
22
+ type PickerBillingClass,
23
+ type PickerModelRow,
24
+ } from "../model-policy";
25
+
26
+ type ClientPickerModelRow = PickerModelRow<ClientModel>;
27
+
28
+ type NavLevel = "providers" | "models" | "thinking";
29
+
30
+ type PickerNavState = {
31
+ level: NavLevel;
32
+ rail: PickerBillingClass | null;
33
+ modelId: string | null;
34
+ };
35
+
36
+ type NavUpdater = PickerNavState | ((previous: PickerNavState) => PickerNavState);
37
+
38
+ export type ModelPolicyPickerMessages = {
39
+ label: string;
40
+ loading: string;
41
+ noModels: string;
42
+ thinking: string;
43
+ fast: string;
44
+ fastRateHint: string;
45
+ codexOnly: string;
46
+ billingHints: Record<PickerBillingClass, string>;
47
+ };
48
+
49
+ export const defaultModelPolicyPickerMessages: ModelPolicyPickerMessages = {
50
+ label: "Model and effort",
51
+ loading: "Loading model catalog…",
52
+ noModels: "No models available.",
53
+ thinking: "Thinking",
54
+ fast: "Fast",
55
+ fastRateHint: "2× rate",
56
+ codexOnly: "Codex-only session",
57
+ billingHints: {
58
+ opengeni_credits: "Will use credits",
59
+ codex_subscription: "ChatGPT / Codex plan",
60
+ byok: "Billed to your AI Gateway",
61
+ },
62
+ };
63
+
64
+ export type ModelPolicyPickerProps = {
65
+ /** Lightweight deployment models. Catalog rows take precedence when supplied. */
66
+ models?: ClientModel[] | undefined;
67
+ /** Catalog-backed rows with availability and billing-class truth. */
68
+ rows?: PickerModelRow[] | undefined;
69
+ model: string;
70
+ effort: ReasoningEffort;
71
+ latencyMode: LatencyMode;
72
+ disabled?: boolean | undefined;
73
+ loading?: boolean | undefined;
74
+ error?: string | null | undefined;
75
+ /** Non-Codex models stay visible but cannot be selected. */
76
+ codexOnly?: boolean | undefined;
77
+ /** Resets drill-down navigation when the session scope changes. */
78
+ sessionKey?: string | undefined;
79
+ /** Prefer bottom on new-chat surfaces and top for bottom-docked composers. */
80
+ menuSide?: "top" | "bottom" | undefined;
81
+ className?: string | undefined;
82
+ messages?: Partial<ModelPolicyPickerMessages> | undefined;
83
+ onModelChange: (modelId: string) => void;
84
+ onEffortChange: (effort: ReasoningEffort) => void;
85
+ onLatencyModeChange: (latencyMode: LatencyMode) => void;
86
+ };
87
+
88
+ const SLIDE_EASE = [0.22, 1, 0.36, 1] as const;
89
+
90
+ function OpenGeniMark(props: SVGProps<SVGSVGElement>) {
91
+ return (
92
+ <svg
93
+ viewBox="0 0 140 133"
94
+ fill="none"
95
+ stroke="currentColor"
96
+ strokeWidth={1.2}
97
+ strokeLinejoin="round"
98
+ aria-hidden="true"
99
+ {...props}
100
+ >
101
+ <g transform="translate(-17.5,-20.999893188476562) scale(1.75)">
102
+ <g transform="translate(0,-952.36218)">
103
+ <path d="m 60.7828,964.36215 27.1809,0.8834 -27.1809,25.9958 z m -1.9745,1.4513 0,26.7845 -25.2681,0 c 8.6166,-8.7334 16.8796,-17.8103 25.2681,-26.7845 z m 27.7053,3.628 3.4864,1.1989 -12.5877,7.4768 z m -68.1835,2.9656 5.5226,0 12.8654,14.0705 -5.9854,6.1204 -12.4026,0 c 9e-4,-6.7347 0,-13.4597 0,-20.1909 z m -1.9746,1.2304 0,5.8364 -6.3555,0 z m 3.363,20.9796 38.627,0 -10.7675,29.43465 z m 39.0898,4.54286 0,41.20229 -12.5878,-6.8775 c 4.1972,-11.443 8.3886,-22.879 12.5878,-34.32479 z" />
104
+ </g>
105
+ </g>
106
+ </svg>
107
+ );
108
+ }
109
+
110
+ function ChatGptMark(props: SVGProps<SVGSVGElement>) {
111
+ return (
112
+ <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
113
+ <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3653-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8414 3.3698-2.02 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.783-2.7622a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
114
+ </svg>
115
+ );
116
+ }
117
+
118
+ export function BillingClassMark(props: {
119
+ billingClass: PickerBillingClass;
120
+ className?: string | undefined;
121
+ "aria-label"?: string | undefined;
122
+ }) {
123
+ const labels: Record<PickerBillingClass, string> = {
124
+ opengeni_credits: "OpenGeni",
125
+ codex_subscription: "Codex",
126
+ byok: "Bring your own key",
127
+ };
128
+ const label = props["aria-label"] ?? labels[props.billingClass];
129
+ const accessibility =
130
+ label.length === 0
131
+ ? { "aria-hidden": true as const }
132
+ : { role: "img" as const, "aria-label": label };
133
+ const shell = cn(
134
+ "inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden text-og-fg-subtle",
135
+ props.className,
136
+ );
137
+ const mark = "size-3.5";
138
+ return (
139
+ <span
140
+ className={shell}
141
+ data-testid={`billing-class-icon-${props.billingClass}`}
142
+ {...accessibility}
143
+ >
144
+ {props.billingClass === "opengeni_credits" ? (
145
+ <OpenGeniMark className={mark} />
146
+ ) : props.billingClass === "codex_subscription" ? (
147
+ <ChatGptMark className={mark} />
148
+ ) : (
149
+ <KeyRoundIcon className={mark} aria-hidden />
150
+ )}
151
+ </span>
152
+ );
153
+ }
154
+
155
+ function isCodexModel(model: ClientModel): boolean {
156
+ return model.id.startsWith("codex/") || model.source === "codex";
157
+ }
158
+
159
+ function applyCodexOnly(
160
+ rows: ClientPickerModelRow[],
161
+ codexOnly: boolean,
162
+ unavailableReason: string,
163
+ ): ClientPickerModelRow[] {
164
+ if (!codexOnly) return rows;
165
+ return rows.map((row) =>
166
+ isCodexModel(row.catalog)
167
+ ? row
168
+ : {
169
+ ...row,
170
+ selectable: false,
171
+ unavailableReason: row.unavailableReason ?? unavailableReason,
172
+ },
173
+ );
174
+ }
175
+
176
+ function effectiveRows(props: ModelPolicyPickerProps): ClientPickerModelRow[] {
177
+ const rows = props.rows !== undefined ? props.rows : projectClientModelRows(props.models ?? []);
178
+ const messages = { ...defaultModelPolicyPickerMessages, ...props.messages };
179
+ return applyCodexOnly(rows, props.codexOnly === true, messages.codexOnly);
180
+ }
181
+
182
+ function defaultNavState(rows: ClientPickerModelRow[], modelId: string): PickerNavState {
183
+ const selected = findPickerRow(rows, modelId);
184
+ if (!selected) {
185
+ const rails = new Set(rows.map((row) => row.billingClass));
186
+ const onlyRail = rails.size === 1 ? rows[0]?.billingClass : null;
187
+ return onlyRail
188
+ ? { level: "models", rail: onlyRail, modelId: null }
189
+ : { level: "providers", rail: null, modelId: null };
190
+ }
191
+ return { level: "thinking", rail: selected.billingClass, modelId: selected.id };
192
+ }
193
+
194
+ function usePickerNavState(
195
+ sessionKey: string | undefined,
196
+ rows: ClientPickerModelRow[],
197
+ model: string,
198
+ ): [PickerNavState, (next: NavUpdater) => void] {
199
+ const [state, setState] = useState<PickerNavState>(() => defaultNavState(rows, model));
200
+ useEffect(() => {
201
+ setState(defaultNavState(rows, model));
202
+ // The menu deliberately preserves its drill-down while the same session is mounted.
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [sessionKey]);
205
+ return [state, setState];
206
+ }
207
+
208
+ export function PickerNavRow(props: {
209
+ label: string;
210
+ hint?: string | undefined;
211
+ icon?: ReactNode;
212
+ trailing?: ReactNode;
213
+ showChevron?: boolean | undefined;
214
+ disabled?: boolean | undefined;
215
+ title?: string | undefined;
216
+ active?: boolean | undefined;
217
+ testId?: string | undefined;
218
+ onClick: () => void;
219
+ }) {
220
+ return (
221
+ <button
222
+ type="button"
223
+ disabled={props.disabled}
224
+ title={props.title}
225
+ onClick={props.onClick}
226
+ data-testid={props.testId}
227
+ className={cn(
228
+ "flex w-full cursor-pointer items-center gap-2 rounded-og-sm px-2.5 py-2 text-left text-og-fg outline-none transition-colors hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40",
229
+ props.disabled && "cursor-not-allowed opacity-50",
230
+ )}
231
+ >
232
+ {props.icon}
233
+ <span className="min-w-0 flex-1">
234
+ <span className={cn("block truncate text-sm", props.active && "font-medium")}>
235
+ {props.label}
236
+ </span>
237
+ {props.hint ? (
238
+ <span className="mt-0.5 block truncate text-xs text-og-fg-subtle">{props.hint}</span>
239
+ ) : null}
240
+ </span>
241
+ {props.trailing ? <span className="ml-auto shrink-0">{props.trailing}</span> : null}
242
+ {props.showChevron === false ? null : (
243
+ <ChevronRightIcon className="size-3.5 shrink-0 text-og-fg-subtle" />
244
+ )}
245
+ </button>
246
+ );
247
+ }
248
+
249
+ export function PickerBackHeader(props: {
250
+ label: string;
251
+ icon?: ReactNode;
252
+ onBack: () => void;
253
+ trailing?: ReactNode;
254
+ }) {
255
+ return (
256
+ <div className="mb-1 flex items-center gap-0.5 border-b border-og-border/70 px-0.5 pb-1.5">
257
+ <button
258
+ type="button"
259
+ onClick={props.onBack}
260
+ data-testid="model-picker-back"
261
+ className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-og-sm px-1.5 py-1.5 text-left text-og-fg outline-none transition-colors hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40"
262
+ >
263
+ <ChevronLeftIcon className="size-3.5 shrink-0 text-og-fg-subtle" />
264
+ {props.icon}
265
+ <span className="min-w-0 flex-1 truncate text-sm font-medium">{props.label}</span>
266
+ </button>
267
+ {props.trailing ? <div className="relative z-10 shrink-0">{props.trailing}</div> : null}
268
+ </div>
269
+ );
270
+ }
271
+
272
+ export function PickerAnimatedPage(props: {
273
+ pageKey: string;
274
+ direction: 1 | -1;
275
+ children: ReactNode;
276
+ }) {
277
+ const reduceMotion = useReducedMotion();
278
+ return (
279
+ <motion.div
280
+ key={props.pageKey}
281
+ initial={reduceMotion ? false : { x: props.direction * 14, opacity: 0 }}
282
+ animate={{ x: 0, opacity: 1 }}
283
+ transition={{ duration: reduceMotion ? 0 : 0.18, ease: SLIDE_EASE }}
284
+ className="w-full"
285
+ >
286
+ {props.children}
287
+ </motion.div>
288
+ );
289
+ }
290
+
291
+ export function ModelPolicyPickerMenu(
292
+ props: ModelPolicyPickerProps & {
293
+ nav?: PickerNavState | undefined;
294
+ onNavChange?: ((next: NavUpdater) => void) | undefined;
295
+ },
296
+ ) {
297
+ const messages = useMemo(
298
+ () => ({ ...defaultModelPolicyPickerMessages, ...props.messages }),
299
+ [props.messages],
300
+ );
301
+ const rows = effectiveRows(props);
302
+ const groups = useMemo(() => groupPickerRowsByBillingClass(rows), [rows]);
303
+ const [localNav, setLocalNav] = usePickerNavState(
304
+ props.nav === undefined ? props.sessionKey : undefined,
305
+ rows,
306
+ props.model,
307
+ );
308
+ const nav = props.nav ?? localNav;
309
+ const setNav = props.onNavChange ?? setLocalNav;
310
+ const [direction, setDirection] = useState<1 | -1>(1);
311
+
312
+ const effectiveNav =
313
+ nav.level === "providers" && groups.length === 1
314
+ ? { level: "models" as const, rail: groups[0]!.billingClass, modelId: null }
315
+ : nav;
316
+ const activeGroup =
317
+ effectiveNav.rail === null
318
+ ? undefined
319
+ : groups.find((group) => group.billingClass === effectiveNav.rail);
320
+ const focusModel =
321
+ effectiveNav.modelId === null ? undefined : findPickerRow(rows, effectiveNav.modelId);
322
+ const selectedRow = findPickerRow(rows, props.model);
323
+ const { latencyMode, loading, onLatencyModeChange } = props;
324
+
325
+ useEffect(() => {
326
+ if (loading || !selectedRow?.selectable) return;
327
+ const supportsCurrentMode = runnableLatencyModesForModel(selectedRow.catalog).includes(
328
+ latencyMode,
329
+ );
330
+ if (!supportsCurrentMode && latencyMode !== "standard") {
331
+ onLatencyModeChange("standard");
332
+ }
333
+ }, [latencyMode, loading, onLatencyModeChange, selectedRow]);
334
+
335
+ const go = (next: PickerNavState, nextDirection: 1 | -1) => {
336
+ setDirection(nextDirection);
337
+ setNav(next);
338
+ };
339
+
340
+ const selectEffort = (row: ClientPickerModelRow, effort: ReasoningEffort) => {
341
+ if (!row.selectable) return;
342
+ if (row.id !== props.model) props.onModelChange(row.id);
343
+ props.onEffortChange(coerceReasoningEffortForModel(row.catalog, effort));
344
+ };
345
+
346
+ const pageKey =
347
+ effectiveNav.level === "providers"
348
+ ? "providers"
349
+ : effectiveNav.level === "models"
350
+ ? `models:${effectiveNav.rail ?? ""}`
351
+ : `thinking:${effectiveNav.modelId ?? ""}`;
352
+
353
+ let body: ReactNode;
354
+ if (props.loading) {
355
+ body = <p className="px-2 py-3 text-xs text-og-fg-subtle">{messages.loading}</p>;
356
+ } else if (groups.length === 0) {
357
+ body = <p className="px-2 py-3 text-xs text-og-fg-subtle">{messages.noModels}</p>;
358
+ } else if (effectiveNav.level === "providers") {
359
+ body = (
360
+ <div className="flex flex-col gap-0.5" data-testid="model-picker-providers">
361
+ {groups.map((group) => (
362
+ <PickerNavRow
363
+ key={group.billingClass}
364
+ label={group.label}
365
+ hint={messages.billingHints[group.billingClass]}
366
+ icon={<BillingClassMark billingClass={group.billingClass} />}
367
+ active={selectedRow?.billingClass === group.billingClass}
368
+ testId={`model-picker-rail-${group.billingClass}`}
369
+ onClick={() => go({ level: "models", rail: group.billingClass, modelId: null }, 1)}
370
+ />
371
+ ))}
372
+ </div>
373
+ );
374
+ } else if (effectiveNav.level === "models" && activeGroup) {
375
+ body = (
376
+ <div data-testid="model-picker-models">
377
+ {groups.length > 1 ? (
378
+ <PickerBackHeader
379
+ label={activeGroup.label}
380
+ icon={<BillingClassMark billingClass={activeGroup.billingClass} />}
381
+ onBack={() => go({ level: "providers", rail: null, modelId: null }, -1)}
382
+ />
383
+ ) : null}
384
+ <div className="flex flex-col gap-0.5">
385
+ {activeGroup.rows.map((row) => (
386
+ <PickerNavRow
387
+ key={`${row.billingClass}:${row.id}`}
388
+ label={row.label}
389
+ hint={row.unavailableReason ?? undefined}
390
+ disabled={!row.selectable}
391
+ title={row.unavailableReason ?? undefined}
392
+ active={row.id === props.model}
393
+ testId={`model-picker-choice-${row.id}`}
394
+ onClick={() => {
395
+ if (row.selectable) {
396
+ go({ level: "thinking", rail: row.billingClass, modelId: row.id }, 1);
397
+ }
398
+ }}
399
+ />
400
+ ))}
401
+ </div>
402
+ </div>
403
+ );
404
+ } else if (effectiveNav.level === "thinking" && focusModel) {
405
+ const activeModel = focusModel.id === props.model;
406
+ const supportsFast = runnableLatencyModesForModel(focusModel.catalog).includes("fast");
407
+ body = (
408
+ <div data-testid="model-picker-reasoning">
409
+ <PickerBackHeader
410
+ label={focusModel.label}
411
+ icon={<BillingClassMark billingClass={focusModel.billingClass} />}
412
+ onBack={() => go({ level: "models", rail: focusModel.billingClass, modelId: null }, -1)}
413
+ trailing={
414
+ supportsFast ? (
415
+ <button
416
+ type="button"
417
+ disabled={!focusModel.selectable}
418
+ aria-pressed={activeModel && props.latencyMode === "fast"}
419
+ aria-label={
420
+ activeModel && props.latencyMode === "fast"
421
+ ? `Disable ${messages.fast}`
422
+ : `Enable ${messages.fast}`
423
+ }
424
+ title={`${messages.fast} · ${messages.fastRateHint}`}
425
+ data-testid="model-picker-fast"
426
+ onClick={() => {
427
+ if (!activeModel) {
428
+ props.onModelChange(focusModel.id);
429
+ props.onEffortChange(
430
+ coerceReasoningEffortForModel(focusModel.catalog, props.effort),
431
+ );
432
+ }
433
+ props.onLatencyModeChange(
434
+ activeModel && props.latencyMode === "fast" ? "standard" : "fast",
435
+ );
436
+ }}
437
+ className={cn(
438
+ "flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-og-sm text-og-fg-subtle outline-none transition-colors hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
439
+ activeModel && props.latencyMode === "fast" && "text-og-fg",
440
+ )}
441
+ >
442
+ <ZapIcon
443
+ className={cn(
444
+ "size-3.5",
445
+ activeModel && props.latencyMode === "fast" && "fill-current",
446
+ )}
447
+ />
448
+ </button>
449
+ ) : null
450
+ }
451
+ />
452
+ <p className="px-2.5 pt-1 pb-1 text-xs font-medium tracking-wide text-og-fg-subtle uppercase">
453
+ {messages.thinking}
454
+ </p>
455
+ {focusModel.catalog.capabilities?.inputModalities.includes("image") === false ? (
456
+ <p className="px-2.5 pb-1.5 text-xs leading-relaxed text-og-fg-subtle">
457
+ Unsupported attachments stay in the session but are hidden from this model.
458
+ </p>
459
+ ) : null}
460
+ <div className="flex flex-col gap-0.5">
461
+ {effortOptionsForModel(focusModel.catalog).map((effort) => {
462
+ const selected = activeModel && effort === props.effort;
463
+ return (
464
+ <button
465
+ key={effort}
466
+ type="button"
467
+ disabled={!focusModel.selectable}
468
+ onClick={() => selectEffort(focusModel, effort)}
469
+ className={cn(
470
+ "flex h-8 w-full cursor-pointer items-center rounded-og-sm px-2.5 text-left text-sm text-og-fg outline-none transition-colors hover:bg-og-surface-2 focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50",
471
+ selected && "bg-og-surface-2",
472
+ )}
473
+ >
474
+ <span className="min-w-0 flex-1 truncate">{labelReasoningEffort(effort)}</span>
475
+ {selected ? (
476
+ <CheckIcon
477
+ className="ml-auto size-3.5 shrink-0"
478
+ data-testid="model-picker-effort-check"
479
+ />
480
+ ) : null}
481
+ </button>
482
+ );
483
+ })}
484
+ </div>
485
+ </div>
486
+ );
487
+ } else {
488
+ body = (
489
+ <div className="flex flex-col gap-0.5" data-testid="model-picker-providers">
490
+ {groups.map((group) => (
491
+ <PickerNavRow
492
+ key={group.billingClass}
493
+ label={group.label}
494
+ hint={messages.billingHints[group.billingClass]}
495
+ icon={<BillingClassMark billingClass={group.billingClass} />}
496
+ testId={`model-picker-rail-${group.billingClass}`}
497
+ onClick={() => go({ level: "models", rail: group.billingClass, modelId: null }, 1)}
498
+ />
499
+ ))}
500
+ </div>
501
+ );
502
+ }
503
+
504
+ return (
505
+ <div data-testid="model-picker-menu" className="relative overflow-hidden">
506
+ {props.error ? (
507
+ <p className="px-2 py-1 text-xs text-og-status-failed" role="alert">
508
+ {props.error}
509
+ </p>
510
+ ) : null}
511
+ <PickerAnimatedPage pageKey={pageKey} direction={direction}>
512
+ {body}
513
+ </PickerAnimatedPage>
514
+ </div>
515
+ );
516
+ }
517
+
518
+ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
519
+ const [open, setOpen] = useState(false);
520
+ const messages = useMemo(
521
+ () => ({ ...defaultModelPolicyPickerMessages, ...props.messages }),
522
+ [props.messages],
523
+ );
524
+ const rows = effectiveRows(props);
525
+ const [nav, setNav] = usePickerNavState(props.sessionKey, rows, props.model);
526
+ const selected = findPickerRow(rows, props.model);
527
+
528
+ if (props.loading) {
529
+ return (
530
+ <span
531
+ className={cn(
532
+ "inline-flex h-8 w-40 shrink-0 animate-pulse rounded-full bg-og-surface-2",
533
+ props.className,
534
+ )}
535
+ aria-label={messages.loading}
536
+ data-testid="model-picker-loading"
537
+ />
538
+ );
539
+ }
540
+ return (
541
+ <DropdownMenu.Root open={open} onOpenChange={setOpen}>
542
+ <DropdownMenu.Trigger asChild>
543
+ <button
544
+ type="button"
545
+ disabled={props.disabled}
546
+ aria-label={messages.label}
547
+ className={cn(
548
+ "inline-flex h-8 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-xs text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-7 max-sm:max-w-[9rem] max-sm:px-2",
549
+ props.className,
550
+ )}
551
+ >
552
+ <BillingClassMark
553
+ billingClass={
554
+ selected?.billingClass ??
555
+ (props.model.startsWith("codex/") ? "codex_subscription" : "opengeni_credits")
556
+ }
557
+ className="text-og-fg"
558
+ />
559
+ <span className="truncate font-medium text-og-fg">{selected?.label ?? props.model}</span>
560
+ <span className="max-sm:hidden">{labelReasoningEffort(props.effort)}</span>
561
+ {props.latencyMode === "fast" ? (
562
+ <ZapIcon
563
+ className="size-3.5 shrink-0 fill-current stroke-current text-og-fg"
564
+ aria-label={messages.fast}
565
+ data-testid="model-picker-fast-icon"
566
+ />
567
+ ) : null}
568
+ <ChevronDownIcon className="size-3 shrink-0" />
569
+ </button>
570
+ </DropdownMenu.Trigger>
571
+ <DropdownMenu.Portal>
572
+ <DropdownMenu.Content
573
+ align="start"
574
+ side={props.menuSide ?? "bottom"}
575
+ sideOffset={8}
576
+ collisionPadding={12}
577
+ onCloseAutoFocus={(event) => event.preventDefault()}
578
+ className="z-50 flex max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] w-72 max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-og-lg border border-og-border bg-og-surface-1 p-1.5 text-og-fg shadow-og-lg"
579
+ >
580
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
581
+ <ModelPolicyPickerMenu {...props} nav={nav} onNavChange={setNav} />
582
+ </div>
583
+ </DropdownMenu.Content>
584
+ </DropdownMenu.Portal>
585
+ </DropdownMenu.Root>
586
+ );
587
+ }
@@ -382,7 +382,7 @@ function WakeButton({ children, onClick }: { children: ReactNode; onClick: () =>
382
382
  <button
383
383
  type="button"
384
384
  onClick={onClick}
385
- className="mt-1 inline-flex min-h-11 items-center justify-center rounded-og-md bg-og-accent px-3 py-2 text-og-sm font-medium text-og-on-accent shadow-sm transition-colors hover:bg-og-accent-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-2 focus-visible:ring-offset-og-bg"
385
+ className="mt-1 inline-flex min-h-11 items-center justify-center rounded-og-md bg-og-accent-deep px-3 py-2 text-og-sm font-medium text-og-accent-fg shadow-sm transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-accent focus-visible:ring-offset-2 focus-visible:ring-offset-og-bg"
386
386
  >
387
387
  {children}
388
388
  </button>