@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.
- package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
- package/dist/chunk-23EJ676W.js.map +1 -0
- package/dist/{chunk-UWYTCQWW.js → chunk-KC27K42G.js} +92 -21
- package/dist/{chunk-UWYTCQWW.js.map → chunk-KC27K42G.js.map} +1 -1
- package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
- package/dist/chunk-KG5F2OKE.js.map +1 -0
- package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
- package/dist/chunk-LWR4MXSS.js.map +1 -0
- package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
- package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
- package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
- package/dist/components/chat-composer.d.ts +5 -1
- package/dist/components/composer-transcription-control.d.ts +3 -1
- package/dist/components/composer.d.ts +6 -4
- package/dist/components/session-chrome.d.ts +1 -1
- package/dist/composer.js +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +69 -20
- package/dist/index.js.map +1 -1
- package/dist/model-policy.d.ts +2 -0
- package/dist/model-policy.js +1 -1
- package/dist/realtime/realtime-control.d.ts +29 -1
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +269 -151
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +2 -2
- package/dist/session.js +2 -2
- package/dist/timeline/index.d.ts +1 -1
- package/dist/timeline/parsers.d.ts +13 -8
- package/package.json +2 -2
- package/src/components/chat-composer.tsx +58 -19
- package/src/components/composer-transcription-control.tsx +194 -165
- package/src/components/composer.tsx +78 -14
- package/src/components/model-policy-picker.tsx +7 -2
- package/src/components/session-chrome.tsx +89 -78
- package/src/index.ts +2 -0
- package/src/model-policy.ts +4 -0
- package/src/realtime/realtime-control.tsx +307 -137
- package/src/realtime.ts +1 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/parsers.ts +201 -19
- package/src/timeline/projection.ts +11 -0
- package/src/timeline/tool-renderers.tsx +7 -5
- package/src/timeline/turn-summary.tsx +7 -2
- package/styles/tokens.css +8 -5
- package/dist/chunk-4IJCL7YO.js.map +0 -1
- package/dist/chunk-KR2SK5GJ.js.map +0 -1
- package/dist/chunk-SJKT4TKW.js.map +0 -1
- /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 {
|
|
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
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
{
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
<
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
<
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
<
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
<
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|
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
|
-
: "
|
|
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 "
|
|
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