@opengeni/react 0.42.1 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
  2. package/dist/chunk-23EJ676W.js.map +1 -0
  3. package/dist/{chunk-UWYTCQWW.js → chunk-KC27K42G.js} +92 -21
  4. package/dist/{chunk-UWYTCQWW.js.map → chunk-KC27K42G.js.map} +1 -1
  5. package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
  6. package/dist/chunk-KG5F2OKE.js.map +1 -0
  7. package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
  8. package/dist/chunk-LWR4MXSS.js.map +1 -0
  9. package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
  10. package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
  11. package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
  12. package/dist/components/chat-composer.d.ts +5 -1
  13. package/dist/components/composer-transcription-control.d.ts +3 -1
  14. package/dist/components/composer.d.ts +6 -4
  15. package/dist/components/session-chrome.d.ts +1 -1
  16. package/dist/composer.js +3 -3
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +69 -20
  19. package/dist/index.js.map +1 -1
  20. package/dist/model-policy.d.ts +2 -0
  21. package/dist/model-policy.js +1 -1
  22. package/dist/realtime/realtime-control.d.ts +29 -1
  23. package/dist/realtime.d.ts +1 -1
  24. package/dist/realtime.js +269 -151
  25. package/dist/realtime.js.map +1 -1
  26. package/dist/session-ui.js +2 -2
  27. package/dist/session.js +2 -2
  28. package/dist/timeline/index.d.ts +1 -1
  29. package/dist/timeline/parsers.d.ts +13 -8
  30. package/package.json +2 -2
  31. package/src/components/chat-composer.tsx +58 -19
  32. package/src/components/composer-transcription-control.tsx +194 -165
  33. package/src/components/composer.tsx +78 -14
  34. package/src/components/model-policy-picker.tsx +7 -2
  35. package/src/components/session-chrome.tsx +89 -78
  36. package/src/index.ts +2 -0
  37. package/src/model-policy.ts +4 -0
  38. package/src/realtime/realtime-control.tsx +307 -137
  39. package/src/realtime.ts +1 -0
  40. package/src/timeline/index.ts +2 -0
  41. package/src/timeline/parsers.ts +201 -19
  42. package/src/timeline/projection.ts +11 -0
  43. package/src/timeline/tool-renderers.tsx +7 -5
  44. package/src/timeline/turn-summary.tsx +7 -2
  45. package/styles/tokens.css +8 -5
  46. package/dist/chunk-4IJCL7YO.js.map +0 -1
  47. package/dist/chunk-KR2SK5GJ.js.map +0 -1
  48. package/dist/chunk-SJKT4TKW.js.map +0 -1
  49. /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
@@ -3,6 +3,8 @@ export type PickerBillingClass = "opengeni_credits" | "codex_subscription" | "by
3
3
  export type PickerModelRow<TCatalog extends ClientModel = WorkspaceModelCatalogModel> = {
4
4
  id: string;
5
5
  label: string;
6
+ /** Catalog-curated compact label for dense UI; fall back to `label` when absent. */
7
+ shortLabel?: string | undefined;
6
8
  billingClass: PickerBillingClass;
7
9
  billingClassLabel: string;
8
10
  selectable: boolean;
@@ -15,7 +15,7 @@ import {
15
15
  projectPickerRows,
16
16
  runnableLatencyModesForModel,
17
17
  sortPickerRows
18
- } from "./chunk-SJKT4TKW.js";
18
+ } from "./chunk-23EJ676W.js";
19
19
  export {
20
20
  advancedSourceSummary,
21
21
  availabilityReasonLabel,
@@ -1,6 +1,6 @@
1
1
  import { type EffectiveSessionControl, type SessionEvent, type SessionStatus } from "@opengeni/sdk";
2
2
  import { type SessionRealtimeClientLike, type SessionRealtimeController, type SessionRealtimeControllerSnapshot, type CreateSessionRealtimeControllerOptions, type SessionRealtimeModel } from "@opengeni/sdk/realtime";
3
- import { type RefObject } from "react";
3
+ import { type ReactNode, type RefObject } from "react";
4
4
  import type { EmbeddedRealtimeSessionClientLike } from "../client";
5
5
  export type RealtimeModelOption = {
6
6
  id: SessionRealtimeModel;
@@ -65,6 +65,8 @@ export declare function SessionRealtimeControl(props: {
65
65
  codexConnected: boolean;
66
66
  realtimeAutostartModel?: SessionRealtimeModel | undefined;
67
67
  onRealtimeAutostartConsumed?: (() => void) | undefined;
68
+ /** Host hides dictate (and can choreograph layout) while realtime voice is live. */
69
+ onVoiceActiveChange?: ((active: boolean) => void) | undefined;
68
70
  /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */
69
71
  controllerFactory?: SessionRealtimeControllerFactory | undefined;
70
72
  }): import("react/jsx-runtime").JSX.Element;
@@ -85,6 +87,16 @@ export declare function NewSessionRealtimeControl(props: {
85
87
  disabled?: boolean | undefined;
86
88
  disabledReason?: string | null | undefined;
87
89
  onStart: (model: SessionRealtimeModel) => Promise<boolean>;
90
+ /** Parent-owned selection (e.g. shared with the mobile “+” voice-model panel). */
91
+ models?: readonly RealtimeModelOption[] | undefined;
92
+ selectedModel?: RealtimeModelOption | undefined;
93
+ onSelectModel?: ((modelId: string) => void) | undefined;
94
+ /**
95
+ * `split` attaches the model chevron at every breakpoint (public default).
96
+ * `split-desktop` hides it below `sm` when the host owns mobile selection (e.g. “+”).
97
+ * `none` never attaches a model menu to the bar button.
98
+ */
99
+ modelMenu?: "split" | "split-desktop" | "none" | undefined;
88
100
  }): import("react/jsx-runtime").JSX.Element;
89
101
  export declare function RealtimeVoiceControl(props: {
90
102
  snapshot: SessionRealtimeControllerSnapshot;
@@ -94,7 +106,15 @@ export declare function RealtimeVoiceControl(props: {
94
106
  selectionDisabled?: boolean | undefined;
95
107
  /** Prefer `bottom` on home/new-session; `top` for the docked session composer. */
96
108
  menuSide?: "top" | "bottom" | undefined;
109
+ /**
110
+ * `split` = start + model chevron at every breakpoint (public SDK default).
111
+ * `split-desktop` = chevron from `sm` up; below that the host owns selection.
112
+ * `none` = start button only.
113
+ */
114
+ modelMenu?: "split" | "split-desktop" | "none" | undefined;
97
115
  showDiagnostics?: boolean;
116
+ /** Extra classes on the in-bar mute cluster (e.g. `max-sm:hidden` when mutes live in “+”). */
117
+ muteControlsClassName?: string | undefined;
98
118
  audioRef: RefObject<HTMLAudioElement | null>;
99
119
  selectedModel?: RealtimeModelOption | undefined;
100
120
  models?: readonly RealtimeModelOption[] | undefined;
@@ -106,6 +126,14 @@ export declare function RealtimeVoiceControl(props: {
106
126
  onSetInputMuted: (muted: boolean) => void;
107
127
  onSetOutputMuted: (muted: boolean) => void;
108
128
  }): import("react/jsx-runtime").JSX.Element;
129
+ /** Drill-in body for the mobile composer “+” menu — same catalog as the desktop chevron. */
130
+ export declare function RealtimeVoiceModelPanel(props: {
131
+ models: readonly RealtimeModelOption[];
132
+ selectedModel: RealtimeModelOption;
133
+ disabled?: boolean | undefined;
134
+ leading?: ReactNode | undefined;
135
+ onSelect: (modelId: SessionRealtimeModel) => void;
136
+ }): import("react/jsx-runtime").JSX.Element;
109
137
  export declare function RealtimeModelPickerMenu(props: {
110
138
  models: readonly RealtimeModelOption[];
111
139
  selectedModel: RealtimeModelOption;
@@ -5,7 +5,7 @@
5
5
  * base `@opengeni/react` entry remains SSR/browser safe and does not eagerly
6
6
  * load microphone, WebRTC, Gateway, or realtime UI code.
7
7
  */
8
- export { CodexRealtimeControl, NewSessionRealtimeControl, RealtimeModelPickerMenu, RealtimeVoiceControl, SessionCodexRealtimeControl, SessionRealtimeControl, codexRealtimeAdmissionAllowed, codexRealtimeAdmissionBlocker, useRealtimeModelSelection, useSessionCodexRealtime, useSessionRealtime, useWorkspaceRealtimeModelSelection, } from "./realtime/realtime-control";
8
+ export { CodexRealtimeControl, NewSessionRealtimeControl, RealtimeModelPickerMenu, RealtimeVoiceControl, RealtimeVoiceModelPanel, SessionCodexRealtimeControl, SessionRealtimeControl, codexRealtimeAdmissionAllowed, codexRealtimeAdmissionBlocker, useRealtimeModelSelection, useSessionCodexRealtime, useSessionRealtime, useWorkspaceRealtimeModelSelection, } from "./realtime/realtime-control";
9
9
  export type { RealtimeModelOption, SessionRealtimeControllerFactory, } from "./realtime/realtime-control";
10
10
  export type { EmbeddedRealtimeSessionClientLike } from "./client";
11
11
  export type { EmbeddedRealtimeSessionClientOverride } from "./session-context";
package/dist/realtime.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  PickerAnimatedPage,
4
4
  PickerBackHeader,
5
5
  PickerNavRow
6
- } from "./chunk-WZT5G5OR.js";
6
+ } from "./chunk-U6K24XQD.js";
7
7
  import {
8
8
  useEmbeddedRealtimeSession
9
9
  } from "./chunk-EKZH6IDG.js";
10
- import "./chunk-SJKT4TKW.js";
10
+ import "./chunk-23EJ676W.js";
11
11
  import {
12
12
  cn
13
13
  } from "./chunk-TK7G6XLT.js";
@@ -31,7 +31,13 @@ import {
31
31
  VolumeXIcon
32
32
  } from "lucide-react";
33
33
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
34
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
34
+ import {
35
+ useCallback,
36
+ useEffect,
37
+ useMemo,
38
+ useRef,
39
+ useState
40
+ } from "react";
35
41
 
36
42
  // src/realtime/dropdown-menu.tsx
37
43
  import { ChevronRightIcon } from "lucide-react";
@@ -324,6 +330,11 @@ function SessionRealtimeControl(props) {
324
330
  const { models, selectedModel: selectedRealtimeModel, selectModel } = selection;
325
331
  const { canStart, start } = realtime;
326
332
  const onRealtimeAutostartConsumed = props.onRealtimeAutostartConsumed;
333
+ const onVoiceActiveChange = props.onVoiceActiveChange;
334
+ const voiceActive = realtime.snapshot.mode?.state === "active";
335
+ useEffect(() => {
336
+ onVoiceActiveChange?.(voiceActive);
337
+ }, [onVoiceActiveChange, voiceActive]);
327
338
  useEffect(() => {
328
339
  const pending = autostartModelRef.current;
329
340
  if (!pending || autostartStartedRef.current || lifecycle?.state === "active") return;
@@ -440,11 +451,16 @@ var IDLE_REALTIME_SNAPSHOT = {
440
451
  error: null
441
452
  };
442
453
  function NewSessionRealtimeControl(props) {
443
- const selection = useRealtimeModelSelection({
454
+ const internalSelection = useRealtimeModelSelection({
444
455
  client: props.client,
445
456
  workspaceId: props.workspaceId,
446
457
  codexConnected: props.codexConnected
447
458
  });
459
+ const selection = props.models && props.selectedModel && props.onSelectModel ? {
460
+ models: props.models,
461
+ selectedModel: props.selectedModel,
462
+ selectModel: props.onSelectModel
463
+ } : internalSelection;
448
464
  const audioRef = useRef(null);
449
465
  const [starting, setStarting] = useState(false);
450
466
  const [error, setError] = useState(null);
@@ -477,6 +493,7 @@ function NewSessionRealtimeControl(props) {
477
493
  modelAvailable: selection.selectedModel.available,
478
494
  selectionDisabled: starting,
479
495
  menuSide: "bottom",
496
+ modelMenu: props.modelMenu ?? "split",
480
497
  showDiagnostics: false,
481
498
  audioRef,
482
499
  selectedModel: selection.selectedModel,
@@ -513,6 +530,9 @@ function RealtimeVoiceControl(props) {
513
530
  const mainLabel = audioBlocked ? "Resume voice audio" : retryConnection ? "Retry voice connection" : modeOwned ? "End voice conversation" : `Start voice with ${selectedModel.label}`;
514
531
  const runMainAction = audioBlocked ? props.onRetryAudibleOutput : retryConnection ? props.onRetry : modeOwned ? props.onStop : props.onStart;
515
532
  const diagnosticsVisible = props.showDiagnostics ?? import.meta.env.DEV;
533
+ const modelMenu = props.modelMenu ?? "split";
534
+ const showAttachedModelMenu = modelMenu === "split" || modelMenu === "split-desktop";
535
+ const desktopOnlyModelMenu = modelMenu === "split-desktop";
516
536
  const [pickerOpen, setPickerOpen] = useState(false);
517
537
  const [pickerProvider, setPickerProvider] = useState(
518
538
  selectedModel.provider
@@ -524,169 +544,217 @@ function RealtimeVoiceControl(props) {
524
544
  return /* @__PURE__ */ jsxs2(
525
545
  "div",
526
546
  {
547
+ role: "group",
527
548
  "aria-label": "Realtime voice",
528
549
  "data-picker-side": props.menuSide ?? "top",
529
550
  className: "inline-flex shrink-0 items-center",
530
551
  children: [
531
552
  /* @__PURE__ */ jsx2("audio", { ref: props.audioRef, autoPlay: true, className: "hidden", "aria-hidden": "true" }),
532
553
  /* @__PURE__ */ jsx2(RealtimeComposerGlow, { phase: status.phase, reduceMotion: reduceMotion === true }),
533
- /* @__PURE__ */ jsx2(AnimatePresence, { initial: false, children: modeOwned ? /* @__PURE__ */ jsxs2(
554
+ /* @__PURE__ */ jsx2(AnimatePresence, { initial: false, children: modeOwned ? /* @__PURE__ */ jsx2(
534
555
  motion.div,
535
556
  {
536
557
  "data-testid": "realtime-mute-controls",
537
- initial: reduceMotion ? false : { opacity: 0, width: 0, marginRight: 0 },
538
- animate: { opacity: 1, width: "auto", marginRight: 4 },
539
- ...reduceMotion ? {} : { exit: { opacity: 0, width: 0, marginRight: 0 } },
540
- transition: { duration: reduceMotion ? 0 : 0.18, ease: [0.2, 0.8, 0.2, 1] },
541
- className: "inline-flex shrink-0 items-center gap-0.5 overflow-hidden",
542
- children: [
543
- /* @__PURE__ */ jsx2(
544
- RealtimeMuteButton,
545
- {
546
- muted: props.snapshot.inputMuted,
547
- kind: "microphone",
548
- reduceMotion: reduceMotion === true,
549
- onToggle: () => props.onSetInputMuted(!props.snapshot.inputMuted)
550
- }
551
- ),
552
- /* @__PURE__ */ jsx2(
553
- RealtimeMuteButton,
554
- {
555
- muted: props.snapshot.outputMuted,
556
- kind: "output",
557
- reduceMotion: reduceMotion === true,
558
- onToggle: () => props.onSetOutputMuted(!props.snapshot.outputMuted)
559
- }
560
- )
561
- ]
562
- },
563
- "realtime-mute-controls"
564
- ) : null }),
565
- /* @__PURE__ */ jsx2(
566
- motion.button,
567
- {
568
- type: "button",
569
- "data-testid": "realtime-primary-action",
570
- "data-phase": status.phase,
571
- "aria-label": mainLabel,
572
- "aria-pressed": modeOwned,
573
- title: mainLabel,
574
- disabled: mainDisabled,
575
- ...reduceMotion ? {} : { whileTap: { scale: 0.92 } },
576
- transition: { type: "spring", stiffness: 520, damping: 30 },
577
- onClick: () => void runMainAction().catch(() => void 0),
558
+ initial: reduceMotion ? false : { opacity: 0, width: 0 },
559
+ animate: {
560
+ opacity: 1,
561
+ width: "auto",
562
+ transition: reduceMotion ? { duration: 0 } : { delay: 0.3, duration: 0.48, ease: [0.22, 1, 0.36, 1] }
563
+ },
564
+ ...reduceMotion ? {} : {
565
+ exit: {
566
+ opacity: 0,
567
+ width: 0,
568
+ transition: { duration: 0.28, ease: [0.4, 0, 1, 1] }
569
+ }
570
+ },
578
571
  className: cn(
579
- "relative inline-flex size-8 items-center justify-center rounded-l-og-md border outline-none",
580
- "transition-[background-color,border-color,color,box-shadow] duration-200 ease-og-out",
581
- "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
582
- "disabled:cursor-not-allowed disabled:opacity-45 pointer-coarse:size-11",
583
- voiceButtonTone(status.phase)
572
+ "inline-flex shrink-0 items-center overflow-hidden",
573
+ props.muteControlsClassName
584
574
  ),
585
- children: /* @__PURE__ */ jsx2(
586
- RealtimeActionGlyph,
575
+ children: /* @__PURE__ */ jsxs2(
576
+ motion.div,
587
577
  {
588
- phase: status.phase,
589
- audioBlocked,
590
- reduceMotion: reduceMotion === true
578
+ className: "inline-flex items-center gap-0.5 pr-1",
579
+ initial: reduceMotion ? false : { x: 18, opacity: 0 },
580
+ animate: {
581
+ x: 0,
582
+ opacity: 1,
583
+ transition: reduceMotion ? { duration: 0 } : { delay: 0.32, type: "spring", stiffness: 320, damping: 30, mass: 0.8 }
584
+ },
585
+ ...reduceMotion ? {} : {
586
+ exit: {
587
+ x: 14,
588
+ opacity: 0,
589
+ transition: { duration: 0.22, ease: [0.4, 0, 1, 1] }
590
+ }
591
+ },
592
+ children: [
593
+ /* @__PURE__ */ jsx2(
594
+ RealtimeMuteButton,
595
+ {
596
+ muted: props.snapshot.inputMuted,
597
+ kind: "microphone",
598
+ reduceMotion: reduceMotion === true,
599
+ enterDelay: reduceMotion ? 0 : 0.34,
600
+ onToggle: () => props.onSetInputMuted(!props.snapshot.inputMuted)
601
+ }
602
+ ),
603
+ /* @__PURE__ */ jsx2(
604
+ RealtimeMuteButton,
605
+ {
606
+ muted: props.snapshot.outputMuted,
607
+ kind: "output",
608
+ reduceMotion: reduceMotion === true,
609
+ enterDelay: reduceMotion ? 0 : 0.4,
610
+ onToggle: () => props.onSetOutputMuted(!props.snapshot.outputMuted)
611
+ }
612
+ )
613
+ ]
591
614
  }
592
615
  )
593
- }
594
- ),
595
- /* @__PURE__ */ jsxs2(DropdownMenu, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
596
- /* @__PURE__ */ jsx2(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx2(
597
- "button",
616
+ },
617
+ "realtime-mute-controls"
618
+ ) : null }),
619
+ /* @__PURE__ */ jsxs2("div", { className: "inline-flex shrink-0 items-center", children: [
620
+ /* @__PURE__ */ jsx2(
621
+ motion.button,
598
622
  {
599
623
  type: "button",
600
- "aria-label": "Choose voice model and options",
601
- title: `Voice model: ${selectedModel.label}`,
602
- disabled: props.selectionDisabled,
624
+ "data-testid": "realtime-primary-action",
625
+ "data-phase": status.phase,
626
+ "aria-label": mainLabel,
627
+ "aria-pressed": modeOwned,
628
+ title: mainLabel,
629
+ disabled: mainDisabled,
630
+ ...reduceMotion ? {} : { whileTap: { scale: 0.92 } },
631
+ transition: { type: "spring", stiffness: 520, damping: 30 },
632
+ onClick: () => void runMainAction().catch(() => void 0),
603
633
  className: cn(
604
- "inline-flex h-8 w-6 items-center justify-center rounded-r-og-md border border-l-0 outline-none",
605
- "transition-[background-color,border-color,color] duration-200 ease-og-out",
634
+ // Match transcription mic: ghost icon when idle; filled only when live.
635
+ // Public contract: coarse pointers keep a 44px target; split menu uses
636
+ // left-only rounding at `sm+` where the chevron attaches.
637
+ "relative inline-flex size-8 items-center justify-center outline-none",
638
+ "rounded-og-md transition-[background-color,border-color,color,box-shadow] duration-200 ease-og-out",
606
639
  "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
607
- "pointer-coarse:h-11 pointer-coarse:w-7",
640
+ "disabled:cursor-not-allowed disabled:opacity-45 pointer-coarse:size-11",
641
+ showAttachedModelMenu && (desktopOnlyModelMenu ? "sm:rounded-l-og-md sm:rounded-r-none" : "rounded-l-og-md rounded-r-none"),
608
642
  voiceButtonTone(status.phase)
609
643
  ),
610
- children: /* @__PURE__ */ jsx2(ChevronDownIcon, { className: "size-3", "aria-hidden": true })
644
+ children: /* @__PURE__ */ jsx2(
645
+ RealtimeActionGlyph,
646
+ {
647
+ phase: status.phase,
648
+ audioBlocked,
649
+ reduceMotion: reduceMotion === true
650
+ }
651
+ )
611
652
  }
612
- ) }),
613
- /* @__PURE__ */ jsxs2(
614
- DropdownMenuContent,
615
- {
616
- side: props.menuSide ?? "top",
617
- align: "end",
618
- sideOffset: 8,
619
- collisionPadding: 12,
620
- 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",
621
- onCloseAutoFocus: (event) => event.preventDefault(),
622
- children: [
623
- /* @__PURE__ */ jsx2("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children: /* @__PURE__ */ jsx2(
624
- RealtimeModelPickerMenu,
625
- {
626
- models,
627
- selectedModel,
628
- provider: pickerProvider,
629
- direction: pickerDirection,
630
- disabled: props.selectionDisabled === true || props.snapshot.mode?.state === "active",
631
- onProviderChange: (provider, direction) => {
632
- setPickerDirection(direction);
633
- setPickerProvider(provider);
634
- },
635
- onSelect: (modelId) => {
636
- props.onSelectModel?.(modelId);
637
- setPickerOpen(false);
653
+ ),
654
+ showAttachedModelMenu ? /* @__PURE__ */ jsxs2(DropdownMenu, { open: pickerOpen, onOpenChange: setPickerOpen, children: [
655
+ /* @__PURE__ */ jsx2(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx2(
656
+ "button",
657
+ {
658
+ type: "button",
659
+ "aria-label": "Choose voice model and options",
660
+ title: `Voice model: ${selectedModel.label}`,
661
+ disabled: props.selectionDisabled,
662
+ className: cn(
663
+ // ≥24px wide so WCAG 2.2 target-size passes when the chevron is shown.
664
+ "inline-flex h-8 w-6 items-center justify-center rounded-r-og-md outline-none",
665
+ "transition-[background-color,border-color,color] duration-200 ease-og-out",
666
+ "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
667
+ "pointer-coarse:h-11 pointer-coarse:w-7",
668
+ desktopOnlyModelMenu && "hidden sm:inline-flex",
669
+ voiceChevronTone(status.phase)
670
+ ),
671
+ children: /* @__PURE__ */ jsx2(ChevronDownIcon, { className: "size-3", "aria-hidden": true })
672
+ }
673
+ ) }),
674
+ /* @__PURE__ */ jsxs2(
675
+ DropdownMenuContent,
676
+ {
677
+ side: props.menuSide ?? "top",
678
+ align: "end",
679
+ sideOffset: 8,
680
+ collisionPadding: 12,
681
+ 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",
682
+ onCloseAutoFocus: (event) => event.preventDefault(),
683
+ children: [
684
+ /* @__PURE__ */ jsx2("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain", children: /* @__PURE__ */ jsx2(
685
+ RealtimeModelPickerMenu,
686
+ {
687
+ models,
688
+ selectedModel,
689
+ provider: pickerProvider,
690
+ direction: pickerDirection,
691
+ disabled: props.selectionDisabled === true || props.snapshot.mode?.state === "active",
692
+ onProviderChange: (provider, direction) => {
693
+ setPickerDirection(direction);
694
+ setPickerProvider(provider);
695
+ },
696
+ onSelect: (modelId) => {
697
+ props.onSelectModel?.(modelId);
698
+ setPickerOpen(false);
699
+ }
638
700
  }
639
- }
640
- ) }),
641
- status.phase !== "idle" ? /* @__PURE__ */ jsxs2(Fragment, { children: [
642
- /* @__PURE__ */ jsx2(DropdownMenuSeparator, { className: "mx-1 bg-og-border" }),
643
- /* @__PURE__ */ jsxs2("div", { className: "px-2.5 py-2", role: "status", "aria-live": "polite", children: [
644
- /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 text-og-sm font-medium text-og-fg", children: [
645
- /* @__PURE__ */ jsx2(RealtimeStatusDot, { phase: status.phase, reduceMotion: reduceMotion === true }),
646
- status.label
647
- ] }),
648
- /* @__PURE__ */ jsx2("p", { className: "mt-1 text-og-xs leading-5 text-og-fg-subtle", children: status.detail })
649
- ] })
650
- ] }) : null,
651
- audioBlocked ? /* @__PURE__ */ jsxs2(
652
- DropdownMenuItem,
653
- {
654
- className: "rounded-og-md",
655
- onSelect: () => void props.onRetryAudibleOutput().catch(() => void 0),
656
- children: [
657
- /* @__PURE__ */ jsx2(Volume2Icon, {}),
658
- "Resume audio"
659
- ]
660
- }
661
- ) : null,
662
- retryConnection ? /* @__PURE__ */ jsxs2(
663
- DropdownMenuItem,
664
- {
665
- className: "rounded-og-md",
666
- onSelect: () => void props.onRetry().catch(() => void 0),
667
- children: [
668
- /* @__PURE__ */ jsx2(RotateCcwIcon, {}),
669
- "Retry connection"
670
- ]
671
- }
672
- ) : null,
673
- modeOwned && props.snapshot.status !== "lost_owner" ? /* @__PURE__ */ jsxs2(
674
- DropdownMenuItem,
675
- {
676
- variant: "destructive",
677
- className: "rounded-og-md",
678
- disabled: props.snapshot.status === "stopping",
679
- onSelect: () => void props.onStop().catch(() => void 0),
680
- children: [
681
- /* @__PURE__ */ jsx2(SquareIcon, {}),
682
- "End voice conversation"
683
- ]
684
- }
685
- ) : null,
686
- diagnosticsVisible ? /* @__PURE__ */ jsx2(RealtimeDiagnosticsMenu, { snapshot: props.snapshot }) : null
687
- ]
688
- }
689
- )
701
+ ) }),
702
+ status.phase !== "idle" ? /* @__PURE__ */ jsxs2(Fragment, { children: [
703
+ /* @__PURE__ */ jsx2(DropdownMenuSeparator, { className: "mx-1 bg-og-border" }),
704
+ /* @__PURE__ */ jsxs2("div", { className: "px-2.5 py-2", role: "status", "aria-live": "polite", children: [
705
+ /* @__PURE__ */ jsxs2("div", { className: "flex items-center gap-2 text-og-sm font-medium text-og-fg", children: [
706
+ /* @__PURE__ */ jsx2(
707
+ RealtimeStatusDot,
708
+ {
709
+ phase: status.phase,
710
+ reduceMotion: reduceMotion === true
711
+ }
712
+ ),
713
+ status.label
714
+ ] }),
715
+ /* @__PURE__ */ jsx2("p", { className: "mt-1 text-og-xs leading-5 text-og-fg-subtle", children: status.detail })
716
+ ] })
717
+ ] }) : null,
718
+ audioBlocked ? /* @__PURE__ */ jsxs2(
719
+ DropdownMenuItem,
720
+ {
721
+ className: "rounded-og-md",
722
+ onSelect: () => void props.onRetryAudibleOutput().catch(() => void 0),
723
+ children: [
724
+ /* @__PURE__ */ jsx2(Volume2Icon, {}),
725
+ "Resume audio"
726
+ ]
727
+ }
728
+ ) : null,
729
+ retryConnection ? /* @__PURE__ */ jsxs2(
730
+ DropdownMenuItem,
731
+ {
732
+ className: "rounded-og-md",
733
+ onSelect: () => void props.onRetry().catch(() => void 0),
734
+ children: [
735
+ /* @__PURE__ */ jsx2(RotateCcwIcon, {}),
736
+ "Retry connection"
737
+ ]
738
+ }
739
+ ) : null,
740
+ modeOwned && props.snapshot.status !== "lost_owner" ? /* @__PURE__ */ jsxs2(
741
+ DropdownMenuItem,
742
+ {
743
+ variant: "destructive",
744
+ className: "rounded-og-md",
745
+ disabled: props.snapshot.status === "stopping",
746
+ onSelect: () => void props.onStop().catch(() => void 0),
747
+ children: [
748
+ /* @__PURE__ */ jsx2(SquareIcon, {}),
749
+ "End voice conversation"
750
+ ]
751
+ }
752
+ ) : null,
753
+ diagnosticsVisible ? /* @__PURE__ */ jsx2(RealtimeDiagnosticsMenu, { snapshot: props.snapshot }) : null
754
+ ]
755
+ }
756
+ )
757
+ ] }) : null
690
758
  ] }),
691
759
  /* @__PURE__ */ jsxs2("span", { className: "sr-only", role: "status", "aria-live": "polite", children: [
692
760
  status.label,
@@ -698,6 +766,34 @@ function RealtimeVoiceControl(props) {
698
766
  }
699
767
  );
700
768
  }
769
+ function RealtimeVoiceModelPanel(props) {
770
+ const [provider, setProvider] = useState(null);
771
+ const [direction, setDirection] = useState(1);
772
+ return /* @__PURE__ */ jsxs2("div", { className: "flex min-h-0 flex-1 flex-col", "data-testid": "realtime-voice-model-panel", children: [
773
+ /* @__PURE__ */ jsxs2("div", { className: "flex shrink-0 items-start gap-1 px-2 pt-1 pb-1.5", children: [
774
+ props.leading,
775
+ /* @__PURE__ */ jsxs2("div", { className: "min-w-0", children: [
776
+ /* @__PURE__ */ jsx2("div", { className: "text-sm font-medium text-og-fg", children: "Voice model" }),
777
+ /* @__PURE__ */ jsx2("p", { className: "mt-0.5 text-xs text-og-fg-subtle", children: "Used when you start a voice conversation." })
778
+ ] })
779
+ ] }),
780
+ /* @__PURE__ */ jsx2("div", { className: "min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5 pb-1.5", children: /* @__PURE__ */ jsx2(
781
+ RealtimeModelPickerMenu,
782
+ {
783
+ models: props.models,
784
+ selectedModel: props.selectedModel,
785
+ provider,
786
+ direction,
787
+ disabled: props.disabled === true,
788
+ onProviderChange: (next, nextDirection) => {
789
+ setDirection(nextDirection);
790
+ setProvider(next);
791
+ },
792
+ onSelect: props.onSelect
793
+ }
794
+ ) })
795
+ ] });
796
+ }
701
797
  function RealtimeMuteButton(props) {
702
798
  const microphone = props.kind === "microphone";
703
799
  const label = microphone ? props.muted ? "Unmute microphone" : "Mute microphone" : props.muted ? "Unmute voice audio" : "Mute voice audio";
@@ -709,13 +805,22 @@ function RealtimeMuteButton(props) {
709
805
  "aria-label": label,
710
806
  "aria-pressed": props.muted,
711
807
  title: label,
808
+ initial: props.reduceMotion ? false : { scale: 0.72, opacity: 0 },
809
+ animate: { scale: 1, opacity: 1 },
810
+ transition: {
811
+ delay: props.enterDelay ?? 0,
812
+ type: "spring",
813
+ stiffness: 420,
814
+ damping: 26,
815
+ mass: 0.7
816
+ },
712
817
  ...props.reduceMotion ? {} : { whileTap: { scale: 0.92 } },
713
818
  onClick: props.onToggle,
714
819
  className: cn(
715
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md border outline-none",
820
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md outline-none",
716
821
  "transition-[background-color,border-color,color] duration-150 ease-og-out",
717
822
  "focus-visible:ring-2 focus-visible:ring-og-accent/45 pointer-coarse:size-11",
718
- props.muted ? "border-og-accent/35 bg-og-accent-soft text-og-accent" : "border-og-border bg-og-surface-2 text-og-fg-muted hover:border-og-accent/35 hover:text-og-fg"
823
+ props.muted ? "border border-og-accent/35 bg-og-accent-soft text-og-accent" : "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg"
719
824
  ),
720
825
  children: /* @__PURE__ */ jsx2(Icon, { className: "size-3.5", "aria-hidden": true })
721
826
  }
@@ -970,15 +1075,27 @@ function RealtimeStatusDot(props) {
970
1075
  }
971
1076
  function voiceButtonTone(phase) {
972
1077
  if (phase === "listening" || phase === "speaking") {
973
- return "border-og-accent/45 bg-og-accent text-og-accent-fg shadow-og-sm hover:bg-og-accent-strong";
1078
+ return "border border-og-accent/45 bg-og-accent text-og-accent-fg shadow-og-sm hover:bg-og-accent-strong";
1079
+ }
1080
+ if (phase === "blocked" || phase === "error") {
1081
+ return "border border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15";
1082
+ }
1083
+ if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") {
1084
+ return "border border-og-accent/35 bg-og-accent-soft text-og-accent";
1085
+ }
1086
+ return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg";
1087
+ }
1088
+ function voiceChevronTone(phase) {
1089
+ if (phase === "listening" || phase === "speaking") {
1090
+ return "border border-l-0 border-og-accent/45 bg-og-accent text-og-accent-fg hover:bg-og-accent-strong";
974
1091
  }
975
1092
  if (phase === "blocked" || phase === "error") {
976
- return "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15";
1093
+ 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";
977
1094
  }
978
1095
  if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") {
979
- return "border-og-accent/35 bg-og-accent-soft text-og-accent";
1096
+ return "border border-l-0 border-og-accent/35 bg-og-accent-soft text-og-accent";
980
1097
  }
981
- 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";
1098
+ return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg";
982
1099
  }
983
1100
  function toRealtimeModelOption(model) {
984
1101
  return { ...model };
@@ -1011,6 +1128,7 @@ export {
1011
1128
  NewSessionRealtimeControl,
1012
1129
  RealtimeModelPickerMenu,
1013
1130
  RealtimeVoiceControl,
1131
+ RealtimeVoiceModelPanel,
1014
1132
  SessionCodexRealtimeControl,
1015
1133
  SessionRealtimeControl,
1016
1134
  codexRealtimeAdmissionAllowed,