@agent-native/toolkit 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -1
- package/agent-native.eject.json +411 -0
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +20 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import { Button } from "@agent-native/toolkit/ui/button";
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectGroup,
|
|
6
|
+
SelectItem,
|
|
7
|
+
SelectTrigger,
|
|
8
|
+
} from "@agent-native/toolkit/ui/select";
|
|
9
|
+
import {
|
|
10
|
+
IconAlertTriangle,
|
|
11
|
+
IconBrain,
|
|
12
|
+
IconLanguage,
|
|
13
|
+
IconLoader2,
|
|
14
|
+
IconMicrophone,
|
|
15
|
+
IconPhoneOff,
|
|
16
|
+
IconSettings,
|
|
17
|
+
IconVolume,
|
|
18
|
+
} from "@tabler/icons-react";
|
|
19
|
+
import {
|
|
20
|
+
type MouseEvent,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useId,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
useSyncExternalStore,
|
|
27
|
+
} from "react";
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
Popover,
|
|
31
|
+
PopoverAnchor,
|
|
32
|
+
PopoverContent,
|
|
33
|
+
PopoverTrigger,
|
|
34
|
+
} from "../ui/popover.js";
|
|
35
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
|
|
36
|
+
import { cn } from "../utils.js";
|
|
37
|
+
import {
|
|
38
|
+
createRealtimeVoiceAudioLevelStore,
|
|
39
|
+
type RealtimeVoiceAudioLevelStore,
|
|
40
|
+
} from "./realtime-voice-audio-level.js";
|
|
41
|
+
|
|
42
|
+
export type RealtimeVoiceModeState =
|
|
43
|
+
| "connecting"
|
|
44
|
+
| "listening"
|
|
45
|
+
| "speaking"
|
|
46
|
+
| "working"
|
|
47
|
+
| "error"
|
|
48
|
+
| "ending";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* User-visible copy stays outside the shared component so host catalogs remain
|
|
52
|
+
* the source of truth. Callers should provide these values through `useT()`.
|
|
53
|
+
*/
|
|
54
|
+
export interface RealtimeVoiceModeCopy {
|
|
55
|
+
entryButtonLabel: string;
|
|
56
|
+
promptTitle: string;
|
|
57
|
+
promptDescription: string;
|
|
58
|
+
setupTitle: string;
|
|
59
|
+
setupDescription: string;
|
|
60
|
+
connectBuilder: string;
|
|
61
|
+
useOpenAiKey: string;
|
|
62
|
+
startWithOpenAiKey: string;
|
|
63
|
+
startVoiceMode: string;
|
|
64
|
+
keepDictating: string;
|
|
65
|
+
showChat: string;
|
|
66
|
+
hideChat: string;
|
|
67
|
+
endVoiceMode: string;
|
|
68
|
+
voiceSettings: string;
|
|
69
|
+
settings: {
|
|
70
|
+
microphone: string;
|
|
71
|
+
defaultMicrophone: string;
|
|
72
|
+
microphoneSwitchFailed: string;
|
|
73
|
+
language: string;
|
|
74
|
+
autoLanguage: string;
|
|
75
|
+
languages: Record<
|
|
76
|
+
Exclude<
|
|
77
|
+
import("./useRealtimeVoiceMode.js").RealtimeVoiceLanguage,
|
|
78
|
+
"auto"
|
|
79
|
+
>,
|
|
80
|
+
string
|
|
81
|
+
>;
|
|
82
|
+
intelligence: string;
|
|
83
|
+
intelligenceLevels: Record<
|
|
84
|
+
import("./useRealtimeVoiceMode.js").RealtimeVoiceIntelligence,
|
|
85
|
+
string
|
|
86
|
+
>;
|
|
87
|
+
voiceStyle: string;
|
|
88
|
+
voiceChangePending: string;
|
|
89
|
+
voiceDescriptions: Record<
|
|
90
|
+
import("./useRealtimeVoiceMode.js").RealtimeVoice,
|
|
91
|
+
string
|
|
92
|
+
>;
|
|
93
|
+
};
|
|
94
|
+
status: Record<RealtimeVoiceModeState, string>;
|
|
95
|
+
errors: {
|
|
96
|
+
unsupported: string;
|
|
97
|
+
responseFailed: string;
|
|
98
|
+
sessionFailed: string;
|
|
99
|
+
channelDisconnected: string;
|
|
100
|
+
connectionTimedOut?: string;
|
|
101
|
+
connectionFailed: string;
|
|
102
|
+
offerFailed: string;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface RealtimeVoiceModeEntryProps {
|
|
107
|
+
copy: RealtimeVoiceModeCopy;
|
|
108
|
+
disabled?: boolean;
|
|
109
|
+
providerStatusPending?: boolean;
|
|
110
|
+
open?: boolean;
|
|
111
|
+
onOpenChange?: (open: boolean) => void;
|
|
112
|
+
onStartVoiceMode: () => void;
|
|
113
|
+
onKeepDictating: () => void;
|
|
114
|
+
setupRequired?: boolean;
|
|
115
|
+
openAiConfigured?: boolean;
|
|
116
|
+
connectingBuilder?: boolean;
|
|
117
|
+
onConnectBuilder?: () => void;
|
|
118
|
+
onUseOpenAiKey?: () => void;
|
|
119
|
+
className?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Composer mic entry point for apps that support a full-duplex voice session.
|
|
124
|
+
* The first click offers voice mode without silently changing the existing
|
|
125
|
+
* editable-dictation behavior.
|
|
126
|
+
*/
|
|
127
|
+
export function RealtimeVoiceModeEntry({
|
|
128
|
+
copy,
|
|
129
|
+
disabled,
|
|
130
|
+
providerStatusPending = false,
|
|
131
|
+
open: controlledOpen,
|
|
132
|
+
onOpenChange,
|
|
133
|
+
onStartVoiceMode,
|
|
134
|
+
onKeepDictating,
|
|
135
|
+
setupRequired = false,
|
|
136
|
+
openAiConfigured = false,
|
|
137
|
+
connectingBuilder = false,
|
|
138
|
+
onConnectBuilder,
|
|
139
|
+
onUseOpenAiKey,
|
|
140
|
+
className,
|
|
141
|
+
}: RealtimeVoiceModeEntryProps) {
|
|
142
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
|
143
|
+
const open = controlledOpen ?? uncontrolledOpen;
|
|
144
|
+
const titleId = useId();
|
|
145
|
+
const descriptionId = useId();
|
|
146
|
+
const [collisionBoundary, setCollisionBoundary] =
|
|
147
|
+
useState<HTMLElement | null>(null);
|
|
148
|
+
|
|
149
|
+
const setTriggerNode = useCallback((node: HTMLButtonElement | null) => {
|
|
150
|
+
const nextBoundary =
|
|
151
|
+
node?.closest<HTMLElement>(".agent-panel-root") ?? null;
|
|
152
|
+
setCollisionBoundary((currentBoundary) =>
|
|
153
|
+
currentBoundary === nextBoundary ? currentBoundary : nextBoundary,
|
|
154
|
+
);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const setOpen = (nextOpen: boolean) => {
|
|
158
|
+
if (controlledOpen === undefined) setUncontrolledOpen(nextOpen);
|
|
159
|
+
onOpenChange?.(nextOpen);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const choose = (callback: () => void) => {
|
|
163
|
+
setOpen(false);
|
|
164
|
+
callback();
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
169
|
+
<Tooltip>
|
|
170
|
+
<TooltipTrigger asChild>
|
|
171
|
+
<PopoverTrigger asChild>
|
|
172
|
+
<Button
|
|
173
|
+
ref={setTriggerNode}
|
|
174
|
+
type="button"
|
|
175
|
+
variant="ghost"
|
|
176
|
+
size="icon"
|
|
177
|
+
disabled={disabled}
|
|
178
|
+
aria-label={copy.entryButtonLabel}
|
|
179
|
+
aria-expanded={open}
|
|
180
|
+
className={cn(
|
|
181
|
+
"size-7 shrink-0 text-muted-foreground hover:text-foreground",
|
|
182
|
+
className,
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<IconMicrophone />
|
|
186
|
+
</Button>
|
|
187
|
+
</PopoverTrigger>
|
|
188
|
+
</TooltipTrigger>
|
|
189
|
+
<TooltipContent>{copy.entryButtonLabel}</TooltipContent>
|
|
190
|
+
</Tooltip>
|
|
191
|
+
|
|
192
|
+
<PopoverContent
|
|
193
|
+
side="top"
|
|
194
|
+
align="end"
|
|
195
|
+
sideOffset={10}
|
|
196
|
+
collisionBoundary={collisionBoundary ?? undefined}
|
|
197
|
+
collisionPadding={16}
|
|
198
|
+
data-collision-boundary={collisionBoundary ? "agent-panel" : "viewport"}
|
|
199
|
+
className={cn(
|
|
200
|
+
"p-4",
|
|
201
|
+
setupRequired
|
|
202
|
+
? "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]"
|
|
203
|
+
: "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,22rem),22rem)]",
|
|
204
|
+
)}
|
|
205
|
+
aria-labelledby={titleId}
|
|
206
|
+
aria-describedby={descriptionId}
|
|
207
|
+
>
|
|
208
|
+
<div className="grid gap-3">
|
|
209
|
+
<div className="grid gap-1">
|
|
210
|
+
<h2 id={titleId} className="text-sm font-semibold text-foreground">
|
|
211
|
+
{setupRequired ? copy.setupTitle : copy.promptTitle}
|
|
212
|
+
</h2>
|
|
213
|
+
<p
|
|
214
|
+
id={descriptionId}
|
|
215
|
+
className="text-sm leading-relaxed text-muted-foreground"
|
|
216
|
+
>
|
|
217
|
+
{setupRequired ? copy.setupDescription : copy.promptDescription}
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
|
|
222
|
+
<Button
|
|
223
|
+
type="button"
|
|
224
|
+
variant="ghost"
|
|
225
|
+
size="sm"
|
|
226
|
+
onClick={() => choose(onKeepDictating)}
|
|
227
|
+
>
|
|
228
|
+
{copy.keepDictating}
|
|
229
|
+
</Button>
|
|
230
|
+
{setupRequired ? (
|
|
231
|
+
<>
|
|
232
|
+
<Button
|
|
233
|
+
type="button"
|
|
234
|
+
variant="outline"
|
|
235
|
+
size="sm"
|
|
236
|
+
onClick={() => choose(onUseOpenAiKey ?? onStartVoiceMode)}
|
|
237
|
+
>
|
|
238
|
+
{openAiConfigured
|
|
239
|
+
? copy.startWithOpenAiKey
|
|
240
|
+
: copy.useOpenAiKey}
|
|
241
|
+
</Button>
|
|
242
|
+
<Button
|
|
243
|
+
type="button"
|
|
244
|
+
size="sm"
|
|
245
|
+
className="whitespace-nowrap"
|
|
246
|
+
disabled={connectingBuilder}
|
|
247
|
+
onClick={() => choose(onConnectBuilder ?? onStartVoiceMode)}
|
|
248
|
+
>
|
|
249
|
+
{connectingBuilder ? (
|
|
250
|
+
<IconLoader2 className="animate-spin" />
|
|
251
|
+
) : null}
|
|
252
|
+
{copy.connectBuilder}
|
|
253
|
+
</Button>
|
|
254
|
+
</>
|
|
255
|
+
) : (
|
|
256
|
+
<Button
|
|
257
|
+
type="button"
|
|
258
|
+
size="sm"
|
|
259
|
+
disabled={providerStatusPending}
|
|
260
|
+
onClick={() => choose(onStartVoiceMode)}
|
|
261
|
+
>
|
|
262
|
+
{providerStatusPending ? (
|
|
263
|
+
<IconLoader2 className="animate-spin" />
|
|
264
|
+
) : (
|
|
265
|
+
<IconMicrophone />
|
|
266
|
+
)}
|
|
267
|
+
{copy.startVoiceMode}
|
|
268
|
+
</Button>
|
|
269
|
+
)}
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</PopoverContent>
|
|
273
|
+
</Popover>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface RealtimeVoiceModeDockProps {
|
|
278
|
+
state: RealtimeVoiceModeState;
|
|
279
|
+
copy: RealtimeVoiceModeCopy;
|
|
280
|
+
chatVisible: boolean;
|
|
281
|
+
audioLevels?: RealtimeVoiceAudioLevelStore;
|
|
282
|
+
onToggleChat: () => void;
|
|
283
|
+
onEndVoiceMode: () => void;
|
|
284
|
+
settings?: RealtimeVoiceModeInlineSettings;
|
|
285
|
+
errorMessage?: string | null;
|
|
286
|
+
className?: string;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface RealtimeVoiceModeSettingOption {
|
|
290
|
+
value: string;
|
|
291
|
+
label: string;
|
|
292
|
+
description?: string;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface RealtimeVoiceModeSelectSetting {
|
|
296
|
+
label: string;
|
|
297
|
+
value: string;
|
|
298
|
+
options: readonly RealtimeVoiceModeSettingOption[];
|
|
299
|
+
onValueChange: (value: string) => void;
|
|
300
|
+
disabled?: boolean;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface RealtimeVoiceModeInlineSettings {
|
|
304
|
+
dialogLabel: string;
|
|
305
|
+
appliesNextConversationNote?: string;
|
|
306
|
+
microphoneError?: string;
|
|
307
|
+
microphone: RealtimeVoiceModeSelectSetting;
|
|
308
|
+
language: RealtimeVoiceModeSelectSetting;
|
|
309
|
+
intelligence: RealtimeVoiceModeSelectSetting;
|
|
310
|
+
voiceStyle: RealtimeVoiceModeSelectSetting;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const SILENT_AUDIO_LEVELS = createRealtimeVoiceAudioLevelStore();
|
|
314
|
+
const WAVEFORM_WEIGHTS = [0.55, 0.82, 1, 0.82, 0.55];
|
|
315
|
+
const AUDIO_ACTIVITY_THRESHOLD = 0.035;
|
|
316
|
+
|
|
317
|
+
function usePrefersReducedMotion(): boolean {
|
|
318
|
+
const [reduced, setReduced] = useState(false);
|
|
319
|
+
useEffect(() => {
|
|
320
|
+
if (typeof window === "undefined" || !window.matchMedia) return;
|
|
321
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
322
|
+
const update = () => setReduced(query.matches);
|
|
323
|
+
update();
|
|
324
|
+
query.addEventListener?.("change", update);
|
|
325
|
+
return () => query.removeEventListener?.("change", update);
|
|
326
|
+
}, []);
|
|
327
|
+
return reduced;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function VoiceWaveform({
|
|
331
|
+
level,
|
|
332
|
+
reducedMotion,
|
|
333
|
+
activity,
|
|
334
|
+
}: {
|
|
335
|
+
level: number;
|
|
336
|
+
reducedMotion: boolean;
|
|
337
|
+
activity: "idle" | "user" | "assistant";
|
|
338
|
+
}) {
|
|
339
|
+
const visibleLevel = reducedMotion ? 0.45 : level;
|
|
340
|
+
return (
|
|
341
|
+
<span
|
|
342
|
+
aria-hidden="true"
|
|
343
|
+
className="flex h-6 items-center justify-center gap-0.5"
|
|
344
|
+
data-realtime-voice-waveform="true"
|
|
345
|
+
data-realtime-voice-waveform-activity={activity}
|
|
346
|
+
>
|
|
347
|
+
{WAVEFORM_WEIGHTS.map((weight, index) => (
|
|
348
|
+
<span
|
|
349
|
+
key={index}
|
|
350
|
+
className="h-5 w-0.5 origin-center rounded-full bg-current transition-transform duration-75 ease-out motion-reduce:transition-none"
|
|
351
|
+
style={{
|
|
352
|
+
transform: `scaleY(${0.2 + visibleLevel * 0.8 * weight})`,
|
|
353
|
+
}}
|
|
354
|
+
/>
|
|
355
|
+
))}
|
|
356
|
+
</span>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function VoiceConnectingIndicator() {
|
|
361
|
+
return (
|
|
362
|
+
<IconLoader2
|
|
363
|
+
aria-hidden="true"
|
|
364
|
+
className="size-6 animate-spin motion-reduce:animate-none"
|
|
365
|
+
data-realtime-voice-connecting-indicator="true"
|
|
366
|
+
/>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const ORB_STATE_CLASSES: Record<RealtimeVoiceModeState, string> = {
|
|
371
|
+
connecting:
|
|
372
|
+
"bg-background/65 text-foreground ring-border/60 hover:bg-background/75",
|
|
373
|
+
listening:
|
|
374
|
+
"bg-background/65 text-foreground ring-border/60 hover:bg-background/75",
|
|
375
|
+
speaking:
|
|
376
|
+
"bg-background/65 text-foreground ring-border/60 hover:bg-background/75",
|
|
377
|
+
working:
|
|
378
|
+
"bg-background/65 text-foreground ring-border/60 hover:bg-background/75",
|
|
379
|
+
error:
|
|
380
|
+
"bg-destructive/20 text-destructive ring-destructive/30 hover:bg-destructive/25",
|
|
381
|
+
ending: "cursor-wait bg-background/65 text-muted-foreground ring-border/60",
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
function useChatPanelTranslation(chatVisible: boolean): number {
|
|
385
|
+
const [translation, setTranslation] = useState(0);
|
|
386
|
+
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!chatVisible || typeof window === "undefined") {
|
|
389
|
+
setTranslation(0);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const direction = window.getComputedStyle(
|
|
394
|
+
document.documentElement,
|
|
395
|
+
).direction;
|
|
396
|
+
const inlineEnd = direction === "rtl" ? "left" : "right";
|
|
397
|
+
|
|
398
|
+
const update = () => {
|
|
399
|
+
const panels = Array.from(
|
|
400
|
+
document.querySelectorAll<HTMLElement>(
|
|
401
|
+
`.agent-sidebar-panel[data-agent-sidebar-position="${inlineEnd}"][data-agent-sidebar-state="open"]`,
|
|
402
|
+
),
|
|
403
|
+
);
|
|
404
|
+
const panel = panels.find((candidate) => {
|
|
405
|
+
const rect = candidate.getBoundingClientRect();
|
|
406
|
+
return rect.width > 0 && rect.height > 0;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
if (!panel) {
|
|
410
|
+
setTranslation(0);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// A fullscreen chat has no unobscured side to move into. Keep the dock
|
|
415
|
+
// at its normal edge so it stays reachable instead of pinning it to the
|
|
416
|
+
// opposite side of the same overlay.
|
|
417
|
+
if (panel.dataset.agentSidebarLayout === "fullscreen") {
|
|
418
|
+
setTranslation(0);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const rect = panel.getBoundingClientRect();
|
|
423
|
+
const overlap =
|
|
424
|
+
inlineEnd === "right"
|
|
425
|
+
? Math.max(0, window.innerWidth - rect.left)
|
|
426
|
+
: Math.max(0, rect.right);
|
|
427
|
+
const maximumShift = Math.max(0, window.innerWidth - 96);
|
|
428
|
+
const distance = Math.min(overlap, maximumShift);
|
|
429
|
+
setTranslation(inlineEnd === "right" ? -distance : distance);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
update();
|
|
433
|
+
const frame = window.requestAnimationFrame(update);
|
|
434
|
+
window.addEventListener("resize", update);
|
|
435
|
+
|
|
436
|
+
const resizeObserver =
|
|
437
|
+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
|
438
|
+
const observedPanels = new WeakSet<HTMLElement>();
|
|
439
|
+
const observePanels = () => {
|
|
440
|
+
document
|
|
441
|
+
.querySelectorAll<HTMLElement>(".agent-sidebar-panel")
|
|
442
|
+
.forEach((panel) => {
|
|
443
|
+
if (!observedPanels.has(panel)) {
|
|
444
|
+
observedPanels.add(panel);
|
|
445
|
+
resizeObserver?.observe(panel);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
};
|
|
449
|
+
observePanels();
|
|
450
|
+
|
|
451
|
+
const mutationObserver = new MutationObserver(() => {
|
|
452
|
+
observePanels();
|
|
453
|
+
update();
|
|
454
|
+
});
|
|
455
|
+
mutationObserver.observe(document.body, {
|
|
456
|
+
attributes: true,
|
|
457
|
+
attributeFilter: [
|
|
458
|
+
"data-agent-sidebar-layout",
|
|
459
|
+
"data-agent-sidebar-state",
|
|
460
|
+
"style",
|
|
461
|
+
],
|
|
462
|
+
childList: true,
|
|
463
|
+
subtree: true,
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
return () => {
|
|
467
|
+
window.cancelAnimationFrame(frame);
|
|
468
|
+
window.removeEventListener("resize", update);
|
|
469
|
+
resizeObserver?.disconnect();
|
|
470
|
+
mutationObserver.disconnect();
|
|
471
|
+
};
|
|
472
|
+
}, [chatVisible]);
|
|
473
|
+
|
|
474
|
+
return translation;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function VoiceSettingRow({
|
|
478
|
+
icon: Icon,
|
|
479
|
+
setting,
|
|
480
|
+
onSelectOpenChange,
|
|
481
|
+
}: {
|
|
482
|
+
icon: typeof IconLanguage;
|
|
483
|
+
setting: RealtimeVoiceModeSelectSetting;
|
|
484
|
+
onSelectOpenChange: (open: boolean) => void;
|
|
485
|
+
}) {
|
|
486
|
+
const selected = setting.options.find(
|
|
487
|
+
(option) => option.value === setting.value,
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<Select
|
|
492
|
+
value={setting.value}
|
|
493
|
+
onValueChange={setting.onValueChange}
|
|
494
|
+
onOpenChange={onSelectOpenChange}
|
|
495
|
+
disabled={setting.disabled}
|
|
496
|
+
>
|
|
497
|
+
<SelectTrigger
|
|
498
|
+
aria-label={`${setting.label}: ${selected?.label ?? setting.value}`}
|
|
499
|
+
className="h-11 rounded-none border-0 bg-transparent px-3 shadow-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
500
|
+
>
|
|
501
|
+
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
502
|
+
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
|
503
|
+
<span className="shrink-0 text-sm font-medium text-foreground">
|
|
504
|
+
{setting.label}
|
|
505
|
+
</span>
|
|
506
|
+
<span className="ms-auto min-w-0 truncate text-end text-sm text-muted-foreground">
|
|
507
|
+
{selected?.label ?? setting.value}
|
|
508
|
+
</span>
|
|
509
|
+
</div>
|
|
510
|
+
</SelectTrigger>
|
|
511
|
+
<SelectContent
|
|
512
|
+
align="end"
|
|
513
|
+
className="min-w-56"
|
|
514
|
+
data-realtime-voice-setting-options="true"
|
|
515
|
+
>
|
|
516
|
+
<SelectGroup>
|
|
517
|
+
{setting.options.map((option) => (
|
|
518
|
+
<SelectItem key={option.value} value={option.value}>
|
|
519
|
+
<span className="grid gap-0.5">
|
|
520
|
+
<span>{option.label}</span>
|
|
521
|
+
{option.description ? (
|
|
522
|
+
<span className="text-xs text-muted-foreground">
|
|
523
|
+
{option.description}
|
|
524
|
+
</span>
|
|
525
|
+
) : null}
|
|
526
|
+
</span>
|
|
527
|
+
</SelectItem>
|
|
528
|
+
))}
|
|
529
|
+
</SelectGroup>
|
|
530
|
+
</SelectContent>
|
|
531
|
+
</Select>
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function VoiceInlineSettings({
|
|
536
|
+
settings,
|
|
537
|
+
disabled,
|
|
538
|
+
onSelectOpenChange,
|
|
539
|
+
}: {
|
|
540
|
+
settings: RealtimeVoiceModeInlineSettings;
|
|
541
|
+
disabled: boolean;
|
|
542
|
+
onSelectOpenChange: (open: boolean) => void;
|
|
543
|
+
}) {
|
|
544
|
+
const selectedVoice = settings.voiceStyle.options.find(
|
|
545
|
+
(option) => option.value === settings.voiceStyle.value,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
return (
|
|
549
|
+
<div data-realtime-voice-settings="true">
|
|
550
|
+
<h2 className="sr-only">{settings.dialogLabel}</h2>
|
|
551
|
+
<div className="grid gap-0.5 px-4 pb-3 pt-4 text-center">
|
|
552
|
+
<div className="truncate text-base font-semibold text-foreground">
|
|
553
|
+
{selectedVoice?.label ?? settings.voiceStyle.value}
|
|
554
|
+
</div>
|
|
555
|
+
{selectedVoice?.description ? (
|
|
556
|
+
<div className="truncate text-xs text-muted-foreground">
|
|
557
|
+
{selectedVoice.description}
|
|
558
|
+
</div>
|
|
559
|
+
) : null}
|
|
560
|
+
</div>
|
|
561
|
+
<div className="mx-2 mb-2 overflow-hidden rounded-xl border border-border/70 bg-background/70">
|
|
562
|
+
<VoiceSettingRow
|
|
563
|
+
icon={IconMicrophone}
|
|
564
|
+
setting={{
|
|
565
|
+
...settings.microphone,
|
|
566
|
+
disabled: disabled || settings.microphone.disabled,
|
|
567
|
+
}}
|
|
568
|
+
onSelectOpenChange={onSelectOpenChange}
|
|
569
|
+
/>
|
|
570
|
+
<div className="mx-3 h-px bg-border/70" />
|
|
571
|
+
<VoiceSettingRow
|
|
572
|
+
icon={IconLanguage}
|
|
573
|
+
setting={{
|
|
574
|
+
...settings.language,
|
|
575
|
+
disabled: disabled || settings.language.disabled,
|
|
576
|
+
}}
|
|
577
|
+
onSelectOpenChange={onSelectOpenChange}
|
|
578
|
+
/>
|
|
579
|
+
<div className="mx-3 h-px bg-border/70" />
|
|
580
|
+
<VoiceSettingRow
|
|
581
|
+
icon={IconBrain}
|
|
582
|
+
setting={{
|
|
583
|
+
...settings.intelligence,
|
|
584
|
+
disabled: disabled || settings.intelligence.disabled,
|
|
585
|
+
}}
|
|
586
|
+
onSelectOpenChange={onSelectOpenChange}
|
|
587
|
+
/>
|
|
588
|
+
<div className="mx-3 h-px bg-border/70" />
|
|
589
|
+
<VoiceSettingRow
|
|
590
|
+
icon={IconVolume}
|
|
591
|
+
setting={{
|
|
592
|
+
...settings.voiceStyle,
|
|
593
|
+
disabled: disabled || settings.voiceStyle.disabled,
|
|
594
|
+
}}
|
|
595
|
+
onSelectOpenChange={onSelectOpenChange}
|
|
596
|
+
/>
|
|
597
|
+
</div>
|
|
598
|
+
{settings.microphoneError ? (
|
|
599
|
+
<p className="px-4 pb-3 text-center text-xs text-destructive">
|
|
600
|
+
{settings.microphoneError}
|
|
601
|
+
</p>
|
|
602
|
+
) : null}
|
|
603
|
+
{settings.appliesNextConversationNote ? (
|
|
604
|
+
<p className="px-4 pb-3 text-center text-xs text-muted-foreground">
|
|
605
|
+
{settings.appliesNextConversationNote}
|
|
606
|
+
</p>
|
|
607
|
+
) : null}
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Persistent voice-session control. Toggling the main orb only changes chat
|
|
614
|
+
* visibility; ending the realtime session is intentionally a separate action.
|
|
615
|
+
*/
|
|
616
|
+
export function RealtimeVoiceModeDock({
|
|
617
|
+
state,
|
|
618
|
+
copy,
|
|
619
|
+
chatVisible,
|
|
620
|
+
audioLevels = SILENT_AUDIO_LEVELS,
|
|
621
|
+
onToggleChat,
|
|
622
|
+
onEndVoiceMode,
|
|
623
|
+
settings,
|
|
624
|
+
errorMessage,
|
|
625
|
+
className,
|
|
626
|
+
}: RealtimeVoiceModeDockProps) {
|
|
627
|
+
const statusId = useId();
|
|
628
|
+
const controlsId = useId();
|
|
629
|
+
const [controlsOpen, setControlsOpen] = useState(false);
|
|
630
|
+
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
631
|
+
const selectInteractionRef = useRef(false);
|
|
632
|
+
const selectInteractionFrameRef = useRef<number | null>(null);
|
|
633
|
+
const levels = useSyncExternalStore(
|
|
634
|
+
audioLevels.subscribe,
|
|
635
|
+
audioLevels.getSnapshot,
|
|
636
|
+
audioLevels.getSnapshot,
|
|
637
|
+
);
|
|
638
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
639
|
+
// Microphone metering begins while the SDP request is still in flight. Keep
|
|
640
|
+
// the connecting affordance authoritative until WebRTC is established;
|
|
641
|
+
// otherwise speaking into the mic replaces the loader with a waveform and
|
|
642
|
+
// makes a stalled connection look like a live call.
|
|
643
|
+
const connected =
|
|
644
|
+
state === "listening" || state === "speaking" || state === "working";
|
|
645
|
+
const activity = connected
|
|
646
|
+
? levels.output > AUDIO_ACTIVITY_THRESHOLD
|
|
647
|
+
? "assistant"
|
|
648
|
+
: levels.input > AUDIO_ACTIVITY_THRESHOLD
|
|
649
|
+
? "user"
|
|
650
|
+
: "idle"
|
|
651
|
+
: "idle";
|
|
652
|
+
const activityLevel = activity === "assistant" ? levels.output : levels.input;
|
|
653
|
+
const toggleLabel = chatVisible ? copy.hideChat : copy.showChat;
|
|
654
|
+
const ending = state === "ending";
|
|
655
|
+
const errorDetailVisible = state === "error" && Boolean(errorMessage);
|
|
656
|
+
const controlsVisible = controlsOpen || settingsOpen;
|
|
657
|
+
const chatPanelTranslation = useChatPanelTranslation(chatVisible);
|
|
658
|
+
|
|
659
|
+
const handleSelectOpenChange = useCallback((open: boolean) => {
|
|
660
|
+
if (selectInteractionFrameRef.current !== null) {
|
|
661
|
+
window.cancelAnimationFrame(selectInteractionFrameRef.current);
|
|
662
|
+
selectInteractionFrameRef.current = null;
|
|
663
|
+
}
|
|
664
|
+
selectInteractionRef.current = true;
|
|
665
|
+
if (!open) {
|
|
666
|
+
// Radix closes the portalled Select before its focus/outside events have
|
|
667
|
+
// fully settled. Keep the parent protected through the current frame.
|
|
668
|
+
selectInteractionFrameRef.current = window.requestAnimationFrame(() => {
|
|
669
|
+
selectInteractionRef.current = false;
|
|
670
|
+
selectInteractionFrameRef.current = null;
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
}, []);
|
|
674
|
+
|
|
675
|
+
useEffect(
|
|
676
|
+
() => () => {
|
|
677
|
+
if (selectInteractionFrameRef.current !== null) {
|
|
678
|
+
window.cancelAnimationFrame(selectInteractionFrameRef.current);
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
[],
|
|
682
|
+
);
|
|
683
|
+
|
|
684
|
+
const closeControlsUnlessFocused = (event: MouseEvent<HTMLDivElement>) => {
|
|
685
|
+
if (settingsOpen) return;
|
|
686
|
+
if (!event.currentTarget.contains(document.activeElement)) {
|
|
687
|
+
setControlsOpen(false);
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<div
|
|
693
|
+
className={cn(
|
|
694
|
+
"pointer-events-none fixed bottom-4 end-4 flex max-w-[calc(100vw-2rem)] flex-col items-end gap-2 transition-transform duration-200 ease-[var(--ease-collapse)] motion-reduce:transition-none",
|
|
695
|
+
className,
|
|
696
|
+
)}
|
|
697
|
+
style={{
|
|
698
|
+
zIndex: 270,
|
|
699
|
+
transform: `translateX(${chatPanelTranslation}px)`,
|
|
700
|
+
}}
|
|
701
|
+
data-realtime-voice-state={state}
|
|
702
|
+
data-realtime-voice-activity={activity}
|
|
703
|
+
data-realtime-voice-chat-offset={chatPanelTranslation}
|
|
704
|
+
>
|
|
705
|
+
{errorDetailVisible ? (
|
|
706
|
+
<div
|
|
707
|
+
role="alert"
|
|
708
|
+
className="pointer-events-auto max-w-xs rounded-lg border border-destructive/30 bg-background px-3 py-2 text-sm text-destructive shadow-md"
|
|
709
|
+
>
|
|
710
|
+
{errorMessage}
|
|
711
|
+
</div>
|
|
712
|
+
) : null}
|
|
713
|
+
|
|
714
|
+
<div
|
|
715
|
+
className="group pointer-events-auto flex items-center gap-2"
|
|
716
|
+
onMouseEnter={() => setControlsOpen(true)}
|
|
717
|
+
onMouseLeave={closeControlsUnlessFocused}
|
|
718
|
+
onFocusCapture={() => setControlsOpen(true)}
|
|
719
|
+
onBlurCapture={(event) => {
|
|
720
|
+
if (
|
|
721
|
+
!event.currentTarget.contains(event.relatedTarget as Node | null)
|
|
722
|
+
) {
|
|
723
|
+
setControlsOpen(false);
|
|
724
|
+
}
|
|
725
|
+
}}
|
|
726
|
+
>
|
|
727
|
+
<div
|
|
728
|
+
id={controlsId}
|
|
729
|
+
data-realtime-voice-controls={controlsVisible ? "open" : "closed"}
|
|
730
|
+
className={cn(
|
|
731
|
+
"flex items-center gap-1 rounded-full border border-border/70 bg-background/95 p-1 shadow-lg backdrop-blur-md transition-[transform,opacity] duration-150 ease-out motion-reduce:transition-none",
|
|
732
|
+
controlsVisible
|
|
733
|
+
? "pointer-events-auto translate-x-0 opacity-100"
|
|
734
|
+
: "pointer-events-none opacity-0 ltr:translate-x-2 rtl:-translate-x-2 group-hover:pointer-events-auto group-hover:translate-x-0 group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:translate-x-0 group-focus-within:opacity-100",
|
|
735
|
+
)}
|
|
736
|
+
>
|
|
737
|
+
<span
|
|
738
|
+
id={statusId}
|
|
739
|
+
role={state === "error" && !errorDetailVisible ? "alert" : "status"}
|
|
740
|
+
aria-live={
|
|
741
|
+
state === "error" && !errorDetailVisible ? "assertive" : "polite"
|
|
742
|
+
}
|
|
743
|
+
className="sr-only"
|
|
744
|
+
>
|
|
745
|
+
{copy.status[state]}
|
|
746
|
+
</span>
|
|
747
|
+
|
|
748
|
+
{settings ? (
|
|
749
|
+
<Popover open={settingsOpen} onOpenChange={setSettingsOpen}>
|
|
750
|
+
<Tooltip>
|
|
751
|
+
<TooltipTrigger asChild>
|
|
752
|
+
<PopoverAnchor asChild>
|
|
753
|
+
<Button
|
|
754
|
+
type="button"
|
|
755
|
+
variant="ghost"
|
|
756
|
+
size="icon"
|
|
757
|
+
disabled={ending}
|
|
758
|
+
onClick={() => setSettingsOpen((open) => !open)}
|
|
759
|
+
aria-label={copy.voiceSettings}
|
|
760
|
+
aria-expanded={settingsOpen}
|
|
761
|
+
className="size-8 rounded-full text-muted-foreground transition-transform duration-150 ease-out active:scale-[0.97]"
|
|
762
|
+
>
|
|
763
|
+
<IconSettings />
|
|
764
|
+
</Button>
|
|
765
|
+
</PopoverAnchor>
|
|
766
|
+
</TooltipTrigger>
|
|
767
|
+
<TooltipContent>{copy.voiceSettings}</TooltipContent>
|
|
768
|
+
</Tooltip>
|
|
769
|
+
<PopoverContent
|
|
770
|
+
side="top"
|
|
771
|
+
align="end"
|
|
772
|
+
sideOffset={10}
|
|
773
|
+
className="w-[min(20rem,calc(100vw-2rem))] overflow-hidden p-0"
|
|
774
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
775
|
+
onInteractOutside={(event) => {
|
|
776
|
+
const target = event.target;
|
|
777
|
+
if (
|
|
778
|
+
selectInteractionRef.current ||
|
|
779
|
+
(target instanceof Element &&
|
|
780
|
+
target.closest(
|
|
781
|
+
'[data-realtime-voice-setting-options="true"]',
|
|
782
|
+
))
|
|
783
|
+
) {
|
|
784
|
+
event.preventDefault();
|
|
785
|
+
}
|
|
786
|
+
}}
|
|
787
|
+
>
|
|
788
|
+
<VoiceInlineSettings
|
|
789
|
+
settings={settings}
|
|
790
|
+
disabled={ending}
|
|
791
|
+
onSelectOpenChange={handleSelectOpenChange}
|
|
792
|
+
/>
|
|
793
|
+
</PopoverContent>
|
|
794
|
+
</Popover>
|
|
795
|
+
) : null}
|
|
796
|
+
|
|
797
|
+
<Tooltip>
|
|
798
|
+
<TooltipTrigger asChild>
|
|
799
|
+
<Button
|
|
800
|
+
type="button"
|
|
801
|
+
variant="ghost"
|
|
802
|
+
size="icon"
|
|
803
|
+
disabled={ending}
|
|
804
|
+
onClick={onEndVoiceMode}
|
|
805
|
+
aria-label={copy.endVoiceMode}
|
|
806
|
+
className="size-8 rounded-full text-destructive transition-transform duration-150 ease-out hover:bg-destructive/10 hover:text-destructive active:scale-[0.97]"
|
|
807
|
+
>
|
|
808
|
+
<IconPhoneOff />
|
|
809
|
+
</Button>
|
|
810
|
+
</TooltipTrigger>
|
|
811
|
+
<TooltipContent>{copy.endVoiceMode}</TooltipContent>
|
|
812
|
+
</Tooltip>
|
|
813
|
+
</div>
|
|
814
|
+
|
|
815
|
+
<Tooltip>
|
|
816
|
+
<TooltipTrigger asChild>
|
|
817
|
+
<Button
|
|
818
|
+
type="button"
|
|
819
|
+
size="icon"
|
|
820
|
+
disabled={ending}
|
|
821
|
+
onClick={() => {
|
|
822
|
+
setControlsOpen(true);
|
|
823
|
+
onToggleChat();
|
|
824
|
+
}}
|
|
825
|
+
aria-label={toggleLabel}
|
|
826
|
+
aria-pressed={chatVisible}
|
|
827
|
+
data-realtime-voice-chat-toggle="orb"
|
|
828
|
+
aria-describedby={statusId}
|
|
829
|
+
aria-controls={controlsId}
|
|
830
|
+
aria-expanded={controlsVisible}
|
|
831
|
+
className={cn(
|
|
832
|
+
"relative isolate size-16 overflow-visible rounded-full ring-1 backdrop-blur-xl transition-transform duration-150 ease-out focus-visible:ring-offset-2 active:scale-[0.97] motion-reduce:transition-none",
|
|
833
|
+
ORB_STATE_CLASSES[state],
|
|
834
|
+
)}
|
|
835
|
+
>
|
|
836
|
+
<span className="relative z-10 flex items-center justify-center">
|
|
837
|
+
{state === "connecting" ? (
|
|
838
|
+
<VoiceConnectingIndicator />
|
|
839
|
+
) : state !== "error" ? (
|
|
840
|
+
<VoiceWaveform
|
|
841
|
+
level={activityLevel}
|
|
842
|
+
reducedMotion={reducedMotion}
|
|
843
|
+
activity={activity}
|
|
844
|
+
/>
|
|
845
|
+
) : (
|
|
846
|
+
<IconAlertTriangle />
|
|
847
|
+
)}
|
|
848
|
+
</span>
|
|
849
|
+
</Button>
|
|
850
|
+
</TooltipTrigger>
|
|
851
|
+
<TooltipContent>{toggleLabel}</TooltipContent>
|
|
852
|
+
</Tooltip>
|
|
853
|
+
</div>
|
|
854
|
+
</div>
|
|
855
|
+
);
|
|
856
|
+
}
|