import { COMPOSER_VOICE_INPUT_START_EVENT, composerVoiceEventScope, } from "../composer-voice-events"; import { type EffectiveSessionControl, type SessionEvent, type SessionStatus } from "@opengeni/sdk"; import { projectSessionRealtimeLifecycle, type SessionRealtimeControllerSnapshot, type RealtimeVoiceSettings, type SessionRealtimeModel, } from "@opengeni/sdk/realtime"; import { AudioLinesIcon, CheckIcon, ChevronDownIcon, CircleAlertIcon, LanguagesIcon, LoaderCircleIcon, MicIcon, MicOffIcon, RotateCcwIcon, SquareIcon, Volume2Icon, VolumeXIcon, XIcon, } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type ReactNode, type RefObject, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { BillingClassMark, PickerAnimatedPage, PickerBackHeader, PickerNavRow, } from "../components/model-policy-picker"; import { cn } from "../lib/cn"; import { useComposerFooterSignal } from "../lib/composer-footer-signals"; import type { PickerBillingClass as BillingClass } from "../model-policy"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./dropdown-menu"; import { MENU_CHECK_CLASS } from "../lib/menu-styles"; import { CODEX_LIVE_MODEL, realtimeAppLanguage, type RealtimeControllerClient, type RealtimeModelOption, type SessionRealtimeControllerFactory, sessionRealtimeLifecycleIsActive, useRealtimeLanguagePreference, useRealtimeModelSelection, useSessionRealtime, } from "./session-realtime"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; export { codexRealtimeAdmissionAllowed, codexRealtimeAdmissionBlocker, CODEX_LIVE_MODEL, prestartSessionRealtime, realtimeAppLanguage, realtimeConversationLanguage, sessionRealtimeLifecycleIsActive, useRealtimeLanguagePreference, useRealtimeModelSelection, useSessionRealtime, } from "./session-realtime"; export type { RealtimeModelOption, SessionRealtimeControllerFactory } from "./session-realtime"; /** Short status label for a credit/availability refusal (shared with the model menu). */ function realtimeRefusalLabel(t: OpenGeniText, code: string | null | undefined): string | null { switch (code) { case "insufficient_credits": return t("realtime.realtimeControl.creditsNeeded"); case "allowance_exhausted": case "monthly_model_cost_limit": return t("realtime.realtimeControl.usageLimitReached"); case "realtime_voice_unavailable": return t("realtime.realtimeControl.voiceUnavailable"); case "credits_disabled": return t("realtime.realtimeControl.creditsOff"); default: return null; } } // Wire identifiers of the realtime catalog provider field; display names come from REALTIME_PROVIDER_META. const REALTIME_MODEL_PROVIDERS = [ "OpenGeni", "Connected Codex", "Connected SuperGrok", "Your Gateway", "Your ElevenLabs", ] as const; type RealtimeModelProvider = (typeof REALTIME_MODEL_PROVIDERS)[number]; const REALTIME_PROVIDER_META = ( t: OpenGeniText, ): Record< RealtimeModelProvider, // `key` is unique per provider; several providers can share a billing class. { key: string; billingClass: BillingClass; label: string; hint: string } > => ({ // The wire value keeps its historical spelling; the product name is Opengeni. OpenGeni: { key: "opengeni", billingClass: "opengeni_credits", label: t("realtime.realtimeControl.opengeni"), hint: t("realtime.realtimeControl.usesOpengeniCredits"), }, "Connected Codex": { key: "codex", billingClass: "codex_subscription", label: t("realtime.realtimeControl.connectedCodex"), hint: t("realtime.realtimeControl.chatgptCodexPlan"), }, "Connected SuperGrok": { key: "supergrok", billingClass: "supergrok_subscription", label: t("realtime.realtimeControl.connectedSupergrok"), hint: t("realtime.realtimeControl.supergrokXaiPlan"), }, "Your Gateway": { key: "gateway", billingClass: "byok", label: t("realtime.realtimeControl.yourGateway"), hint: t("realtime.realtimeControl.billedToYourAiGateway"), }, "Your ElevenLabs": { key: "elevenlabs", billingClass: "byok", label: t("realtime.realtimeControl.yourElevenlabs"), hint: t("realtime.realtimeControl.billedToYourElevenlabs"), }, }); /** Display name; the catalog's `provider` value stays the wire identifier. */ function realtimeProviderLabel(t: OpenGeniText, provider: RealtimeModelProvider): string { return REALTIME_PROVIDER_META(t)[provider].label; } export function SessionRealtimeControl(props: { client?: RealtimeControllerClient | undefined; workspaceId?: string | undefined; sessionId: string; sessionStatus: SessionStatus; effectiveControl: EffectiveSessionControl; events: SessionEvent[]; eventsReady: boolean; codexConnected: boolean; /** Model-visible application context captured with each durable realtime message. */ getModelContext?: (() => string | undefined) | undefined; realtimeAutostartModel?: SessionRealtimeModel | undefined; onRealtimeAutostartConsumed?: (() => void) | undefined; /** Host hides dictate (and can choreograph layout) while realtime voice is live. */ onVoiceActiveChange?: ((active: boolean) => void) | undefined; /** * `split` attaches the model chevron at every breakpoint (public default). * `split-desktop` hides it below `sm` when the host owns mobile selection. * Inside a container-responsive Composer.Root, `sm` follows that composer. * `none` never attaches a model menu to the bar button. */ modelMenu?: "split" | "split-desktop" | "none" | undefined; /** Explicitly expose low-level controller diagnostics; disabled by default. */ showDiagnostics?: boolean | undefined; /** * Voice settings for this session's voice (voice, language, turn eagerness), * over the workspace's saved settings for the selected model. A language the * person picks in the voice menu takes precedence. Settings the selected * model does not offer are ignored. */ voiceSettings?: RealtimeVoiceSettings | undefined; /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */ controllerFactory?: SessionRealtimeControllerFactory | undefined; }) { const lifecycle = useMemo(() => projectSessionRealtimeLifecycle(props.events), [props.events]); const lifecycleActive = sessionRealtimeLifecycleIsActive(lifecycle); const selection = useRealtimeModelSelection({ client: props.client, workspaceId: props.workspaceId, codexConnected: props.codexConnected, activeModel: lifecycleActive && lifecycle?.state === "active" ? lifecycle.model : null, }); const selectedModel = selection.selectedModel; const realtime = useSessionRealtime({ ...props, model: selectedModel.id, modelAvailable: selectedModel.available, modelUnavailableReason: selectedModel.unavailableReason, language: selection.languageFor(selectedModel.id), voiceSettings: selection.voiceSettingsFor(selectedModel.id, props.voiceSettings), }); const autostartModelRef = useRef(props.realtimeAutostartModel ?? null); const autostartStartedRef = useRef(false); const { models, selectedModel: selectedRealtimeModel, selectModel } = selection; const { canStart, start, prestarted } = realtime; const onRealtimeAutostartConsumed = props.onRealtimeAutostartConsumed; const onVoiceActiveChange = props.onVoiceActiveChange; const voiceActive = realtime.snapshot.mode?.state === "active"; useEffect(() => { onVoiceActiveChange?.(voiceActive); }, [onVoiceActiveChange, voiceActive]); useEffect(() => () => onVoiceActiveChange?.(false), [onVoiceActiveChange]); useEffect(() => { const pending = autostartModelRef.current; if (!pending || autostartStartedRef.current) return; const consume = () => { autostartModelRef.current = null; onRealtimeAutostartConsumed?.(); }; if (prestarted && selectedRealtimeModel.id === pending) { // The host started this call before the view mounted; it is the autostart. autostartStartedRef.current = true; consume(); return; } if (lifecycleActive) return; const available = models.some((model) => model.id === pending && model.available); if (!available) return; if (selectedRealtimeModel.id !== pending) { selectModel(pending); return; } if (!canStart) return; autostartStartedRef.current = true; // One automatic attempt. A failed start stays visible with its reason and // the user retries explicitly; re-arming would loop on a definitive refusal. void start().then(consume, consume); }, [ canStart, lifecycleActive, models, prestarted, props.sessionId, props.workspaceId, onRealtimeAutostartConsumed, selectModel, selectedRealtimeModel.id, start, ]); return ( ); } const IDLE_REALTIME_SNAPSHOT: SessionRealtimeControllerSnapshot = { status: "idle", realtimeId: null, mode: null, bridge: null, microphone: "inactive", inputMuted: false, audibleOutput: "inactive", outputMuted: false, connectionGeneration: 0, reconnectAttempt: 0, diagnostic: null, error: null, }; type NewSessionRealtimeControlProps = { client?: RealtimeControllerClient | undefined; workspaceId?: string | undefined; codexConnected: boolean; disabled?: boolean | undefined; disabledReason?: string | null | undefined; onStart: (model: SessionRealtimeModel) => Promise; /** Parent-owned selection (e.g. shared with the mobile “+” voice-model panel). */ models?: readonly RealtimeModelOption[] | undefined; selectedModel?: RealtimeModelOption | undefined; onSelectModel?: ((modelId: string) => void) | undefined; /** * `split` attaches the model chevron at every breakpoint (public default). * `split-desktop` hides it below `sm` when the host owns mobile selection (e.g. “+”). * Inside a container-responsive Composer.Root, `sm` follows that composer. * `none` never attaches a model menu to the bar button. */ modelMenu?: "split" | "split-desktop" | "none" | undefined; }; type NewSessionRealtimeSelection = { models: readonly RealtimeModelOption[]; selectedModel: RealtimeModelOption; selectModel: (modelId: string) => void; }; export function NewSessionRealtimeControl(props: NewSessionRealtimeControlProps) { if (props.models && props.selectedModel && props.onSelectModel) { return ( ); } return ; } function NewSessionRealtimeControlWithInternalSelection(props: NewSessionRealtimeControlProps) { const selection = useRealtimeModelSelection({ client: props.client, workspaceId: props.workspaceId, codexConnected: props.codexConnected, }); return ; } function NewSessionRealtimeControlView( props: NewSessionRealtimeControlProps & { selection: NewSessionRealtimeSelection }, ) { const t = useOpenGeniText(); const selection = props.selection; const audioRef = useRef(null); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); const snapshot = useMemo( () => starting ? { ...IDLE_REALTIME_SNAPSHOT, status: "starting" } : error ? { ...IDLE_REALTIME_SNAPSHOT, status: "error", error } : IDLE_REALTIME_SNAPSHOT, [error, starting], ); const canStart = selection.selectedModel.available && props.disabled !== true && !starting; const onStart = props.onStart; const selectedModelId = selection.selectedModel.id; const start = useCallback(async () => { if (!canStart) return; setStarting(true); setError(null); try { const started = await onStart(selectedModelId); if (!started) setError(t("realtime.realtimeControl.theVoiceSessionCouldNot")); } catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); } finally { setStarting(false); } }, [canStart, onStart, selectedModelId, t]); return ( undefined} onRetry={start} onRetryAudibleOutput={async () => undefined} onSetInputMuted={() => undefined} onSetOutputMuted={() => undefined} /> ); } export function RealtimeVoiceControl(props: { snapshot: SessionRealtimeControllerSnapshot; canStart: boolean; admissionBlocker?: string | null | undefined; modelAvailable: boolean; selectionDisabled?: boolean | undefined; /** Prefer `bottom` on home/new-session; `top` for the docked session composer. */ menuSide?: "top" | "bottom" | undefined; /** * `split` = start + model chevron at every breakpoint (public SDK default). * `split-desktop` = chevron from `sm` up; below that the host owns selection. * Inside a container-responsive Composer.Root, `sm` follows that composer. * `none` = start button only. */ modelMenu?: "split" | "split-desktop" | "none" | undefined; showDiagnostics?: boolean | undefined; /** Extra classes on the in-bar mute cluster (e.g. `max-sm:hidden` when mutes live in “+”). */ muteControlsClassName?: string | undefined; audioRef: RefObject; selectedModel?: RealtimeModelOption | undefined; models?: readonly RealtimeModelOption[] | undefined; onSelectModel?: ((modelId: string) => void) | undefined; onStart: () => Promise; onStop: () => Promise; onRetry: () => Promise; onRetryAudibleOutput: () => Promise; onSetInputMuted: (muted: boolean) => void; onSetOutputMuted: (muted: boolean) => void; }) { const t = useOpenGeniText(); const reduceMotion = useReducedMotion(); const selectedModel = props.selectedModel ?? { ...CODEX_LIVE_MODEL(t), available: props.modelAvailable, unavailableReason: props.modelAvailable ? null : CODEX_LIVE_MODEL(t).unavailableReason, }; const models = props.models ?? [selectedModel]; const status = statusContent( t, props.snapshot, props.modelAvailable, props.canStart, props.admissionBlocker ?? null, selectedModel.unavailableCode ?? null, ); const modeOwned = props.snapshot.mode?.state === "active"; const retryConnection = modeOwned && props.snapshot.status !== "stopping" && props.snapshot.status !== "active" && props.snapshot.diagnostic?.recoverable === true; const audioBlocked = props.snapshot.audibleOutput === "blocked" && !props.snapshot.outputMuted; // A call that ended in a failure keeps its reason on screen, not only in a // tooltip or the model menu, until the user starts again or changes model. const startFailed = props.snapshot.status === "error" && !modeOwned; const failureText = status.label === t("realtime.realtimeControl.voiceUnavailable") ? status.detail : t("realtime.realtimeControl.labelDetail", { label: status.label, detail: status.detail }); const mainDisabled = props.snapshot.status === "stopping" || props.snapshot.status === "lost_owner" || (!modeOwned && !props.canStart) || (props.snapshot.status === "starting" && !modeOwned); const mainLabel = audioBlocked ? t("realtime.realtimeControl.resumeVoiceAudio") : retryConnection ? t("realtime.realtimeControl.retryVoiceConnection") : modeOwned ? t("realtime.realtimeControl.endVoiceConversation") : !props.canStart && status.phase === "unavailable" ? `${status.label}: ${status.detail}` : startFailed ? t("realtime.realtimeControl.tryVoiceAgain") : t("realtime.realtimeControl.startVoice"); const runMainAction = audioBlocked ? props.onRetryAudibleOutput : retryConnection ? props.onRetry : modeOwned ? props.onStop : props.onStart; const diagnosticsVisible = props.showDiagnostics ?? false; const modelMenu = props.modelMenu ?? "split"; const showAttachedModelMenu = modelMenu === "split" || modelMenu === "split-desktop"; const desktopOnlyModelMenu = modelMenu === "split-desktop"; const [pickerOpen, setPickerOpen] = useState(false); const [pickerProvider, setPickerProvider] = useState( selectedModel.provider, ); const [pickerDirection, setPickerDirection] = useState<1 | -1>(1); const [failureDismissed, setFailureDismissed] = useState(false); useEffect(() => { if (!startFailed) setFailureDismissed(false); }, [startFailed]); useComposerFooterSignal("realtime-attention", startFailed && !failureDismissed); const rootRef = useRef(null); useEffect(() => { if (!startFailed) return; // Starting dictation in this composer supersedes a stale start failure. const scope = composerVoiceEventScope(rootRef.current); if (!scope) return; const dismiss = () => setFailureDismissed(true); scope.addEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss); return () => scope.removeEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss); }, [startFailed]); useEffect(() => { if (!pickerOpen) setPickerProvider(selectedModel.provider); }, [pickerOpen, selectedModel.provider]); return (
); } /** Drill-in body for the mobile composer “+” menu — same catalog as the desktop chevron. */ export function RealtimeVoiceModelPanel(props: { models: readonly RealtimeModelOption[]; selectedModel: RealtimeModelOption; disabled?: boolean | undefined; leading?: ReactNode | undefined; onSelect: (modelId: SessionRealtimeModel) => void; }) { const t = useOpenGeniText(); const [provider, setProvider] = useState(null); const [direction, setDirection] = useState<1 | -1>(1); return (
{props.leading}
{t("realtime.realtimeControl.voiceModel")}

{t("realtime.realtimeControl.usedWhenYouStartA")}

{ setDirection(nextDirection); setProvider(next); }} onSelect={props.onSelect} />
); } function RealtimeMuteButton(props: { muted: boolean; kind: "microphone" | "output"; reduceMotion: boolean; enterDelay?: number | undefined; onToggle(): void; }) { const t = useOpenGeniText(); const microphone = props.kind === "microphone"; const label = microphone ? props.muted ? t("realtime.realtimeControl.unmuteMicrophone") : t("realtime.realtimeControl.muteMicrophone") : props.muted ? t("realtime.realtimeControl.unmuteVoiceAudio") : t("realtime.realtimeControl.muteVoiceAudio"); const Icon = microphone ? props.muted ? MicOffIcon : MicIcon : props.muted ? VolumeXIcon : Volume2Icon; return ( ); } export function RealtimeModelPickerMenu(props: { models: readonly RealtimeModelOption[]; selectedModel: RealtimeModelOption; provider: RealtimeModelProvider | null; direction: 1 | -1; disabled: boolean; onProviderChange: (provider: RealtimeModelProvider | null, direction: 1 | -1) => void; onSelect: (modelId: SessionRealtimeModel) => void; }) { const t = useOpenGeniText(); const { language: languagePreference, setLanguage } = useRealtimeLanguagePreference(); // The language page is local to the menu; leaving a provider closes it. const [languagePage, setLanguagePage] = useState<{ provider: RealtimeModelProvider; open: boolean; } | null>(null); const changeProvider = (provider: RealtimeModelProvider | null, direction: 1 | -1) => { setLanguagePage(null); props.onProviderChange(provider, direction); }; const providers = REALTIME_MODEL_PROVIDERS.filter((provider) => props.models.some((model) => model.provider === provider), ); const providerLanguages = props.provider ? props.models.find((model) => model.provider === props.provider && model.languages?.length) ?.languages : undefined; const languagePageOpen = providerLanguages !== undefined && languagePage?.provider === props.provider && languagePage.open; const direction = languagePage?.provider === props.provider ? (languagePage.open ? 1 : -1) : props.direction; const pageKey = languagePageOpen ? `voice-languages:${props.provider}` : props.provider ? `voice-models:${props.provider}` : "voice-providers"; const appLanguage = realtimeAppLanguage(t.locale); const appLanguageName = appLanguage ? languageDisplayName(baseLanguage(appLanguage), t.locale) : null; // Without a pick of their own, the person talks in the workspace's language // for this provider's voice, else their app language. const workspaceLanguage = props.provider ? props.models.find( (model) => model.provider === props.provider && model.workspaceVoiceSettings?.language, )?.workspaceVoiceSettings?.language : undefined; const workspaceLanguageName = workspaceLanguage ? languageDisplayName(baseLanguage(workspaceLanguage), t.locale) : null; const automaticLanguageName = workspaceLanguageName ?? appLanguageName; const body = languagePageOpen && props.provider ? (
} onBack={() => setLanguagePage({ provider: props.provider!, open: false })} />

{t("realtime.realtimeControl.voiceLanguageDescription")}

) : undefined } testId="realtime-language-choice-automatic" onClick={() => setLanguage(null)} /> {sortedLanguages(providerLanguages ?? [], t.locale).map(({ code, name, nativeName }) => { const selected = languagePreference === code; return ( : undefined } testId={`realtime-language-choice-${code}`} onClick={() => setLanguage(code)} /> ); })}
) : props.provider ? (
} onBack={() => changeProvider(null, -1)} />
{props.models .filter((model) => model.provider === props.provider) .map((model) => { const selected = model.id === props.selectedModel.id; return ( : undefined } testId={`realtime-model-choice-${model.id}`} onClick={() => props.onSelect(model.id)} /> ); })}
{providerLanguages ? (
} disabled={props.disabled} testId="realtime-language-row" onClick={() => setLanguagePage({ provider: props.provider!, open: true })} />
) : null}
) : (
{providers.map((provider) => { const meta = REALTIME_PROVIDER_META(t)[provider]; return ( } active={props.selectedModel.provider === provider} testId={`realtime-model-provider-${meta.key}`} onClick={() => changeProvider(provider, 1)} /> ); })}
); return (
{body}
); } function baseLanguage(tag: string): string { return tag.split(/[-_]/)[0] ?? tag; } function languageDisplayName(code: string, locale: string | undefined): string { try { return ( new Intl.DisplayNames(locale ? [locale] : undefined, { type: "language" }).of(code) ?? code ); } catch { return code; } } function sortedLanguages( codes: readonly string[], locale: string | undefined, ): { code: string; name: string; nativeName: string }[] { return codes .map((code) => ({ code, name: languageDisplayName(code, locale), nativeName: languageDisplayName(code, code), })) .sort((left, right) => left.name.localeCompare(right.name, locale)); } function RealtimeDiagnosticsMenu({ snapshot }: { snapshot: SessionRealtimeControllerSnapshot }) { const t = useOpenGeniText(); const bridge = snapshot.bridge; const rows = [ ["controller", snapshot.status], ["microphone", snapshot.microphone], [t("realtime.realtimeControl.microphoneMuted"), String(snapshot.inputMuted)], ["audio", snapshot.audibleOutput], [t("realtime.realtimeControl.audioMuted"), String(snapshot.outputMuted)], ["generation", String(snapshot.connectionGeneration)], [t("realtime.realtimeControl.reconnectAttempt"), String(snapshot.reconnectAttempt)], ["mode", snapshot.mode?.state ?? "none"], [ t("realtime.realtimeControl.modeVersion"), snapshot.mode ? String(snapshot.mode.version) : "—", ], [ t("realtime.realtimeControl.connectionEpoch"), snapshot.mode ? String(snapshot.mode.connectionEpoch) : "—", ], [ t("realtime.realtimeControl.providerStarted"), bridge ? String(bridge.providerStarted) : "false", ], [t("realtime.realtimeControl.providerSpeaking"), bridge ? String(bridge.speaking) : "false"], ["delegation", bridge?.activeDelegationId ? "active" : "none"], [ t("realtime.realtimeControl.pendingDurableEvents"), bridge ? String(bridge.pendingInbound) : "0", ], [t("realtime.realtimeControl.pendingBytes"), bridge ? String(bridge.pendingInboundBytes) : "0"], [t("realtime.realtimeControl.clientAck"), bridge?.clientAckThroughSequence?.toString() ?? "—"], [ t("realtime.realtimeControl.ignoredProviderEvents"), bridge ? String(bridge.ignoredEventCount) : "0", ], [t("realtime.realtimeControl.lastIgnoredEvent"), bridge?.lastIgnoredEventType ?? "none"], [t("realtime.realtimeControl.bridgeError"), bridge?.lastError ?? "none"], ["diagnostic", snapshot.diagnostic?.kind ?? "none"], ] as const; return ( <> {t("realtime.realtimeControl.realtimeDiagnostics")} {rows.map(([label, value]) => ( {label} {value} ))} ); } type RealtimeVisualPhase = | "idle" | "connecting" | "listening" | "speaking" | "reconnecting" | "blocked" | "unavailable" | "error" | "elsewhere" | "stopping"; function statusContent( t: OpenGeniText, snapshot: SessionRealtimeControllerSnapshot, modelAvailable: boolean, canStart: boolean, admissionBlocker: string | null, unavailableCode: string | null = null, ): { phase: RealtimeVisualPhase; label: string; detail: string } { if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) { return { phase: "blocked", label: t("realtime.realtimeControl.audioOutputBlocked"), detail: t("realtime.realtimeControl.resumeAudioToHearThe"), }; } switch (snapshot.status) { case "starting": return { phase: "connecting", label: t("realtime.realtimeControl.connecting"), detail: t("realtime.realtimeControl.preparingYourMicrophoneAndLive"), }; case "active": return snapshot.bridge?.speaking ? { phase: "speaking", label: t("realtime.realtimeControl.speaking"), detail: t("realtime.realtimeControl.interruptNaturallyWheneverYouWant"), } : { phase: "listening", label: t("realtime.realtimeControl.listening"), detail: t("realtime.realtimeControl.voiceAndTypedMessagesShare"), }; case "stopping": return { phase: "stopping", label: t("realtime.realtimeControl.endingVoice"), detail: t("realtime.realtimeControl.savingTheRemainingConversationContext"), }; case "recovering": return { phase: "reconnecting", label: t("realtime.realtimeControl.reconnecting"), detail: snapshot.error ?? t("realtime.realtimeControl.keepingThisVoiceConversationConnected"), }; case "lost_owner": return { phase: "elsewhere", label: t("realtime.realtimeControl.voiceActiveElsewhere"), detail: t("realtime.realtimeControl.returnToTheBrowserThat"), }; case "error": if (snapshot.refusal) { return { phase: "unavailable", label: realtimeRefusalLabel(t, snapshot.refusal.code) ?? t("realtime.realtimeControl.voiceUnavailable"), detail: snapshot.refusal.message, }; } return { phase: "error", label: t("realtime.realtimeControl.voiceUnavailable"), detail: snapshot.error ?? t("realtime.realtimeControl.theConnectionCouldNotBe"), }; case "idle": if (!modelAvailable) { return { phase: "unavailable", label: realtimeRefusalLabel(t, unavailableCode) ?? t("realtime.realtimeControl.voiceModelUnavailable"), detail: admissionBlocker ?? t("realtime.realtimeControl.chooseAnotherVoiceModel"), }; } if (!canStart && admissionBlocker) { return { phase: "unavailable", label: t("realtime.realtimeControl.voiceUnavailable"), detail: admissionBlocker, }; } return { phase: "idle", label: t("realtime.realtimeControl.startVoice"), detail: t("realtime.realtimeControl.talkLiveWithTheAgent"), }; } } function RealtimeComposerGlow(props: { phase: RealtimeVisualPhase; reduceMotion: boolean }) { const visible = [ "connecting", "listening", "speaking", "reconnecting", "blocked", "error", "stopping", ].includes(props.phase); return ( {visible ? ( ) : null} ); } function RealtimeActionGlyph(props: { phase: RealtimeVisualPhase; audioBlocked: boolean; reduceMotion: boolean; }) { if (["connecting", "reconnecting", "stopping"].includes(props.phase)) { return ; } if (props.phase === "blocked") { return props.audioBlocked ? ( ) : ( ); } if (props.phase === "error" || props.phase === "elsewhere" || props.phase === "unavailable") { return ; } if (props.phase === "listening" || props.phase === "speaking") { return ( {!props.reduceMotion ? ( ) : null} ); } return ; } function RealtimeStatusDot(props: { phase: RealtimeVisualPhase; reduceMotion: boolean }) { const live = props.phase === "listening" || props.phase === "speaking"; return ( {live && !props.reduceMotion ? ( ) : null} ); } function voiceButtonTone(phase: RealtimeVisualPhase): string { if (phase === "listening" || phase === "speaking") { return "border border-og-primary-border bg-og-primary text-og-primary-fg hover:bg-og-primary-hover"; } if (phase === "blocked" || phase === "error") { return "border border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15"; } if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") { return "border border-og-accent/35 bg-og-accent-soft text-og-accent"; } // Idle: same ghost treatment as the composer transcription mic — no box. return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg"; } function voiceChevronTone(phase: RealtimeVisualPhase): string { if (phase === "listening" || phase === "speaking") { return "border border-l-0 border-og-primary-border bg-og-primary text-og-primary-fg hover:bg-og-primary-hover"; } if (phase === "blocked" || phase === "error") { return "border border-l-0 border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15"; } if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") { return "border border-l-0 border-og-accent/35 bg-og-accent-soft text-og-accent"; } return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg"; } /** @deprecated Use the provider-neutral realtime names. */ export const useSessionCodexRealtime = useSessionRealtime; /** @deprecated Use the provider-neutral realtime names. */ export const useWorkspaceRealtimeModelSelection = useRealtimeModelSelection; /** @deprecated Use the provider-neutral realtime names. */ export const SessionCodexRealtimeControl = SessionRealtimeControl; /** @deprecated Use the provider-neutral realtime names. */ export const CodexRealtimeControl = RealtimeVoiceControl;