@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
@@ -23,7 +23,15 @@ import {
23
23
  VolumeXIcon,
24
24
  } from "lucide-react";
25
25
  import { AnimatePresence, motion, useReducedMotion } from "motion/react";
26
- import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
26
+ import {
27
+ type ReactNode,
28
+ type RefObject,
29
+ useCallback,
30
+ useEffect,
31
+ useMemo,
32
+ useRef,
33
+ useState,
34
+ } from "react";
27
35
 
28
36
  import {
29
37
  BillingClassMark,
@@ -287,6 +295,8 @@ export function SessionRealtimeControl(props: {
287
295
  codexConnected: boolean;
288
296
  realtimeAutostartModel?: SessionRealtimeModel | undefined;
289
297
  onRealtimeAutostartConsumed?: (() => void) | undefined;
298
+ /** Host hides dictate (and can choreograph layout) while realtime voice is live. */
299
+ onVoiceActiveChange?: ((active: boolean) => void) | undefined;
290
300
  /** Deterministic browser-test/demo seam. Production hosts should use the SDK default. */
291
301
  controllerFactory?: SessionRealtimeControllerFactory | undefined;
292
302
  }) {
@@ -310,6 +320,12 @@ export function SessionRealtimeControl(props: {
310
320
  const { models, selectedModel: selectedRealtimeModel, selectModel } = selection;
311
321
  const { canStart, start } = realtime;
312
322
  const onRealtimeAutostartConsumed = props.onRealtimeAutostartConsumed;
323
+ const onVoiceActiveChange = props.onVoiceActiveChange;
324
+ const voiceActive = realtime.snapshot.mode?.state === "active";
325
+
326
+ useEffect(() => {
327
+ onVoiceActiveChange?.(voiceActive);
328
+ }, [onVoiceActiveChange, voiceActive]);
313
329
 
314
330
  useEffect(() => {
315
331
  const pending = autostartModelRef.current;
@@ -452,12 +468,30 @@ export function NewSessionRealtimeControl(props: {
452
468
  disabled?: boolean | undefined;
453
469
  disabledReason?: string | null | undefined;
454
470
  onStart: (model: SessionRealtimeModel) => Promise<boolean>;
471
+ /** Parent-owned selection (e.g. shared with the mobile “+” voice-model panel). */
472
+ models?: readonly RealtimeModelOption[] | undefined;
473
+ selectedModel?: RealtimeModelOption | undefined;
474
+ onSelectModel?: ((modelId: string) => void) | undefined;
475
+ /**
476
+ * `split` attaches the model chevron at every breakpoint (public default).
477
+ * `split-desktop` hides it below `sm` when the host owns mobile selection (e.g. “+”).
478
+ * `none` never attaches a model menu to the bar button.
479
+ */
480
+ modelMenu?: "split" | "split-desktop" | "none" | undefined;
455
481
  }) {
456
- const selection = useRealtimeModelSelection({
482
+ const internalSelection = useRealtimeModelSelection({
457
483
  client: props.client,
458
484
  workspaceId: props.workspaceId,
459
485
  codexConnected: props.codexConnected,
460
486
  });
487
+ const selection =
488
+ props.models && props.selectedModel && props.onSelectModel
489
+ ? {
490
+ models: props.models,
491
+ selectedModel: props.selectedModel,
492
+ selectModel: props.onSelectModel,
493
+ }
494
+ : internalSelection;
461
495
  const audioRef = useRef<HTMLAudioElement>(null);
462
496
  const [starting, setStarting] = useState(false);
463
497
  const [error, setError] = useState<string | null>(null);
@@ -495,6 +529,7 @@ export function NewSessionRealtimeControl(props: {
495
529
  modelAvailable={selection.selectedModel.available}
496
530
  selectionDisabled={starting}
497
531
  menuSide="bottom"
532
+ modelMenu={props.modelMenu ?? "split"}
498
533
  showDiagnostics={false}
499
534
  audioRef={audioRef}
500
535
  selectedModel={selection.selectedModel}
@@ -518,7 +553,15 @@ export function RealtimeVoiceControl(props: {
518
553
  selectionDisabled?: boolean | undefined;
519
554
  /** Prefer `bottom` on home/new-session; `top` for the docked session composer. */
520
555
  menuSide?: "top" | "bottom" | undefined;
556
+ /**
557
+ * `split` = start + model chevron at every breakpoint (public SDK default).
558
+ * `split-desktop` = chevron from `sm` up; below that the host owns selection.
559
+ * `none` = start button only.
560
+ */
561
+ modelMenu?: "split" | "split-desktop" | "none" | undefined;
521
562
  showDiagnostics?: boolean;
563
+ /** Extra classes on the in-bar mute cluster (e.g. `max-sm:hidden` when mutes live in “+”). */
564
+ muteControlsClassName?: string | undefined;
522
565
  audioRef: RefObject<HTMLAudioElement | null>;
523
566
  selectedModel?: RealtimeModelOption | undefined;
524
567
  models?: readonly RealtimeModelOption[] | undefined;
@@ -571,6 +614,9 @@ export function RealtimeVoiceControl(props: {
571
614
  ? props.onStop
572
615
  : props.onStart;
573
616
  const diagnosticsVisible = props.showDiagnostics ?? import.meta.env.DEV;
617
+ const modelMenu = props.modelMenu ?? "split";
618
+ const showAttachedModelMenu = modelMenu === "split" || modelMenu === "split-desktop";
619
+ const desktopOnlyModelMenu = modelMenu === "split-desktop";
574
620
  const [pickerOpen, setPickerOpen] = useState(false);
575
621
  const [pickerProvider, setPickerProvider] = useState<RealtimeModelProvider | null>(
576
622
  selectedModel.provider,
@@ -583,6 +629,7 @@ export function RealtimeVoiceControl(props: {
583
629
 
584
630
  return (
585
631
  <div
632
+ role="group"
586
633
  aria-label="Realtime voice"
587
634
  data-picker-side={props.menuSide ?? "top"}
588
635
  className="inline-flex shrink-0 items-center"
@@ -594,143 +641,202 @@ export function RealtimeVoiceControl(props: {
594
641
  <motion.div
595
642
  key="realtime-mute-controls"
596
643
  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"
644
+ // After dictate collapses and the model slides left, bloom mutes
645
+ // out from the primary voice control. Exit is immediate (no delay).
646
+ initial={reduceMotion ? false : { opacity: 0, width: 0 }}
647
+ animate={{
648
+ opacity: 1,
649
+ width: "auto",
650
+ transition: reduceMotion
651
+ ? { duration: 0 }
652
+ : { delay: 0.3, duration: 0.48, ease: [0.22, 1, 0.36, 1] },
653
+ }}
654
+ {...(reduceMotion
655
+ ? {}
656
+ : {
657
+ exit: {
658
+ opacity: 0,
659
+ width: 0,
660
+ transition: { duration: 0.28, ease: [0.4, 0, 1, 1] },
661
+ },
662
+ })}
663
+ className={cn(
664
+ "inline-flex shrink-0 items-center overflow-hidden",
665
+ props.muteControlsClassName,
666
+ )}
602
667
  >
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
- />
668
+ <motion.div
669
+ className="inline-flex items-center gap-0.5 pr-1"
670
+ initial={reduceMotion ? false : { x: 18, opacity: 0 }}
671
+ animate={{
672
+ x: 0,
673
+ opacity: 1,
674
+ transition: reduceMotion
675
+ ? { duration: 0 }
676
+ : { delay: 0.32, type: "spring", stiffness: 320, damping: 30, mass: 0.8 },
677
+ }}
678
+ {...(reduceMotion
679
+ ? {}
680
+ : {
681
+ exit: {
682
+ x: 14,
683
+ opacity: 0,
684
+ transition: { duration: 0.22, ease: [0.4, 0, 1, 1] },
685
+ },
686
+ })}
687
+ >
688
+ <RealtimeMuteButton
689
+ muted={props.snapshot.inputMuted}
690
+ kind="microphone"
691
+ reduceMotion={reduceMotion === true}
692
+ enterDelay={reduceMotion ? 0 : 0.34}
693
+ onToggle={() => props.onSetInputMuted(!props.snapshot.inputMuted)}
694
+ />
695
+ <RealtimeMuteButton
696
+ muted={props.snapshot.outputMuted}
697
+ kind="output"
698
+ reduceMotion={reduceMotion === true}
699
+ enterDelay={reduceMotion ? 0 : 0.4}
700
+ onToggle={() => props.onSetOutputMuted(!props.snapshot.outputMuted)}
701
+ />
702
+ </motion.div>
615
703
  </motion.div>
616
704
  ) : null}
617
705
  </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()}
706
+ <div className="inline-flex shrink-0 items-center">
707
+ <motion.button
708
+ type="button"
709
+ data-testid="realtime-primary-action"
710
+ data-phase={status.phase}
711
+ aria-label={mainLabel}
712
+ aria-pressed={modeOwned}
713
+ title={mainLabel}
714
+ disabled={mainDisabled}
715
+ {...(reduceMotion ? {} : { whileTap: { scale: 0.92 } })}
716
+ transition={{ type: "spring", stiffness: 520, damping: 30 }}
717
+ onClick={() => void runMainAction().catch(() => undefined)}
718
+ className={cn(
719
+ // Match transcription mic: ghost icon when idle; filled only when live.
720
+ // Public contract: coarse pointers keep a 44px target; split menu uses
721
+ // left-only rounding at `sm+` where the chevron attaches.
722
+ "relative inline-flex size-8 items-center justify-center outline-none",
723
+ "rounded-og-md transition-[background-color,border-color,color,box-shadow] duration-200 ease-og-out",
724
+ "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
725
+ "disabled:cursor-not-allowed disabled:opacity-45 pointer-coarse:size-11",
726
+ showAttachedModelMenu &&
727
+ (desktopOnlyModelMenu
728
+ ? "sm:rounded-l-og-md sm:rounded-r-none"
729
+ : "rounded-l-og-md rounded-r-none"),
730
+ voiceButtonTone(status.phase),
731
+ )}
669
732
  >
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>
733
+ <RealtimeActionGlyph
734
+ phase={status.phase}
735
+ audioBlocked={audioBlocked}
736
+ reduceMotion={reduceMotion === true}
737
+ />
738
+ </motion.button>
687
739
 
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>
740
+ {showAttachedModelMenu ? (
741
+ <DropdownMenu open={pickerOpen} onOpenChange={setPickerOpen}>
742
+ <DropdownMenuTrigger asChild>
743
+ <button
744
+ type="button"
745
+ aria-label="Choose voice model and options"
746
+ title={`Voice model: ${selectedModel.label}`}
747
+ disabled={props.selectionDisabled}
748
+ className={cn(
749
+ // ≥24px wide so WCAG 2.2 target-size passes when the chevron is shown.
750
+ "inline-flex h-8 w-6 items-center justify-center rounded-r-og-md outline-none",
751
+ "transition-[background-color,border-color,color] duration-200 ease-og-out",
752
+ "focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-og-accent/45",
753
+ "pointer-coarse:h-11 pointer-coarse:w-7",
754
+ desktopOnlyModelMenu && "hidden sm:inline-flex",
755
+ voiceChevronTone(status.phase),
756
+ )}
757
+ >
758
+ <ChevronDownIcon className="size-3" aria-hidden />
759
+ </button>
760
+ </DropdownMenuTrigger>
761
+ <DropdownMenuContent
762
+ side={props.menuSide ?? "top"}
763
+ align="end"
764
+ sideOffset={8}
765
+ collisionPadding={12}
766
+ 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"
767
+ onCloseAutoFocus={(event) => event.preventDefault()}
768
+ >
769
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
770
+ <RealtimeModelPickerMenu
771
+ models={models}
772
+ selectedModel={selectedModel}
773
+ provider={pickerProvider}
774
+ direction={pickerDirection}
775
+ disabled={
776
+ props.selectionDisabled === true || props.snapshot.mode?.state === "active"
777
+ }
778
+ onProviderChange={(provider, direction) => {
779
+ setPickerDirection(direction);
780
+ setPickerProvider(provider);
781
+ }}
782
+ onSelect={(modelId) => {
783
+ props.onSelectModel?.(modelId);
784
+ setPickerOpen(false);
785
+ }}
786
+ />
697
787
  </div>
698
- </>
699
- ) : null}
700
788
 
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}
789
+ {status.phase !== "idle" ? (
790
+ <>
791
+ <DropdownMenuSeparator className="mx-1 bg-og-border" />
792
+ <div className="px-2.5 py-2" role="status" aria-live="polite">
793
+ <div className="flex items-center gap-2 text-og-sm font-medium text-og-fg">
794
+ <RealtimeStatusDot
795
+ phase={status.phase}
796
+ reduceMotion={reduceMotion === true}
797
+ />
798
+ {status.label}
799
+ </div>
800
+ <p className="mt-1 text-og-xs leading-5 text-og-fg-subtle">{status.detail}</p>
801
+ </div>
802
+ </>
803
+ ) : null}
730
804
 
731
- {diagnosticsVisible ? <RealtimeDiagnosticsMenu snapshot={props.snapshot} /> : null}
732
- </DropdownMenuContent>
733
- </DropdownMenu>
805
+ {audioBlocked ? (
806
+ <DropdownMenuItem
807
+ className="rounded-og-md"
808
+ onSelect={() => void props.onRetryAudibleOutput().catch(() => undefined)}
809
+ >
810
+ <Volume2Icon />
811
+ Resume audio
812
+ </DropdownMenuItem>
813
+ ) : null}
814
+ {retryConnection ? (
815
+ <DropdownMenuItem
816
+ className="rounded-og-md"
817
+ onSelect={() => void props.onRetry().catch(() => undefined)}
818
+ >
819
+ <RotateCcwIcon />
820
+ Retry connection
821
+ </DropdownMenuItem>
822
+ ) : null}
823
+ {modeOwned && props.snapshot.status !== "lost_owner" ? (
824
+ <DropdownMenuItem
825
+ variant="destructive"
826
+ className="rounded-og-md"
827
+ disabled={props.snapshot.status === "stopping"}
828
+ onSelect={() => void props.onStop().catch(() => undefined)}
829
+ >
830
+ <SquareIcon />
831
+ End voice conversation
832
+ </DropdownMenuItem>
833
+ ) : null}
834
+
835
+ {diagnosticsVisible ? <RealtimeDiagnosticsMenu snapshot={props.snapshot} /> : null}
836
+ </DropdownMenuContent>
837
+ </DropdownMenu>
838
+ ) : null}
839
+ </div>
734
840
 
735
841
  <span className="sr-only" role="status" aria-live="polite">
736
842
  {status.label}. {status.detail}
@@ -744,10 +850,51 @@ export function RealtimeVoiceControl(props: {
744
850
  );
745
851
  }
746
852
 
853
+ /** Drill-in body for the mobile composer “+” menu — same catalog as the desktop chevron. */
854
+ export function RealtimeVoiceModelPanel(props: {
855
+ models: readonly RealtimeModelOption[];
856
+ selectedModel: RealtimeModelOption;
857
+ disabled?: boolean | undefined;
858
+ leading?: ReactNode | undefined;
859
+ onSelect: (modelId: SessionRealtimeModel) => void;
860
+ }) {
861
+ const [provider, setProvider] = useState<RealtimeModelProvider | null>(null);
862
+ const [direction, setDirection] = useState<1 | -1>(1);
863
+
864
+ return (
865
+ <div className="flex min-h-0 flex-1 flex-col" data-testid="realtime-voice-model-panel">
866
+ <div className="flex shrink-0 items-start gap-1 px-2 pt-1 pb-1.5">
867
+ {props.leading}
868
+ <div className="min-w-0">
869
+ <div className="text-sm font-medium text-og-fg">Voice model</div>
870
+ <p className="mt-0.5 text-xs text-og-fg-subtle">
871
+ Used when you start a voice conversation.
872
+ </p>
873
+ </div>
874
+ </div>
875
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5 pb-1.5">
876
+ <RealtimeModelPickerMenu
877
+ models={props.models}
878
+ selectedModel={props.selectedModel}
879
+ provider={provider}
880
+ direction={direction}
881
+ disabled={props.disabled === true}
882
+ onProviderChange={(next, nextDirection) => {
883
+ setDirection(nextDirection);
884
+ setProvider(next);
885
+ }}
886
+ onSelect={props.onSelect}
887
+ />
888
+ </div>
889
+ </div>
890
+ );
891
+ }
892
+
747
893
  function RealtimeMuteButton(props: {
748
894
  muted: boolean;
749
895
  kind: "microphone" | "output";
750
896
  reduceMotion: boolean;
897
+ enterDelay?: number | undefined;
751
898
  onToggle(): void;
752
899
  }) {
753
900
  const microphone = props.kind === "microphone";
@@ -772,15 +919,24 @@ function RealtimeMuteButton(props: {
772
919
  aria-label={label}
773
920
  aria-pressed={props.muted}
774
921
  title={label}
922
+ initial={props.reduceMotion ? false : { scale: 0.72, opacity: 0 }}
923
+ animate={{ scale: 1, opacity: 1 }}
924
+ transition={{
925
+ delay: props.enterDelay ?? 0,
926
+ type: "spring",
927
+ stiffness: 420,
928
+ damping: 26,
929
+ mass: 0.7,
930
+ }}
775
931
  {...(props.reduceMotion ? {} : { whileTap: { scale: 0.92 } })}
776
932
  onClick={props.onToggle}
777
933
  className={cn(
778
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md border outline-none",
934
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md outline-none",
779
935
  "transition-[background-color,border-color,color] duration-150 ease-og-out",
780
936
  "focus-visible:ring-2 focus-visible:ring-og-accent/45 pointer-coarse:size-11",
781
937
  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",
938
+ ? "border border-og-accent/35 bg-og-accent-soft text-og-accent"
939
+ : "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg",
784
940
  )}
785
941
  >
786
942
  <Icon className="size-3.5" aria-hidden />
@@ -1122,15 +1278,29 @@ function RealtimeStatusDot(props: { phase: RealtimeVisualPhase; reduceMotion: bo
1122
1278
 
1123
1279
  function voiceButtonTone(phase: RealtimeVisualPhase): string {
1124
1280
  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";
1281
+ return "border border-og-accent/45 bg-og-accent text-og-accent-fg shadow-og-sm hover:bg-og-accent-strong";
1282
+ }
1283
+ if (phase === "blocked" || phase === "error") {
1284
+ return "border border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting hover:bg-og-status-waiting/15";
1285
+ }
1286
+ if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") {
1287
+ return "border border-og-accent/35 bg-og-accent-soft text-og-accent";
1288
+ }
1289
+ // Idle: same ghost treatment as the composer transcription mic — no box.
1290
+ return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg";
1291
+ }
1292
+
1293
+ function voiceChevronTone(phase: RealtimeVisualPhase): string {
1294
+ if (phase === "listening" || phase === "speaking") {
1295
+ return "border border-l-0 border-og-accent/45 bg-og-accent text-og-accent-fg hover:bg-og-accent-strong";
1126
1296
  }
1127
1297
  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";
1298
+ 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";
1129
1299
  }
1130
1300
  if (phase === "connecting" || phase === "reconnecting" || phase === "stopping") {
1131
- return "border-og-accent/35 bg-og-accent-soft text-og-accent";
1301
+ return "border border-l-0 border-og-accent/35 bg-og-accent-soft text-og-accent";
1132
1302
  }
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";
1303
+ return "text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg";
1134
1304
  }
1135
1305
 
1136
1306
  function toRealtimeModelOption(model: WorkspaceRealtimeModelCatalogItem): RealtimeModelOption {
package/src/realtime.ts CHANGED
@@ -10,6 +10,7 @@ export {
10
10
  NewSessionRealtimeControl,
11
11
  RealtimeModelPickerMenu,
12
12
  RealtimeVoiceControl,
13
+ RealtimeVoiceModelPanel,
13
14
  SessionCodexRealtimeControl,
14
15
  SessionRealtimeControl,
15
16
  codexRealtimeAdmissionAllowed,
@@ -114,6 +114,8 @@ export type {
114
114
  // parsers (pure, reusable by custom renderers)
115
115
  export {
116
116
  applyPatchOps,
117
+ applyPatchOpsFromToolItem,
118
+ parseFreeformApplyPatch,
117
119
  controlCaret,
118
120
  execTruncated,
119
121
  isApplyPatch,