@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,1178 @@
1
+ import { type EffectiveSessionControl, type SessionEvent, type SessionStatus } from "@opengeni/sdk";
2
+ import {
3
+ hasStoredSessionRealtimeOwnerProof,
4
+ projectSessionRealtimeLifecycle,
5
+ type SessionRealtimeClientLike,
6
+ type SessionRealtimeController,
7
+ type SessionRealtimeControllerSnapshot,
8
+ type CreateSessionRealtimeControllerOptions,
9
+ type SessionRealtimeModel,
10
+ type WorkspaceRealtimeModelCatalogItem,
11
+ } from "@opengeni/sdk/realtime";
12
+ import {
13
+ AudioLinesIcon,
14
+ CheckIcon,
15
+ ChevronDownIcon,
16
+ CircleAlertIcon,
17
+ LoaderCircleIcon,
18
+ MicIcon,
19
+ MicOffIcon,
20
+ RotateCcwIcon,
21
+ SquareIcon,
22
+ Volume2Icon,
23
+ VolumeXIcon,
24
+ } from "lucide-react";
25
+ import { AnimatePresence, motion, useReducedMotion } from "motion/react";
26
+ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
27
+
28
+ import {
29
+ BillingClassMark,
30
+ PickerAnimatedPage,
31
+ PickerBackHeader,
32
+ PickerNavRow,
33
+ } from "../components/model-policy-picker";
34
+ import type { EmbeddedRealtimeSessionClientLike } from "../client";
35
+ import { cn } from "../lib/cn";
36
+ import type { PickerBillingClass as BillingClass } from "../model-policy";
37
+ import { useEmbeddedRealtimeSession } from "../session-context";
38
+ import {
39
+ DropdownMenu,
40
+ DropdownMenuContent,
41
+ DropdownMenuItem,
42
+ DropdownMenuSeparator,
43
+ DropdownMenuSub,
44
+ DropdownMenuSubContent,
45
+ DropdownMenuSubTrigger,
46
+ DropdownMenuTrigger,
47
+ } from "./dropdown-menu";
48
+
49
+ export type RealtimeModelOption = {
50
+ id: SessionRealtimeModel;
51
+ label: string;
52
+ provider: "OpenGeni" | "Connected Codex" | "Your Gateway";
53
+ description: string;
54
+ available: boolean;
55
+ unavailableReason: string | null;
56
+ recommended: boolean;
57
+ };
58
+
59
+ const CODEX_LIVE_MODEL: RealtimeModelOption = {
60
+ id: "gpt-live-1-boulder-alpha",
61
+ label: "Codex Live",
62
+ provider: "Connected Codex",
63
+ description: "Deep session integration",
64
+ available: false,
65
+ unavailableReason: "Connect Codex to use this voice model",
66
+ recommended: false,
67
+ };
68
+
69
+ const REALTIME_MODEL_PROVIDERS = ["OpenGeni", "Connected Codex", "Your Gateway"] as const;
70
+ type RealtimeModelProvider = (typeof REALTIME_MODEL_PROVIDERS)[number];
71
+
72
+ const REALTIME_PROVIDER_META: Record<
73
+ RealtimeModelProvider,
74
+ { billingClass: BillingClass; hint: string }
75
+ > = {
76
+ OpenGeni: { billingClass: "opengeni_credits", hint: "Will use credits" },
77
+ "Connected Codex": {
78
+ billingClass: "codex_subscription",
79
+ hint: "ChatGPT / Codex plan",
80
+ },
81
+ "Your Gateway": { billingClass: "byok", hint: "Billed to your AI Gateway" },
82
+ };
83
+ const REALTIME_MODEL_STORAGE_PREFIX = "opengeni:realtime-model";
84
+
85
+ type RealtimeControllerClient = EmbeddedRealtimeSessionClientLike & SessionRealtimeClientLike;
86
+
87
+ export type SessionRealtimeControllerFactory = (
88
+ options: CreateSessionRealtimeControllerOptions,
89
+ ) => SessionRealtimeController;
90
+
91
+ type AdmissionInput = {
92
+ sessionStatus: SessionStatus;
93
+ controlState: "active" | "paused";
94
+ settlement: { state: string } | null;
95
+ codexConnected: boolean;
96
+ lifecycleActive: boolean;
97
+ };
98
+
99
+ export function codexRealtimeAdmissionAllowed(input: AdmissionInput): boolean {
100
+ return codexRealtimeAdmissionBlocker(input) === null;
101
+ }
102
+
103
+ export function codexRealtimeAdmissionBlocker(input: AdmissionInput): string | null {
104
+ if (input.sessionStatus === "cancelled") return "This session was cancelled.";
105
+ if (input.controlState !== "active") return "Resume this session before starting voice.";
106
+ if (input.settlement !== null) return "Wait for the current session transition to finish.";
107
+ if (!input.codexConnected) return "Connect Codex to use this voice model.";
108
+ if (input.lifecycleActive) return "Voice is already active for this session.";
109
+ return null;
110
+ }
111
+
112
+ export function useSessionRealtime(options: {
113
+ client?: RealtimeControllerClient | undefined;
114
+ workspaceId?: string | undefined;
115
+ sessionId: string;
116
+ sessionStatus: SessionStatus;
117
+ effectiveControl: EffectiveSessionControl;
118
+ events: SessionEvent[];
119
+ eventsReady: boolean;
120
+ codexConnected: boolean;
121
+ model?: SessionRealtimeModel | undefined;
122
+ modelAvailable?: boolean | undefined;
123
+ modelUnavailableReason?: string | null | undefined;
124
+ /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */
125
+ controllerFactory?: SessionRealtimeControllerFactory | undefined;
126
+ }) {
127
+ const { client, workspaceId } = useEmbeddedRealtimeSession({
128
+ client: options.client,
129
+ workspaceId: options.workspaceId,
130
+ });
131
+ const model = options.model ?? CODEX_LIVE_MODEL.id;
132
+ const audioRef = useRef<HTMLAudioElement>(null);
133
+ const controllerRef = useRef<SessionRealtimeController | null>(null);
134
+ const controllerModelRef = useRef<SessionRealtimeModel | null>(null);
135
+ const [snapshot, setSnapshot] = useState<SessionRealtimeControllerSnapshot>(() => ({
136
+ status: hasStoredSessionRealtimeOwnerProof({ workspaceId, sessionId: options.sessionId, model })
137
+ ? "recovering"
138
+ : "idle",
139
+ realtimeId: null,
140
+ mode: null,
141
+ bridge: null,
142
+ microphone: "inactive",
143
+ inputMuted: false,
144
+ audibleOutput: "inactive",
145
+ outputMuted: false,
146
+ connectionGeneration: 0,
147
+ reconnectAttempt: 0,
148
+ diagnostic: null,
149
+ error: null,
150
+ }));
151
+ const lifecycle = useMemo(
152
+ () => projectSessionRealtimeLifecycle(options.events),
153
+ [options.events],
154
+ );
155
+ const lifecycleRef = useRef(lifecycle);
156
+ const eventsReadyRef = useRef(options.eventsReady);
157
+ lifecycleRef.current = lifecycle;
158
+ eventsReadyRef.current = options.eventsReady;
159
+ const lifecycleActive = lifecycle?.state === "active";
160
+
161
+ useEffect(() => {
162
+ let disposed = false;
163
+ let controller: SessionRealtimeController | null = null;
164
+ let unsubscribe: (() => void) | null = null;
165
+ void import("@opengeni/sdk/realtime")
166
+ .then(({ createSessionRealtimeController }) => {
167
+ if (disposed) return;
168
+ const controllerFactory = options.controllerFactory ?? createSessionRealtimeController;
169
+ controller = controllerFactory({
170
+ client,
171
+ workspaceId,
172
+ sessionId: options.sessionId,
173
+ ...(audioRef.current ? { remoteAudio: audioRef.current } : {}),
174
+ model,
175
+ });
176
+ controllerRef.current = controller;
177
+ controllerModelRef.current = model;
178
+ unsubscribe = controller.subscribe(setSnapshot);
179
+ if (eventsReadyRef.current) {
180
+ void controller.observeLifecycle(lifecycleRef.current).catch(() => undefined);
181
+ }
182
+ })
183
+ .catch((error: unknown) => {
184
+ if (disposed) return;
185
+ setSnapshot({
186
+ status: "error",
187
+ realtimeId: null,
188
+ mode: null,
189
+ bridge: null,
190
+ microphone: "inactive",
191
+ inputMuted: false,
192
+ audibleOutput: "inactive",
193
+ outputMuted: false,
194
+ connectionGeneration: 0,
195
+ reconnectAttempt: 0,
196
+ diagnostic: {
197
+ kind: "negotiation_failure",
198
+ message:
199
+ error instanceof Error ? error.message : "Codex realtime controller failed to load",
200
+ recoverable: false,
201
+ connectionGeneration: 0,
202
+ attempt: 0,
203
+ },
204
+ error:
205
+ error instanceof Error ? error.message : "Codex realtime controller failed to load",
206
+ });
207
+ });
208
+ return () => {
209
+ disposed = true;
210
+ unsubscribe?.();
211
+ controller?.close();
212
+ if (controllerRef.current === controller) {
213
+ controllerRef.current = null;
214
+ controllerModelRef.current = null;
215
+ }
216
+ };
217
+ }, [client, model, options.controllerFactory, options.sessionId, workspaceId]);
218
+
219
+ useEffect(() => {
220
+ if (!options.eventsReady) return;
221
+ void controllerRef.current?.observeLifecycle(lifecycle).catch(() => undefined);
222
+ }, [lifecycle, options.eventsReady]);
223
+
224
+ const start = useCallback(async () => {
225
+ const controller = controllerRef.current;
226
+ if (!controller || controllerModelRef.current !== model) {
227
+ throw new Error("The selected voice model is still preparing");
228
+ }
229
+ await controller.start();
230
+ }, [model]);
231
+ const stop = useCallback(async () => {
232
+ await controllerRef.current?.stop();
233
+ }, []);
234
+ const retry = useCallback(async () => {
235
+ await controllerRef.current?.retry();
236
+ }, []);
237
+ const retryAudibleOutput = useCallback(async () => {
238
+ await controllerRef.current?.retryAudibleOutput();
239
+ }, []);
240
+ const setInputMuted = useCallback((muted: boolean) => {
241
+ controllerRef.current?.setInputMuted(muted);
242
+ }, []);
243
+ const setOutputMuted = useCallback((muted: boolean) => {
244
+ controllerRef.current?.setOutputMuted(muted);
245
+ }, []);
246
+ const admissionInput = {
247
+ sessionStatus: options.sessionStatus,
248
+ controlState: options.effectiveControl.state,
249
+ settlement: options.effectiveControl.settlement,
250
+ codexConnected: options.modelAvailable ?? options.codexConnected,
251
+ lifecycleActive,
252
+ } satisfies AdmissionInput;
253
+ const admissionBlocker =
254
+ options.modelAvailable === false
255
+ ? (options.modelUnavailableReason ?? "This voice model is unavailable.")
256
+ : codexRealtimeAdmissionBlocker(admissionInput);
257
+ const canStart =
258
+ controllerRef.current !== null &&
259
+ controllerModelRef.current === model &&
260
+ ["idle", "error"].includes(snapshot.status) &&
261
+ admissionBlocker === null;
262
+
263
+ return {
264
+ snapshot,
265
+ lifecycleActive,
266
+ canStart,
267
+ admissionBlocker,
268
+ codexConnected: options.codexConnected,
269
+ audioRef,
270
+ start,
271
+ stop,
272
+ retry,
273
+ retryAudibleOutput,
274
+ setInputMuted,
275
+ setOutputMuted,
276
+ };
277
+ }
278
+
279
+ export function SessionRealtimeControl(props: {
280
+ client?: RealtimeControllerClient | undefined;
281
+ workspaceId?: string | undefined;
282
+ sessionId: string;
283
+ sessionStatus: SessionStatus;
284
+ effectiveControl: EffectiveSessionControl;
285
+ events: SessionEvent[];
286
+ eventsReady: boolean;
287
+ codexConnected: boolean;
288
+ realtimeAutostartModel?: SessionRealtimeModel | undefined;
289
+ onRealtimeAutostartConsumed?: (() => void) | undefined;
290
+ /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */
291
+ controllerFactory?: SessionRealtimeControllerFactory | undefined;
292
+ }) {
293
+ const lifecycle = useMemo(() => projectSessionRealtimeLifecycle(props.events), [props.events]);
294
+ const selection = useRealtimeModelSelection({
295
+ client: props.client,
296
+ workspaceId: props.workspaceId,
297
+ codexConnected: props.codexConnected,
298
+ activeModel: lifecycle?.state === "active" ? lifecycle.model : null,
299
+ });
300
+ const selectedModel = selection.selectedModel;
301
+ const realtime = useSessionRealtime({
302
+ ...props,
303
+ model: selectedModel.id,
304
+ modelAvailable: selectedModel.available,
305
+ modelUnavailableReason: selectedModel.unavailableReason,
306
+ });
307
+ const autostartModelRef = useRef(props.realtimeAutostartModel ?? null);
308
+ const autostartStartedRef = useRef(false);
309
+
310
+ const { models, selectedModel: selectedRealtimeModel, selectModel } = selection;
311
+ const { canStart, start } = realtime;
312
+ const onRealtimeAutostartConsumed = props.onRealtimeAutostartConsumed;
313
+
314
+ useEffect(() => {
315
+ const pending = autostartModelRef.current;
316
+ if (!pending || autostartStartedRef.current || lifecycle?.state === "active") return;
317
+ const available = models.some((model) => model.id === pending && model.available);
318
+ if (!available) return;
319
+ if (selectedRealtimeModel.id !== pending) {
320
+ selectModel(pending);
321
+ return;
322
+ }
323
+ if (!canStart) return;
324
+ autostartStartedRef.current = true;
325
+ void start()
326
+ .then(() => {
327
+ autostartModelRef.current = null;
328
+ onRealtimeAutostartConsumed?.();
329
+ })
330
+ .catch(() => {
331
+ autostartStartedRef.current = false;
332
+ });
333
+ }, [
334
+ canStart,
335
+ lifecycle?.state,
336
+ models,
337
+ props.sessionId,
338
+ props.workspaceId,
339
+ onRealtimeAutostartConsumed,
340
+ selectModel,
341
+ selectedRealtimeModel.id,
342
+ start,
343
+ ]);
344
+
345
+ return (
346
+ <RealtimeVoiceControl
347
+ snapshot={realtime.snapshot}
348
+ canStart={realtime.canStart}
349
+ admissionBlocker={realtime.admissionBlocker}
350
+ modelAvailable={selectedModel.available}
351
+ menuSide="top"
352
+ audioRef={realtime.audioRef}
353
+ selectedModel={selectedModel}
354
+ models={selection.models}
355
+ onSelectModel={realtime.lifecycleActive ? undefined : selection.selectModel}
356
+ onStart={realtime.start}
357
+ onStop={realtime.stop}
358
+ onRetry={realtime.retry}
359
+ onRetryAudibleOutput={realtime.retryAudibleOutput}
360
+ onSetInputMuted={realtime.setInputMuted}
361
+ onSetOutputMuted={realtime.setOutputMuted}
362
+ />
363
+ );
364
+ }
365
+
366
+ export function useRealtimeModelSelection(options: {
367
+ client?: RealtimeControllerClient | undefined;
368
+ workspaceId?: string | undefined;
369
+ codexConnected: boolean;
370
+ activeModel?: SessionRealtimeModel | null | undefined;
371
+ }) {
372
+ const { client, workspaceId } = useEmbeddedRealtimeSession({
373
+ client: options.client,
374
+ workspaceId: options.workspaceId,
375
+ });
376
+ const activeModel = options.activeModel;
377
+ const [catalog, setCatalog] = useState<RealtimeModelOption[]>(() => [
378
+ {
379
+ ...CODEX_LIVE_MODEL,
380
+ available: options.codexConnected,
381
+ unavailableReason: options.codexConnected ? null : CODEX_LIVE_MODEL.unavailableReason,
382
+ },
383
+ ]);
384
+ const [selectedModelId, setSelectedModelId] = useState<SessionRealtimeModel>(
385
+ () => readRealtimeModelPreference(workspaceId) ?? CODEX_LIVE_MODEL.id,
386
+ );
387
+
388
+ useEffect(() => {
389
+ let disposed = false;
390
+ const load = client.getWorkspaceRealtimeModelCatalog;
391
+ if (!load) return;
392
+ void load
393
+ .call(client, workspaceId)
394
+ .then((response) => {
395
+ if (disposed) return;
396
+ const models = response.models.map(toRealtimeModelOption);
397
+ setCatalog(models);
398
+ setSelectedModelId((current) => {
399
+ if (models.some((model) => model.id === current && model.available)) return current;
400
+ return models.find((model) => model.available)?.id ?? CODEX_LIVE_MODEL.id;
401
+ });
402
+ })
403
+ .catch(() => undefined);
404
+ return () => {
405
+ disposed = true;
406
+ };
407
+ }, [client, workspaceId]);
408
+
409
+ useEffect(() => {
410
+ if (activeModel) setSelectedModelId(activeModel);
411
+ }, [activeModel]);
412
+
413
+ const selectedModel =
414
+ catalog.find((model) => model.id === selectedModelId) ??
415
+ ({
416
+ ...CODEX_LIVE_MODEL,
417
+ available: options.codexConnected,
418
+ unavailableReason: options.codexConnected ? null : CODEX_LIVE_MODEL.unavailableReason,
419
+ } satisfies RealtimeModelOption);
420
+ const selectModel = useCallback(
421
+ (value: string) => {
422
+ const model = catalog.find((candidate) => candidate.id === value);
423
+ if (!model || !model.available || activeModel) return;
424
+ setSelectedModelId(model.id);
425
+ writeRealtimeModelPreference(workspaceId, model.id);
426
+ },
427
+ [activeModel, catalog, workspaceId],
428
+ );
429
+
430
+ return { models: catalog, selectedModel, selectModel };
431
+ }
432
+
433
+ const IDLE_REALTIME_SNAPSHOT: SessionRealtimeControllerSnapshot = {
434
+ status: "idle",
435
+ realtimeId: null,
436
+ mode: null,
437
+ bridge: null,
438
+ microphone: "inactive",
439
+ inputMuted: false,
440
+ audibleOutput: "inactive",
441
+ outputMuted: false,
442
+ connectionGeneration: 0,
443
+ reconnectAttempt: 0,
444
+ diagnostic: null,
445
+ error: null,
446
+ };
447
+
448
+ export function NewSessionRealtimeControl(props: {
449
+ client?: RealtimeControllerClient | undefined;
450
+ workspaceId?: string | undefined;
451
+ codexConnected: boolean;
452
+ disabled?: boolean | undefined;
453
+ disabledReason?: string | null | undefined;
454
+ onStart: (model: SessionRealtimeModel) => Promise<boolean>;
455
+ }) {
456
+ const selection = useRealtimeModelSelection({
457
+ client: props.client,
458
+ workspaceId: props.workspaceId,
459
+ codexConnected: props.codexConnected,
460
+ });
461
+ const audioRef = useRef<HTMLAudioElement>(null);
462
+ const [starting, setStarting] = useState(false);
463
+ const [error, setError] = useState<string | null>(null);
464
+ const snapshot = useMemo<SessionRealtimeControllerSnapshot>(
465
+ () =>
466
+ starting
467
+ ? { ...IDLE_REALTIME_SNAPSHOT, status: "starting" }
468
+ : error
469
+ ? { ...IDLE_REALTIME_SNAPSHOT, status: "error", error }
470
+ : IDLE_REALTIME_SNAPSHOT,
471
+ [error, starting],
472
+ );
473
+ const canStart = selection.selectedModel.available && props.disabled !== true && !starting;
474
+ const onStart = props.onStart;
475
+ const selectedModelId = selection.selectedModel.id;
476
+ const start = useCallback(async () => {
477
+ if (!canStart) return;
478
+ setStarting(true);
479
+ setError(null);
480
+ try {
481
+ const started = await onStart(selectedModelId);
482
+ if (!started) setError("The voice session could not be created.");
483
+ } catch (cause) {
484
+ setError(cause instanceof Error ? cause.message : String(cause));
485
+ } finally {
486
+ setStarting(false);
487
+ }
488
+ }, [canStart, onStart, selectedModelId]);
489
+
490
+ return (
491
+ <RealtimeVoiceControl
492
+ snapshot={snapshot}
493
+ canStart={canStart}
494
+ admissionBlocker={selection.selectedModel.unavailableReason ?? props.disabledReason ?? null}
495
+ modelAvailable={selection.selectedModel.available}
496
+ selectionDisabled={starting}
497
+ menuSide="bottom"
498
+ showDiagnostics={false}
499
+ audioRef={audioRef}
500
+ selectedModel={selection.selectedModel}
501
+ models={selection.models}
502
+ onSelectModel={selection.selectModel}
503
+ onStart={start}
504
+ onStop={async () => undefined}
505
+ onRetry={start}
506
+ onRetryAudibleOutput={async () => undefined}
507
+ onSetInputMuted={() => undefined}
508
+ onSetOutputMuted={() => undefined}
509
+ />
510
+ );
511
+ }
512
+
513
+ export function RealtimeVoiceControl(props: {
514
+ snapshot: SessionRealtimeControllerSnapshot;
515
+ canStart: boolean;
516
+ admissionBlocker?: string | null | undefined;
517
+ modelAvailable: boolean;
518
+ selectionDisabled?: boolean | undefined;
519
+ /** Prefer `bottom` on home/new-session; `top` for the docked session composer. */
520
+ menuSide?: "top" | "bottom" | undefined;
521
+ showDiagnostics?: boolean;
522
+ audioRef: RefObject<HTMLAudioElement | null>;
523
+ selectedModel?: RealtimeModelOption | undefined;
524
+ models?: readonly RealtimeModelOption[] | undefined;
525
+ onSelectModel?: ((modelId: string) => void) | undefined;
526
+ onStart: () => Promise<void>;
527
+ onStop: () => Promise<void>;
528
+ onRetry: () => Promise<void>;
529
+ onRetryAudibleOutput: () => Promise<void>;
530
+ onSetInputMuted: (muted: boolean) => void;
531
+ onSetOutputMuted: (muted: boolean) => void;
532
+ }) {
533
+ const reduceMotion = useReducedMotion();
534
+ const selectedModel = props.selectedModel ?? {
535
+ ...CODEX_LIVE_MODEL,
536
+ available: props.modelAvailable,
537
+ unavailableReason: props.modelAvailable ? null : CODEX_LIVE_MODEL.unavailableReason,
538
+ };
539
+ const models = props.models ?? [selectedModel];
540
+ const status = statusContent(
541
+ props.snapshot,
542
+ props.modelAvailable,
543
+ props.canStart,
544
+ props.admissionBlocker ?? null,
545
+ selectedModel.label,
546
+ );
547
+ const modeOwned = props.snapshot.mode?.state === "active";
548
+ const retryConnection =
549
+ modeOwned &&
550
+ props.snapshot.status !== "stopping" &&
551
+ props.snapshot.status !== "active" &&
552
+ props.snapshot.diagnostic?.recoverable === true;
553
+ const audioBlocked = props.snapshot.audibleOutput === "blocked" && !props.snapshot.outputMuted;
554
+ const mainDisabled =
555
+ props.snapshot.status === "stopping" ||
556
+ props.snapshot.status === "lost_owner" ||
557
+ (!modeOwned && !props.canStart) ||
558
+ (props.snapshot.status === "starting" && !modeOwned);
559
+ const mainLabel = audioBlocked
560
+ ? "Resume voice audio"
561
+ : retryConnection
562
+ ? "Retry voice connection"
563
+ : modeOwned
564
+ ? "End voice conversation"
565
+ : `Start voice with ${selectedModel.label}`;
566
+ const runMainAction = audioBlocked
567
+ ? props.onRetryAudibleOutput
568
+ : retryConnection
569
+ ? props.onRetry
570
+ : modeOwned
571
+ ? props.onStop
572
+ : props.onStart;
573
+ const diagnosticsVisible = props.showDiagnostics ?? import.meta.env.DEV;
574
+ const [pickerOpen, setPickerOpen] = useState(false);
575
+ const [pickerProvider, setPickerProvider] = useState<RealtimeModelProvider | null>(
576
+ selectedModel.provider,
577
+ );
578
+ const [pickerDirection, setPickerDirection] = useState<1 | -1>(1);
579
+
580
+ useEffect(() => {
581
+ if (!pickerOpen) setPickerProvider(selectedModel.provider);
582
+ }, [pickerOpen, selectedModel.provider]);
583
+
584
+ return (
585
+ <div
586
+ aria-label="Realtime voice"
587
+ data-picker-side={props.menuSide ?? "top"}
588
+ className="inline-flex shrink-0 items-center"
589
+ >
590
+ <audio ref={props.audioRef} autoPlay className="hidden" aria-hidden="true" />
591
+ <RealtimeComposerGlow phase={status.phase} reduceMotion={reduceMotion === true} />
592
+ <AnimatePresence initial={false}>
593
+ {modeOwned ? (
594
+ <motion.div
595
+ key="realtime-mute-controls"
596
+ data-testid="realtime-mute-controls"
597
+ initial={reduceMotion ? false : { opacity: 0, width: 0, marginRight: 0 }}
598
+ animate={{ opacity: 1, width: "auto", marginRight: 4 }}
599
+ {...(reduceMotion ? {} : { exit: { opacity: 0, width: 0, marginRight: 0 } })}
600
+ transition={{ duration: reduceMotion ? 0 : 0.18, ease: [0.2, 0.8, 0.2, 1] }}
601
+ className="inline-flex shrink-0 items-center gap-0.5 overflow-hidden"
602
+ >
603
+ <RealtimeMuteButton
604
+ muted={props.snapshot.inputMuted}
605
+ kind="microphone"
606
+ reduceMotion={reduceMotion === true}
607
+ onToggle={() => props.onSetInputMuted(!props.snapshot.inputMuted)}
608
+ />
609
+ <RealtimeMuteButton
610
+ muted={props.snapshot.outputMuted}
611
+ kind="output"
612
+ reduceMotion={reduceMotion === true}
613
+ onToggle={() => props.onSetOutputMuted(!props.snapshot.outputMuted)}
614
+ />
615
+ </motion.div>
616
+ ) : null}
617
+ </AnimatePresence>
618
+ <motion.button
619
+ type="button"
620
+ data-testid="realtime-primary-action"
621
+ data-phase={status.phase}
622
+ aria-label={mainLabel}
623
+ aria-pressed={modeOwned}
624
+ title={mainLabel}
625
+ disabled={mainDisabled}
626
+ {...(reduceMotion ? {} : { whileTap: { scale: 0.92 } })}
627
+ transition={{ type: "spring", stiffness: 520, damping: 30 }}
628
+ onClick={() => void runMainAction().catch(() => undefined)}
629
+ className={cn(
630
+ "relative inline-flex size-8 items-center justify-center rounded-l-og-md border outline-none",
631
+ "transition-[background-color,border-color,color,box-shadow] duration-200 ease-og-out",
632
+ "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
633
+ "disabled:cursor-not-allowed disabled:opacity-45 pointer-coarse:size-11",
634
+ voiceButtonTone(status.phase),
635
+ )}
636
+ >
637
+ <RealtimeActionGlyph
638
+ phase={status.phase}
639
+ audioBlocked={audioBlocked}
640
+ reduceMotion={reduceMotion === true}
641
+ />
642
+ </motion.button>
643
+
644
+ <DropdownMenu open={pickerOpen} onOpenChange={setPickerOpen}>
645
+ <DropdownMenuTrigger asChild>
646
+ <button
647
+ type="button"
648
+ aria-label="Choose voice model and options"
649
+ title={`Voice model: ${selectedModel.label}`}
650
+ disabled={props.selectionDisabled}
651
+ className={cn(
652
+ "inline-flex h-8 w-6 items-center justify-center rounded-r-og-md border border-l-0 outline-none",
653
+ "transition-[background-color,border-color,color] duration-200 ease-og-out",
654
+ "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
655
+ "pointer-coarse:h-11 pointer-coarse:w-7",
656
+ voiceButtonTone(status.phase),
657
+ )}
658
+ >
659
+ <ChevronDownIcon className="size-3" aria-hidden />
660
+ </button>
661
+ </DropdownMenuTrigger>
662
+ <DropdownMenuContent
663
+ side={props.menuSide ?? "top"}
664
+ align="end"
665
+ sideOffset={8}
666
+ collisionPadding={12}
667
+ className="flex w-72 max-h-[min(20rem,var(--radix-dropdown-menu-content-available-height))] flex-col overflow-hidden rounded-xl border-border bg-surface p-1.5 shadow-xl"
668
+ onCloseAutoFocus={(event) => event.preventDefault()}
669
+ >
670
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
671
+ <RealtimeModelPickerMenu
672
+ models={models}
673
+ selectedModel={selectedModel}
674
+ provider={pickerProvider}
675
+ direction={pickerDirection}
676
+ disabled={props.selectionDisabled === true || props.snapshot.mode?.state === "active"}
677
+ onProviderChange={(provider, direction) => {
678
+ setPickerDirection(direction);
679
+ setPickerProvider(provider);
680
+ }}
681
+ onSelect={(modelId) => {
682
+ props.onSelectModel?.(modelId);
683
+ setPickerOpen(false);
684
+ }}
685
+ />
686
+ </div>
687
+
688
+ {status.phase !== "idle" ? (
689
+ <>
690
+ <DropdownMenuSeparator className="mx-1 bg-og-border" />
691
+ <div className="px-2.5 py-2" role="status" aria-live="polite">
692
+ <div className="flex items-center gap-2 text-og-sm font-medium text-og-fg">
693
+ <RealtimeStatusDot phase={status.phase} reduceMotion={reduceMotion === true} />
694
+ {status.label}
695
+ </div>
696
+ <p className="mt-1 text-og-xs leading-5 text-og-fg-subtle">{status.detail}</p>
697
+ </div>
698
+ </>
699
+ ) : null}
700
+
701
+ {audioBlocked ? (
702
+ <DropdownMenuItem
703
+ className="rounded-og-md"
704
+ onSelect={() => void props.onRetryAudibleOutput().catch(() => undefined)}
705
+ >
706
+ <Volume2Icon />
707
+ Resume audio
708
+ </DropdownMenuItem>
709
+ ) : null}
710
+ {retryConnection ? (
711
+ <DropdownMenuItem
712
+ className="rounded-og-md"
713
+ onSelect={() => void props.onRetry().catch(() => undefined)}
714
+ >
715
+ <RotateCcwIcon />
716
+ Retry connection
717
+ </DropdownMenuItem>
718
+ ) : null}
719
+ {modeOwned && props.snapshot.status !== "lost_owner" ? (
720
+ <DropdownMenuItem
721
+ variant="destructive"
722
+ className="rounded-og-md"
723
+ disabled={props.snapshot.status === "stopping"}
724
+ onSelect={() => void props.onStop().catch(() => undefined)}
725
+ >
726
+ <SquareIcon />
727
+ End voice conversation
728
+ </DropdownMenuItem>
729
+ ) : null}
730
+
731
+ {diagnosticsVisible ? <RealtimeDiagnosticsMenu snapshot={props.snapshot} /> : null}
732
+ </DropdownMenuContent>
733
+ </DropdownMenu>
734
+
735
+ <span className="sr-only" role="status" aria-live="polite">
736
+ {status.label}. {status.detail}
737
+ </span>
738
+ {props.snapshot.error ? (
739
+ <span className="sr-only" role="alert">
740
+ {props.snapshot.error}
741
+ </span>
742
+ ) : null}
743
+ </div>
744
+ );
745
+ }
746
+
747
+ function RealtimeMuteButton(props: {
748
+ muted: boolean;
749
+ kind: "microphone" | "output";
750
+ reduceMotion: boolean;
751
+ onToggle(): void;
752
+ }) {
753
+ const microphone = props.kind === "microphone";
754
+ const label = microphone
755
+ ? props.muted
756
+ ? "Unmute microphone"
757
+ : "Mute microphone"
758
+ : props.muted
759
+ ? "Unmute voice audio"
760
+ : "Mute voice audio";
761
+ const Icon = microphone
762
+ ? props.muted
763
+ ? MicOffIcon
764
+ : MicIcon
765
+ : props.muted
766
+ ? VolumeXIcon
767
+ : Volume2Icon;
768
+
769
+ return (
770
+ <motion.button
771
+ type="button"
772
+ aria-label={label}
773
+ aria-pressed={props.muted}
774
+ title={label}
775
+ {...(props.reduceMotion ? {} : { whileTap: { scale: 0.92 } })}
776
+ onClick={props.onToggle}
777
+ className={cn(
778
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md border outline-none",
779
+ "transition-[background-color,border-color,color] duration-150 ease-og-out",
780
+ "focus-visible:ring-2 focus-visible:ring-og-accent/45 pointer-coarse:size-11",
781
+ props.muted
782
+ ? "border-og-accent/35 bg-og-accent-soft text-og-accent"
783
+ : "border-og-border bg-og-surface-2 text-og-fg-muted hover:border-og-accent/35 hover:text-og-fg",
784
+ )}
785
+ >
786
+ <Icon className="size-3.5" aria-hidden />
787
+ </motion.button>
788
+ );
789
+ }
790
+
791
+ export function RealtimeModelPickerMenu(props: {
792
+ models: readonly RealtimeModelOption[];
793
+ selectedModel: RealtimeModelOption;
794
+ provider: RealtimeModelProvider | null;
795
+ direction: 1 | -1;
796
+ disabled: boolean;
797
+ onProviderChange: (provider: RealtimeModelProvider | null, direction: 1 | -1) => void;
798
+ onSelect: (modelId: SessionRealtimeModel) => void;
799
+ }) {
800
+ const providers = REALTIME_MODEL_PROVIDERS.filter((provider) =>
801
+ props.models.some((model) => model.provider === provider),
802
+ );
803
+ const pageKey = props.provider ? `voice-models:${props.provider}` : "voice-providers";
804
+ const body = props.provider ? (
805
+ <div data-testid="realtime-model-picker-models">
806
+ <PickerBackHeader
807
+ label={props.provider}
808
+ icon={
809
+ <BillingClassMark
810
+ billingClass={REALTIME_PROVIDER_META[props.provider].billingClass}
811
+ aria-label=""
812
+ />
813
+ }
814
+ onBack={() => props.onProviderChange(null, -1)}
815
+ />
816
+ <div className="flex flex-col gap-0.5">
817
+ {props.models
818
+ .filter((model) => model.provider === props.provider)
819
+ .map((model) => {
820
+ const selected = model.id === props.selectedModel.id;
821
+ return (
822
+ <PickerNavRow
823
+ key={model.id}
824
+ label={model.label}
825
+ hint={
826
+ model.unavailableReason ??
827
+ (model.recommended ? `Recommended · ${model.description}` : model.description)
828
+ }
829
+ disabled={props.disabled || !model.available}
830
+ title={model.unavailableReason ?? model.description}
831
+ active={selected}
832
+ showChevron={false}
833
+ trailing={selected ? <CheckIcon className="size-3.5" aria-hidden /> : undefined}
834
+ testId={`realtime-model-choice-${model.id}`}
835
+ onClick={() => props.onSelect(model.id)}
836
+ />
837
+ );
838
+ })}
839
+ </div>
840
+ </div>
841
+ ) : (
842
+ <div className="flex flex-col gap-0.5" data-testid="realtime-model-picker-providers">
843
+ {providers.map((provider) => {
844
+ const meta = REALTIME_PROVIDER_META[provider];
845
+ return (
846
+ <PickerNavRow
847
+ key={provider}
848
+ label={provider}
849
+ hint={meta.hint}
850
+ icon={<BillingClassMark billingClass={meta.billingClass} aria-label="" />}
851
+ active={props.selectedModel.provider === provider}
852
+ testId={`realtime-model-provider-${meta.billingClass}`}
853
+ onClick={() => props.onProviderChange(provider, 1)}
854
+ />
855
+ );
856
+ })}
857
+ </div>
858
+ );
859
+
860
+ return (
861
+ <div className="relative overflow-hidden" data-testid="realtime-model-picker-menu">
862
+ <PickerAnimatedPage pageKey={pageKey} direction={props.direction}>
863
+ {body}
864
+ </PickerAnimatedPage>
865
+ </div>
866
+ );
867
+ }
868
+
869
+ function RealtimeDiagnosticsMenu({ snapshot }: { snapshot: SessionRealtimeControllerSnapshot }) {
870
+ const bridge = snapshot.bridge;
871
+ const rows = [
872
+ ["controller", snapshot.status],
873
+ ["microphone", snapshot.microphone],
874
+ ["microphone muted", String(snapshot.inputMuted)],
875
+ ["audio", snapshot.audibleOutput],
876
+ ["audio muted", String(snapshot.outputMuted)],
877
+ ["generation", String(snapshot.connectionGeneration)],
878
+ ["reconnect attempt", String(snapshot.reconnectAttempt)],
879
+ ["mode", snapshot.mode?.state ?? "none"],
880
+ ["mode version", snapshot.mode ? String(snapshot.mode.version) : "—"],
881
+ ["connection epoch", snapshot.mode ? String(snapshot.mode.connectionEpoch) : "—"],
882
+ ["provider started", bridge ? String(bridge.providerStarted) : "false"],
883
+ ["provider speaking", bridge ? String(bridge.speaking) : "false"],
884
+ ["delegation", bridge?.activeDelegationId ? "active" : "none"],
885
+ ["pending durable events", bridge ? String(bridge.pendingInbound) : "0"],
886
+ ["pending bytes", bridge ? String(bridge.pendingInboundBytes) : "0"],
887
+ ["client ack", bridge?.clientAckThroughSequence?.toString() ?? "—"],
888
+ ["ignored provider events", bridge ? String(bridge.ignoredEventCount) : "0"],
889
+ ["last ignored event", bridge?.lastIgnoredEventType ?? "none"],
890
+ ["bridge error", bridge?.lastError ?? "none"],
891
+ ["diagnostic", snapshot.diagnostic?.kind ?? "none"],
892
+ ] as const;
893
+
894
+ return (
895
+ <>
896
+ <DropdownMenuSeparator className="mx-1 bg-og-border" />
897
+ <DropdownMenuSub>
898
+ <DropdownMenuSubTrigger className="rounded-og-md text-og-fg-muted">
899
+ Realtime diagnostics
900
+ </DropdownMenuSubTrigger>
901
+ <DropdownMenuSubContent className="max-h-80 w-72 overflow-y-auto rounded-og-lg border-og-border bg-og-surface-1 p-1.5 text-og-fg shadow-og-lg">
902
+ {rows.map(([label, value]) => (
903
+ <DropdownMenuItem
904
+ key={label}
905
+ disabled
906
+ className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-3 rounded-og-md font-mono text-[11px] opacity-100"
907
+ >
908
+ <span className="truncate text-og-fg-subtle">{label}</span>
909
+ <span className="truncate text-right text-og-fg">{value}</span>
910
+ </DropdownMenuItem>
911
+ ))}
912
+ </DropdownMenuSubContent>
913
+ </DropdownMenuSub>
914
+ </>
915
+ );
916
+ }
917
+
918
+ type RealtimeVisualPhase =
919
+ | "idle"
920
+ | "connecting"
921
+ | "listening"
922
+ | "speaking"
923
+ | "reconnecting"
924
+ | "blocked"
925
+ | "unavailable"
926
+ | "error"
927
+ | "elsewhere"
928
+ | "stopping";
929
+
930
+ function statusContent(
931
+ snapshot: SessionRealtimeControllerSnapshot,
932
+ modelAvailable: boolean,
933
+ canStart: boolean,
934
+ admissionBlocker: string | null,
935
+ modelLabel: string,
936
+ ): { phase: RealtimeVisualPhase; label: string; detail: string } {
937
+ if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) {
938
+ return {
939
+ phase: "blocked",
940
+ label: "Audio output blocked",
941
+ detail: "Resume audio to hear the existing voice connection.",
942
+ };
943
+ }
944
+ switch (snapshot.status) {
945
+ case "starting":
946
+ return {
947
+ phase: "connecting",
948
+ label: "Connecting…",
949
+ detail: "Preparing your microphone and live voice.",
950
+ };
951
+ case "active":
952
+ return snapshot.bridge?.speaking
953
+ ? {
954
+ phase: "speaking",
955
+ label: "Speaking",
956
+ detail: "Interrupt naturally whenever you want.",
957
+ }
958
+ : {
959
+ phase: "listening",
960
+ label: "Listening",
961
+ detail: "Voice and typed messages share this session.",
962
+ };
963
+ case "stopping":
964
+ return {
965
+ phase: "stopping",
966
+ label: "Ending voice…",
967
+ detail: "Saving the remaining conversation context.",
968
+ };
969
+ case "recovering":
970
+ return {
971
+ phase: "reconnecting",
972
+ label: "Reconnecting…",
973
+ detail: snapshot.error ?? "Keeping this voice conversation connected.",
974
+ };
975
+ case "lost_owner":
976
+ return {
977
+ phase: "elsewhere",
978
+ label: "Voice active elsewhere",
979
+ detail: "Return to the browser that started it, or wait for that connection to expire.",
980
+ };
981
+ case "error":
982
+ return {
983
+ phase: "error",
984
+ label: "Voice unavailable",
985
+ detail: snapshot.error ?? "The connection could not be started.",
986
+ };
987
+ case "idle":
988
+ if (!modelAvailable) {
989
+ return {
990
+ phase: "unavailable",
991
+ label: "Voice model unavailable",
992
+ detail: admissionBlocker ?? "Choose another voice model.",
993
+ };
994
+ }
995
+ if (!canStart && admissionBlocker) {
996
+ return { phase: "unavailable", label: "Voice unavailable", detail: admissionBlocker };
997
+ }
998
+ return {
999
+ phase: "idle",
1000
+ label: "Start voice",
1001
+ detail: `Talk live with ${modelLabel}.`,
1002
+ };
1003
+ }
1004
+ }
1005
+
1006
+ function RealtimeComposerGlow(props: { phase: RealtimeVisualPhase; reduceMotion: boolean }) {
1007
+ const visible = [
1008
+ "connecting",
1009
+ "listening",
1010
+ "speaking",
1011
+ "reconnecting",
1012
+ "blocked",
1013
+ "error",
1014
+ "stopping",
1015
+ ].includes(props.phase);
1016
+ return (
1017
+ <AnimatePresence initial={false}>
1018
+ {visible ? (
1019
+ <motion.span
1020
+ aria-hidden
1021
+ key={props.phase}
1022
+ initial={{ opacity: 0 }}
1023
+ animate={
1024
+ props.reduceMotion
1025
+ ? { opacity: 0.48 }
1026
+ : props.phase === "speaking"
1027
+ ? { opacity: [0.42, 0.82, 0.52] }
1028
+ : props.phase === "listening"
1029
+ ? { opacity: [0.34, 0.62, 0.42] }
1030
+ : { opacity: [0.28, 0.58, 0.28] }
1031
+ }
1032
+ exit={{ opacity: 0 }}
1033
+ transition={
1034
+ props.reduceMotion
1035
+ ? { duration: 0.12 }
1036
+ : {
1037
+ duration: props.phase === "speaking" ? 1.15 : 2.1,
1038
+ ease: "easeInOut",
1039
+ repeat: Number.POSITIVE_INFINITY,
1040
+ }
1041
+ }
1042
+ className={cn(
1043
+ "pointer-events-none absolute -inset-px z-[1] rounded-og-lg ring-1 ring-inset shadow-og-glow",
1044
+ props.phase === "speaking"
1045
+ ? "ring-og-status-running/50"
1046
+ : props.phase === "blocked" || props.phase === "error"
1047
+ ? "ring-og-status-waiting/45"
1048
+ : "ring-og-accent/45",
1049
+ )}
1050
+ />
1051
+ ) : null}
1052
+ </AnimatePresence>
1053
+ );
1054
+ }
1055
+
1056
+ function RealtimeActionGlyph(props: {
1057
+ phase: RealtimeVisualPhase;
1058
+ audioBlocked: boolean;
1059
+ reduceMotion: boolean;
1060
+ }) {
1061
+ if (["connecting", "reconnecting", "stopping"].includes(props.phase)) {
1062
+ return <LoaderCircleIcon className="size-3.5 animate-og-spin" aria-hidden />;
1063
+ }
1064
+ if (props.phase === "blocked") {
1065
+ return props.audioBlocked ? (
1066
+ <Volume2Icon className="size-3.5" aria-hidden />
1067
+ ) : (
1068
+ <CircleAlertIcon className="size-3.5" aria-hidden />
1069
+ );
1070
+ }
1071
+ if (props.phase === "error" || props.phase === "elsewhere" || props.phase === "unavailable") {
1072
+ return <CircleAlertIcon className="size-3.5" aria-hidden />;
1073
+ }
1074
+ if (props.phase === "listening" || props.phase === "speaking") {
1075
+ return (
1076
+ <span className="relative inline-flex size-4 items-center justify-center">
1077
+ {!props.reduceMotion ? (
1078
+ <motion.span
1079
+ aria-hidden
1080
+ className="absolute inset-0 rounded-full border border-current"
1081
+ animate={{ opacity: [0.65, 0], scale: [0.65, 1.45] }}
1082
+ transition={{
1083
+ duration: props.phase === "speaking" ? 0.9 : 1.5,
1084
+ ease: "easeOut",
1085
+ repeat: Number.POSITIVE_INFINITY,
1086
+ }}
1087
+ />
1088
+ ) : null}
1089
+ <SquareIcon className="relative size-2.5 fill-current" aria-hidden />
1090
+ </span>
1091
+ );
1092
+ }
1093
+ return <AudioLinesIcon className="size-4" aria-hidden />;
1094
+ }
1095
+
1096
+ function RealtimeStatusDot(props: { phase: RealtimeVisualPhase; reduceMotion: boolean }) {
1097
+ const live = props.phase === "listening" || props.phase === "speaking";
1098
+ return (
1099
+ <span className="relative inline-flex size-2 shrink-0" aria-hidden>
1100
+ {live && !props.reduceMotion ? (
1101
+ <motion.span
1102
+ className="absolute inset-0 rounded-full bg-og-status-running"
1103
+ animate={{ opacity: [0.45, 0], scale: [1, 2.2] }}
1104
+ transition={{ duration: 1.4, ease: "easeOut", repeat: Number.POSITIVE_INFINITY }}
1105
+ />
1106
+ ) : null}
1107
+ <span
1108
+ className={cn(
1109
+ "relative size-2 rounded-full",
1110
+ live
1111
+ ? "bg-og-status-running"
1112
+ : props.phase === "blocked" || props.phase === "error"
1113
+ ? "bg-og-status-waiting"
1114
+ : props.phase === "elsewhere" || props.phase === "unavailable"
1115
+ ? "bg-og-fg-subtle"
1116
+ : "bg-og-accent",
1117
+ )}
1118
+ />
1119
+ </span>
1120
+ );
1121
+ }
1122
+
1123
+ function voiceButtonTone(phase: RealtimeVisualPhase): string {
1124
+ if (phase === "listening" || phase === "speaking") {
1125
+ return "border-og-accent/45 bg-og-accent text-og-accent-fg shadow-og-sm hover:bg-og-accent-strong";
1126
+ }
1127
+ if (phase === "blocked" || phase === "error") {
1128
+ return "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15";
1129
+ }
1130
+ if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") {
1131
+ return "border-og-accent/35 bg-og-accent-soft text-og-accent";
1132
+ }
1133
+ return "border-og-border bg-og-surface-2 text-og-fg-muted hover:border-og-accent/35 hover:bg-og-accent-soft hover:text-og-accent";
1134
+ }
1135
+
1136
+ function toRealtimeModelOption(model: WorkspaceRealtimeModelCatalogItem): RealtimeModelOption {
1137
+ return { ...model };
1138
+ }
1139
+
1140
+ function readRealtimeModelPreference(workspaceId: string): SessionRealtimeModel | null {
1141
+ if (typeof localStorage === "undefined") return null;
1142
+ try {
1143
+ const value = localStorage.getItem(`${REALTIME_MODEL_STORAGE_PREFIX}:${workspaceId}`);
1144
+ return isRealtimeModel(value) ? value : null;
1145
+ } catch {
1146
+ return null;
1147
+ }
1148
+ }
1149
+
1150
+ function writeRealtimeModelPreference(workspaceId: string, model: SessionRealtimeModel): void {
1151
+ if (typeof localStorage === "undefined") return;
1152
+ try {
1153
+ localStorage.setItem(`${REALTIME_MODEL_STORAGE_PREFIX}:${workspaceId}`, model);
1154
+ } catch {
1155
+ // Voice selection still works when browser storage is unavailable.
1156
+ }
1157
+ }
1158
+
1159
+ function isRealtimeModel(value: string | null): value is SessionRealtimeModel {
1160
+ return (
1161
+ value === "gpt-live-1-boulder-alpha" ||
1162
+ value === "opengeni-gateway/openai/gpt-realtime-2.1" ||
1163
+ value === "opengeni-gateway/openai/gpt-realtime-mini" ||
1164
+ value === "opengeni-gateway/xai/grok-voice-think-fast-2.0" ||
1165
+ value === "workspace-gateway/openai/gpt-realtime-2.1" ||
1166
+ value === "workspace-gateway/openai/gpt-realtime-mini" ||
1167
+ value === "workspace-gateway/xai/grok-voice-think-fast-2.0"
1168
+ );
1169
+ }
1170
+
1171
+ /** @deprecated Use the provider-neutral realtime names. */
1172
+ export const useSessionCodexRealtime = useSessionRealtime;
1173
+ /** @deprecated Use the provider-neutral realtime names. */
1174
+ export const useWorkspaceRealtimeModelSelection = useRealtimeModelSelection;
1175
+ /** @deprecated Use the provider-neutral realtime names. */
1176
+ export const SessionCodexRealtimeControl = SessionRealtimeControl;
1177
+ /** @deprecated Use the provider-neutral realtime names. */
1178
+ export const CodexRealtimeControl = RealtimeVoiceControl;