@agent-native/toolkit 0.6.0 → 0.7.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.
Files changed (173) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/package.json +17 -1
  16. package/src/app-shell/header-actions.tsx +84 -0
  17. package/src/app-shell/index.ts +1 -0
  18. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  19. package/src/chat-history/ChatHistoryList.tsx +469 -0
  20. package/src/chat-history/index.ts +7 -0
  21. package/src/chat-history.css +314 -0
  22. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  23. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  24. package/src/collab-ui/PresenceBar.tsx +346 -0
  25. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  26. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  27. package/src/collab-ui/agent-identity.ts +16 -0
  28. package/src/collab-ui/index.ts +39 -0
  29. package/src/collab-ui/lead-client.ts +32 -0
  30. package/src/collab-ui/types.spec.ts +18 -0
  31. package/src/collab-ui/types.ts +96 -0
  32. package/src/composer/AgentComposerFrame.tsx +59 -0
  33. package/src/composer/ComposerPlusMenu.tsx +983 -0
  34. package/src/composer/MentionPopover.tsx +412 -0
  35. package/src/composer/PastedTextChip.tsx +150 -0
  36. package/src/composer/PromptComposer.spec.ts +73 -0
  37. package/src/composer/PromptComposer.tsx +718 -0
  38. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  39. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  40. package/src/composer/TiptapComposer.spec.ts +390 -0
  41. package/src/composer/TiptapComposer.tsx +2936 -0
  42. package/src/composer/VoiceButton.spec.ts +33 -0
  43. package/src/composer/VoiceButton.tsx +255 -0
  44. package/src/composer/asset-picker-url.spec.ts +56 -0
  45. package/src/composer/asset-picker-url.ts +80 -0
  46. package/src/composer/attachment-accept.spec.ts +22 -0
  47. package/src/composer/attachment-accept.ts +71 -0
  48. package/src/composer/draft-key.spec.ts +19 -0
  49. package/src/composer/draft-key.ts +7 -0
  50. package/src/composer/extensions/FileReference.tsx +55 -0
  51. package/src/composer/extensions/MentionReference.tsx +93 -0
  52. package/src/composer/extensions/SkillReference.tsx +56 -0
  53. package/src/composer/index.ts +79 -0
  54. package/src/composer/pasted-text.spec.ts +162 -0
  55. package/src/composer/pasted-text.ts +160 -0
  56. package/src/composer/prompt-attachments.spec.ts +80 -0
  57. package/src/composer/prompt-attachments.ts +93 -0
  58. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  59. package/src/composer/realtime-voice-audio-level.ts +64 -0
  60. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  61. package/src/composer/realtime-voice-transcript.ts +134 -0
  62. package/src/composer/runtime-adapters.tsx +343 -0
  63. package/src/composer/types.ts +66 -0
  64. package/src/composer/use-file-search.ts +64 -0
  65. package/src/composer/use-mention-search.ts +90 -0
  66. package/src/composer/use-skills.ts +45 -0
  67. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  68. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  69. package/src/composer/useVoiceDictation.spec.ts +45 -0
  70. package/src/composer/useVoiceDictation.ts +1191 -0
  71. package/src/context-ui/ContextMeter.tsx +101 -0
  72. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  73. package/src/context-ui/ContextTreemap.tsx +125 -0
  74. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  75. package/src/context-ui/format.ts +42 -0
  76. package/src/context-ui/index.ts +15 -0
  77. package/src/context-ui/types.ts +43 -0
  78. package/src/design-tweaks/index.ts +1 -0
  79. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  80. package/src/editor/BubbleToolbar.tsx +253 -0
  81. package/src/editor/CodeBlockNode.tsx +254 -0
  82. package/src/editor/DragHandle.spec.ts +588 -0
  83. package/src/editor/DragHandle.ts +1437 -0
  84. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  85. package/src/editor/ImageExtension.ts +306 -0
  86. package/src/editor/RegistryBlockContext.tsx +107 -0
  87. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  88. package/src/editor/RegistryBlockNode.tsx +652 -0
  89. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  90. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  91. package/src/editor/RichMarkdownEditor.tsx +81 -0
  92. package/src/editor/RunId.ts +61 -0
  93. package/src/editor/SharedRichEditor.tsx +321 -0
  94. package/src/editor/SlashCommandMenu.tsx +310 -0
  95. package/src/editor/disableHistory.spec.ts +85 -0
  96. package/src/editor/extensions.ts +402 -0
  97. package/src/editor/gfmDoc.spec.ts +230 -0
  98. package/src/editor/gfmDoc.ts +92 -0
  99. package/src/editor/index.ts +93 -0
  100. package/src/editor/registrySlashCommands.ts +131 -0
  101. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  102. package/src/editor/surgical-apply.spec.ts +279 -0
  103. package/src/editor/surgical-apply.ts +192 -0
  104. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  105. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  106. package/src/editor/useCollabReconcile.ts +589 -0
  107. package/src/editor.css +510 -0
  108. package/src/hooks/index.ts +2 -0
  109. package/src/hooks/use-mobile.tsx +21 -0
  110. package/src/hooks/use-toast.ts +188 -0
  111. package/src/index.ts +8 -0
  112. package/src/onboarding/index.ts +1 -0
  113. package/src/provider.tsx +47 -0
  114. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  115. package/src/sharing/VisibilityBadge.tsx +37 -0
  116. package/src/sharing/index.ts +4 -0
  117. package/src/styles.css +21 -0
  118. package/src/ui/accordion.tsx +56 -0
  119. package/src/ui/alert-dialog.tsx +141 -0
  120. package/src/ui/alert.tsx +59 -0
  121. package/src/ui/aspect-ratio.tsx +5 -0
  122. package/src/ui/avatar.tsx +48 -0
  123. package/src/ui/badge.tsx +37 -0
  124. package/src/ui/breadcrumb.tsx +115 -0
  125. package/src/ui/button.tsx +78 -0
  126. package/src/ui/calendar.tsx +214 -0
  127. package/src/ui/card.tsx +86 -0
  128. package/src/ui/carousel.tsx +260 -0
  129. package/src/ui/chart.tsx +375 -0
  130. package/src/ui/checkbox.tsx +28 -0
  131. package/src/ui/collapsible.tsx +9 -0
  132. package/src/ui/command.spec.tsx +51 -0
  133. package/src/ui/command.tsx +174 -0
  134. package/src/ui/context-menu.tsx +202 -0
  135. package/src/ui/date-picker.tsx +64 -0
  136. package/src/ui/dialog.spec.tsx +43 -0
  137. package/src/ui/dialog.tsx +146 -0
  138. package/src/ui/drawer.tsx +126 -0
  139. package/src/ui/dropdown-menu.tsx +218 -0
  140. package/src/ui/form.tsx +177 -0
  141. package/src/ui/hover-card.tsx +27 -0
  142. package/src/ui/index.ts +52 -0
  143. package/src/ui/input-otp.tsx +69 -0
  144. package/src/ui/input.tsx +22 -0
  145. package/src/ui/label.tsx +24 -0
  146. package/src/ui/logo.tsx +36 -0
  147. package/src/ui/menubar.tsx +235 -0
  148. package/src/ui/navigation-menu.tsx +128 -0
  149. package/src/ui/pagination.tsx +121 -0
  150. package/src/ui/popover.tsx +44 -0
  151. package/src/ui/progress.tsx +26 -0
  152. package/src/ui/radio-group.tsx +42 -0
  153. package/src/ui/resizable.tsx +43 -0
  154. package/src/ui/scroll-area.tsx +46 -0
  155. package/src/ui/select.tsx +158 -0
  156. package/src/ui/separator.tsx +29 -0
  157. package/src/ui/sheet.tsx +160 -0
  158. package/src/ui/sidebar.tsx +777 -0
  159. package/src/ui/skeleton.tsx +18 -0
  160. package/src/ui/slider.tsx +26 -0
  161. package/src/ui/sonner.tsx +58 -0
  162. package/src/ui/spinner.tsx +17 -0
  163. package/src/ui/switch.tsx +27 -0
  164. package/src/ui/table.tsx +117 -0
  165. package/src/ui/tabs.tsx +53 -0
  166. package/src/ui/textarea.tsx +23 -0
  167. package/src/ui/toast.tsx +127 -0
  168. package/src/ui/toaster.tsx +33 -0
  169. package/src/ui/toggle-group.tsx +59 -0
  170. package/src/ui/toggle.tsx +43 -0
  171. package/src/ui/tooltip.tsx +53 -0
  172. package/src/ui/use-toast.ts +3 -0
  173. package/src/utils.ts +8 -0
@@ -0,0 +1,771 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { TooltipProvider } from "../ui/tooltip.js";
8
+ import { createRealtimeVoiceAudioLevelStore } from "./realtime-voice-audio-level.js";
9
+ import {
10
+ RealtimeVoiceModeDock,
11
+ RealtimeVoiceModeEntry,
12
+ type RealtimeVoiceModeCopy,
13
+ } from "./RealtimeVoiceMode.js";
14
+
15
+ const copy: RealtimeVoiceModeCopy = {
16
+ entryButtonLabel: "Use microphone",
17
+ promptTitle: "Talk to your app",
18
+ promptDescription:
19
+ "Voice mode keeps listening while the agent navigates and takes actions.",
20
+ setupTitle: "Set up voice mode",
21
+ setupDescription:
22
+ "Connect Builder.io to use managed voice with free credits, or add your own keys.",
23
+ connectBuilder: "Connect Builder.io",
24
+ useOpenAiKey: "Add your own keys",
25
+ startWithOpenAiKey: "Start with OpenAI key",
26
+ startVoiceMode: "Start voice mode",
27
+ keepDictating: "Keep dictating",
28
+ showChat: "Show chat",
29
+ hideChat: "Hide chat",
30
+ endVoiceMode: "End voice mode",
31
+ voiceSettings: "Voice settings",
32
+ settings: {
33
+ microphone: "Microphone",
34
+ defaultMicrophone: "System default",
35
+ microphoneSwitchFailed: "Could not switch microphones.",
36
+ language: "Language",
37
+ autoLanguage: "Auto",
38
+ languages: {
39
+ en: "English",
40
+ es: "Spanish",
41
+ fr: "French",
42
+ de: "German",
43
+ it: "Italian",
44
+ pt: "Portuguese",
45
+ ja: "Japanese",
46
+ ko: "Korean",
47
+ zh: "Chinese",
48
+ },
49
+ intelligence: "Intelligence",
50
+ intelligenceLevels: {
51
+ instant: "Instant",
52
+ balanced: "Balanced",
53
+ deep: "Deep",
54
+ },
55
+ voiceStyle: "Voice style",
56
+ voiceChangePending: "Voice changes apply to the next conversation.",
57
+ voiceDescriptions: {
58
+ marin: "Warm and natural",
59
+ cedar: "Clear and grounded",
60
+ coral: "Friendly and bright",
61
+ sage: "Calm and thoughtful",
62
+ verse: "Expressive and versatile",
63
+ alloy: "Balanced and neutral",
64
+ ash: "Smooth and confident",
65
+ ballad: "Warm and expressive",
66
+ echo: "Clear and direct",
67
+ shimmer: "Light and upbeat",
68
+ },
69
+ },
70
+ status: {
71
+ connecting: "Connecting",
72
+ listening: "Listening",
73
+ speaking: "Speaking",
74
+ working: "Working",
75
+ error: "Voice mode needs attention",
76
+ ending: "Ending voice mode",
77
+ },
78
+ errors: {
79
+ unsupported: "Unsupported",
80
+ responseFailed: "Response failed",
81
+ sessionFailed: "Session failed",
82
+ channelDisconnected: "Channel disconnected",
83
+ connectionFailed: "Connection failed",
84
+ offerFailed: "Offer failed",
85
+ },
86
+ };
87
+
88
+ describe("RealtimeVoiceMode", () => {
89
+ let container: HTMLDivElement;
90
+ let root: Root;
91
+
92
+ beforeEach(() => {
93
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
94
+ container = document.createElement("div");
95
+ document.body.appendChild(container);
96
+ root = createRoot(container);
97
+ });
98
+
99
+ afterEach(() => {
100
+ act(() => root.unmount());
101
+ container.remove();
102
+ vi.unstubAllGlobals();
103
+ });
104
+
105
+ const render = (node: React.ReactNode) => {
106
+ act(() => {
107
+ root.render(<TooltipProvider>{node}</TooltipProvider>);
108
+ });
109
+ };
110
+
111
+ it("offers voice mode before starting the existing dictation path", () => {
112
+ const onStartVoiceMode = vi.fn();
113
+ const onKeepDictating = vi.fn();
114
+
115
+ render(
116
+ <RealtimeVoiceModeEntry
117
+ copy={copy}
118
+ onStartVoiceMode={onStartVoiceMode}
119
+ onKeepDictating={onKeepDictating}
120
+ />,
121
+ );
122
+
123
+ const microphone = document.querySelector<HTMLButtonElement>(
124
+ 'button[aria-label="Use microphone"]',
125
+ );
126
+ expect(microphone?.getAttribute("aria-expanded")).toBe("false");
127
+
128
+ act(() => microphone?.click());
129
+
130
+ expect(document.body.textContent).toContain("Talk to your app");
131
+ const prompt = document.querySelector<HTMLElement>('[role="dialog"]');
132
+ expect(prompt?.className).toContain(
133
+ "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,22rem),22rem)]",
134
+ );
135
+ expect(document.body.textContent).toContain("Keep dictating");
136
+ expect(onStartVoiceMode).not.toHaveBeenCalled();
137
+ expect(onKeepDictating).not.toHaveBeenCalled();
138
+
139
+ const startVoiceMode = Array.from(
140
+ document.querySelectorAll<HTMLButtonElement>("button"),
141
+ ).find((button) => button.textContent?.includes("Start voice mode"));
142
+ act(() => startVoiceMode?.click());
143
+
144
+ expect(onStartVoiceMode).toHaveBeenCalledOnce();
145
+ expect(onKeepDictating).not.toHaveBeenCalled();
146
+ expect(document.body.textContent).not.toContain("Talk to your app");
147
+ });
148
+
149
+ it("does not start realtime voice while provider readiness is unresolved", () => {
150
+ const onStartVoiceMode = vi.fn();
151
+ const onKeepDictating = vi.fn();
152
+
153
+ render(
154
+ <RealtimeVoiceModeEntry
155
+ copy={copy}
156
+ open
157
+ providerStatusPending
158
+ onStartVoiceMode={onStartVoiceMode}
159
+ onKeepDictating={onKeepDictating}
160
+ />,
161
+ );
162
+
163
+ const startVoiceMode = Array.from(
164
+ document.querySelectorAll<HTMLButtonElement>("button"),
165
+ ).find((button) => button.textContent?.includes("Start voice mode"));
166
+ expect(startVoiceMode?.disabled).toBe(true);
167
+ act(() => startVoiceMode?.click());
168
+ expect(onStartVoiceMode).not.toHaveBeenCalled();
169
+
170
+ const keepDictating = Array.from(
171
+ document.querySelectorAll<HTMLButtonElement>("button"),
172
+ ).find((button) => button.textContent === "Keep dictating");
173
+ expect(keepDictating?.disabled).toBe(false);
174
+ });
175
+
176
+ it("keeps editable dictation available from the prompt", () => {
177
+ const onStartVoiceMode = vi.fn();
178
+ const onKeepDictating = vi.fn();
179
+
180
+ render(
181
+ <RealtimeVoiceModeEntry
182
+ copy={copy}
183
+ open
184
+ onStartVoiceMode={onStartVoiceMode}
185
+ onKeepDictating={onKeepDictating}
186
+ />,
187
+ );
188
+
189
+ const keepDictating = Array.from(
190
+ document.querySelectorAll<HTMLButtonElement>("button"),
191
+ ).find((button) => button.textContent === "Keep dictating");
192
+ act(() => keepDictating?.click());
193
+
194
+ expect(onKeepDictating).toHaveBeenCalledOnce();
195
+ expect(onStartVoiceMode).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("makes Builder the primary setup action and own keys the secondary", () => {
199
+ const onConnectBuilder = vi.fn();
200
+ const onUseOpenAiKey = vi.fn();
201
+
202
+ render(
203
+ <div className="agent-panel-root">
204
+ <RealtimeVoiceModeEntry
205
+ copy={copy}
206
+ open
207
+ setupRequired
208
+ onStartVoiceMode={vi.fn()}
209
+ onKeepDictating={vi.fn()}
210
+ onConnectBuilder={onConnectBuilder}
211
+ onUseOpenAiKey={onUseOpenAiKey}
212
+ />
213
+ </div>,
214
+ );
215
+
216
+ expect(document.body.textContent).toContain("Set up voice mode");
217
+ const prompt = document.querySelector<HTMLElement>('[role="dialog"]');
218
+ expect(prompt?.className).toContain(
219
+ "w-[min(calc(100vw-2rem),var(--radix-popover-content-available-width,30rem),30rem)]",
220
+ );
221
+ expect(prompt?.dataset.collisionBoundary).toBe("agent-panel");
222
+ const actions = Array.from(prompt?.querySelectorAll("div") ?? []).find(
223
+ (element) => element.className.includes("sm:flex-row"),
224
+ );
225
+ const actionClasses = actions?.className.split(/\s+/) ?? [];
226
+ expect(actionClasses).toContain("sm:flex-wrap");
227
+ expect(actionClasses).not.toContain("sm:flex-nowrap");
228
+ const buttons = Array.from(
229
+ document.querySelectorAll<HTMLButtonElement>("button"),
230
+ );
231
+ expect(
232
+ buttons.find((button) => button.textContent === "Connect Builder.io")
233
+ ?.className,
234
+ ).toContain("whitespace-nowrap");
235
+ act(() =>
236
+ buttons
237
+ .find((button) => button.textContent === "Connect Builder.io")
238
+ ?.click(),
239
+ );
240
+ expect(onConnectBuilder).toHaveBeenCalledOnce();
241
+ expect(onUseOpenAiKey).not.toHaveBeenCalled();
242
+ });
243
+
244
+ it("toggles chat without ending the voice session", () => {
245
+ const onToggleChat = vi.fn();
246
+ const onEndVoiceMode = vi.fn();
247
+
248
+ render(
249
+ <RealtimeVoiceModeDock
250
+ state="listening"
251
+ copy={copy}
252
+ chatVisible={false}
253
+ onToggleChat={onToggleChat}
254
+ onEndVoiceMode={onEndVoiceMode}
255
+ />,
256
+ );
257
+
258
+ const status = document.querySelector('[role="status"]');
259
+ expect(status?.textContent).toBe("Listening");
260
+ expect(status?.className).toContain("sr-only");
261
+ const toggleChat = document.querySelector<HTMLButtonElement>(
262
+ 'button[data-realtime-voice-chat-toggle="orb"]',
263
+ );
264
+ expect(
265
+ document.querySelector(
266
+ 'button[data-realtime-voice-chat-toggle="controls"]',
267
+ ),
268
+ ).toBeNull();
269
+ expect(toggleChat?.getAttribute("aria-pressed")).toBe("false");
270
+ expect(toggleChat?.getAttribute("aria-expanded")).toBe("false");
271
+ expect(toggleChat?.className).toContain("backdrop-blur-xl");
272
+ expect(toggleChat?.className).not.toContain("shadow-lg");
273
+ expect(toggleChat?.querySelector(".blur-md")).toBeNull();
274
+ expect(document.querySelector('[class*="bg-gradient"]')).toBeNull();
275
+ expect(
276
+ document
277
+ .querySelector("[data-realtime-voice-controls]")
278
+ ?.getAttribute("data-realtime-voice-controls"),
279
+ ).toBe("closed");
280
+
281
+ act(() => toggleChat?.click());
282
+
283
+ expect(onToggleChat).toHaveBeenCalledOnce();
284
+ expect(onEndVoiceMode).not.toHaveBeenCalled();
285
+ expect(toggleChat?.getAttribute("aria-expanded")).toBe("true");
286
+ expect(
287
+ document
288
+ .querySelector("[data-realtime-voice-controls]")
289
+ ?.getAttribute("data-realtime-voice-controls"),
290
+ ).toBe("open");
291
+ });
292
+
293
+ it("progressively discloses the end-session control", () => {
294
+ const onEndVoiceMode = vi.fn();
295
+
296
+ render(
297
+ <RealtimeVoiceModeDock
298
+ state="listening"
299
+ copy={copy}
300
+ chatVisible={false}
301
+ onToggleChat={vi.fn()}
302
+ onEndVoiceMode={onEndVoiceMode}
303
+ />,
304
+ );
305
+
306
+ const endVoiceMode = document.querySelector<HTMLButtonElement>(
307
+ 'button[aria-label="End voice mode"]',
308
+ );
309
+
310
+ act(() => endVoiceMode?.click());
311
+ expect(onEndVoiceMode).toHaveBeenCalledOnce();
312
+ });
313
+
314
+ it("opens compact voice settings without leaving or ending the session", () => {
315
+ const onLanguageChange = vi.fn();
316
+ const onIntelligenceChange = vi.fn();
317
+ const onVoiceChange = vi.fn();
318
+
319
+ render(
320
+ <RealtimeVoiceModeDock
321
+ state="listening"
322
+ copy={copy}
323
+ chatVisible={false}
324
+ onToggleChat={vi.fn()}
325
+ onEndVoiceMode={vi.fn()}
326
+ settings={{
327
+ dialogLabel: "Voice settings",
328
+ appliesNextConversationNote:
329
+ "Voice changes apply to the next conversation.",
330
+ microphone: {
331
+ label: "Microphone",
332
+ value: "default",
333
+ options: [
334
+ { value: "default", label: "System default" },
335
+ { value: "studio", label: "Studio microphone" },
336
+ ],
337
+ onValueChange: vi.fn(),
338
+ },
339
+ language: {
340
+ label: "Language",
341
+ value: "en",
342
+ options: [
343
+ { value: "auto", label: "Auto" },
344
+ { value: "en", label: "English" },
345
+ ],
346
+ onValueChange: onLanguageChange,
347
+ },
348
+ intelligence: {
349
+ label: "Intelligence",
350
+ value: "instant",
351
+ options: [
352
+ { value: "instant", label: "Instant" },
353
+ { value: "balanced", label: "Balanced" },
354
+ ],
355
+ onValueChange: onIntelligenceChange,
356
+ },
357
+ voiceStyle: {
358
+ label: "Voice style",
359
+ value: "marin",
360
+ options: [
361
+ {
362
+ value: "marin",
363
+ label: "Marin",
364
+ description: "Warm and conversational",
365
+ },
366
+ { value: "cedar", label: "Cedar" },
367
+ ],
368
+ onValueChange: onVoiceChange,
369
+ },
370
+ }}
371
+ />,
372
+ );
373
+
374
+ const settingsButton = document.querySelector<HTMLButtonElement>(
375
+ 'button[aria-label="Voice settings"]',
376
+ );
377
+ act(() => settingsButton?.click());
378
+
379
+ expect(settingsButton?.getAttribute("aria-expanded")).toBe("true");
380
+ expect(
381
+ document.querySelector('[data-realtime-voice-settings="true"]'),
382
+ ).not.toBeNull();
383
+ expect(document.body.textContent).toContain("Warm and conversational");
384
+ expect(document.body.textContent).toContain(
385
+ "Voice changes apply to the next conversation.",
386
+ );
387
+ expect(
388
+ Array.from(document.querySelectorAll('[role="combobox"]')).map(
389
+ (element) => element.getAttribute("aria-label"),
390
+ ),
391
+ ).toEqual([
392
+ "Microphone: System default",
393
+ "Language: English",
394
+ "Intelligence: Instant",
395
+ "Voice style: Marin",
396
+ ]);
397
+ expect(document.activeElement).not.toBe(
398
+ document.querySelector('[role="combobox"]'),
399
+ );
400
+
401
+ const language = document.querySelector<HTMLElement>(
402
+ '[role="combobox"][aria-label="Language: English"]',
403
+ );
404
+ expect(language?.className).toContain("focus-visible:ring-2");
405
+ act(() => {
406
+ language?.dispatchEvent(
407
+ new PointerEvent("pointerdown", {
408
+ bubbles: true,
409
+ button: 0,
410
+ pointerType: "mouse",
411
+ }),
412
+ );
413
+ });
414
+ const auto = Array.from(
415
+ document.querySelectorAll<HTMLElement>('[role="option"]'),
416
+ ).find((option) => option.textContent?.includes("Auto"));
417
+ expect(auto).toBeDefined();
418
+ const settingsPanel = document.querySelector<HTMLElement>(
419
+ '[data-realtime-voice-settings="true"]',
420
+ );
421
+ act(() => {
422
+ settingsPanel?.dispatchEvent(
423
+ new PointerEvent("pointerdown", {
424
+ bubbles: true,
425
+ button: 0,
426
+ pointerType: "mouse",
427
+ }),
428
+ );
429
+ settingsPanel?.dispatchEvent(
430
+ new PointerEvent("pointerup", {
431
+ bubbles: true,
432
+ button: 0,
433
+ pointerType: "mouse",
434
+ }),
435
+ );
436
+ });
437
+ expect(settingsButton?.getAttribute("aria-expanded")).toBe("true");
438
+ expect(
439
+ document.querySelector('[data-realtime-voice-settings="true"]'),
440
+ ).not.toBeNull();
441
+
442
+ act(() => {
443
+ language?.dispatchEvent(
444
+ new PointerEvent("pointerdown", {
445
+ bubbles: true,
446
+ button: 0,
447
+ pointerType: "mouse",
448
+ }),
449
+ );
450
+ });
451
+ const reopenedAuto = Array.from(
452
+ document.querySelectorAll<HTMLElement>('[role="option"]'),
453
+ ).find((option) => option.textContent?.includes("Auto"));
454
+ expect(reopenedAuto).toBeDefined();
455
+ act(() => {
456
+ reopenedAuto?.dispatchEvent(
457
+ new PointerEvent("pointerdown", {
458
+ bubbles: true,
459
+ button: 0,
460
+ pointerType: "mouse",
461
+ }),
462
+ );
463
+ reopenedAuto?.dispatchEvent(
464
+ new PointerEvent("pointerup", {
465
+ bubbles: true,
466
+ button: 0,
467
+ pointerType: "mouse",
468
+ }),
469
+ );
470
+ });
471
+
472
+ expect(onLanguageChange).toHaveBeenCalledWith("auto");
473
+ expect(onIntelligenceChange).not.toHaveBeenCalled();
474
+ expect(onVoiceChange).not.toHaveBeenCalled();
475
+ expect(settingsButton?.getAttribute("aria-expanded")).toBe("true");
476
+ expect(
477
+ document.querySelector('[data-realtime-voice-settings="true"]'),
478
+ ).not.toBeNull();
479
+ expect(
480
+ document.querySelector("[data-realtime-voice-state]"),
481
+ ).not.toBeNull();
482
+
483
+ act(() => settingsButton?.click());
484
+ expect(settingsButton?.getAttribute("aria-expanded")).toBe("false");
485
+ expect(
486
+ document.querySelector('[data-realtime-voice-settings="true"]'),
487
+ ).toBeNull();
488
+
489
+ act(() => settingsButton?.click());
490
+ expect(settingsButton?.getAttribute("aria-expanded")).toBe("true");
491
+ expect(
492
+ document.querySelector('[data-realtime-voice-settings="true"]'),
493
+ ).not.toBeNull();
494
+ });
495
+
496
+ it("keeps the dock visible when chat opens itself", () => {
497
+ const sidebar = document.createElement("div");
498
+ let sidebarWidth = 320;
499
+ sidebar.className = "agent-sidebar-panel";
500
+ sidebar.dataset.agentSidebarPosition = "right";
501
+ sidebar.dataset.agentSidebarState = "open";
502
+ sidebar.getBoundingClientRect = () =>
503
+ ({
504
+ bottom: 768,
505
+ height: 768,
506
+ left: 1024 - sidebarWidth,
507
+ right: 1024,
508
+ top: 0,
509
+ width: sidebarWidth,
510
+ x: 1024 - sidebarWidth,
511
+ y: 0,
512
+ toJSON: () => ({}),
513
+ }) as DOMRect;
514
+ document.body.appendChild(sidebar);
515
+
516
+ render(
517
+ <RealtimeVoiceModeDock
518
+ state="listening"
519
+ copy={copy}
520
+ chatVisible={false}
521
+ onToggleChat={vi.fn()}
522
+ onEndVoiceMode={vi.fn()}
523
+ />,
524
+ );
525
+ expect(
526
+ document.querySelector("[data-realtime-voice-state]"),
527
+ ).not.toBeNull();
528
+
529
+ render(
530
+ <RealtimeVoiceModeDock
531
+ state="listening"
532
+ copy={copy}
533
+ chatVisible
534
+ onToggleChat={vi.fn()}
535
+ onEndVoiceMode={vi.fn()}
536
+ />,
537
+ );
538
+ expect(
539
+ document.querySelector("[data-realtime-voice-state]"),
540
+ ).not.toBeNull();
541
+ expect(
542
+ document
543
+ .querySelector("[data-realtime-voice-state]")
544
+ ?.getAttribute("data-realtime-voice-chat-offset"),
545
+ ).toBe("-320");
546
+
547
+ act(() => {
548
+ sidebarWidth = 400;
549
+ window.dispatchEvent(new Event("resize"));
550
+ });
551
+ expect(
552
+ document
553
+ .querySelector("[data-realtime-voice-state]")
554
+ ?.getAttribute("data-realtime-voice-chat-offset"),
555
+ ).toBe("-400");
556
+ expect(
557
+ document
558
+ .querySelector('button[aria-label="Hide chat"]')
559
+ ?.getAttribute("aria-pressed"),
560
+ ).toBe("true");
561
+
562
+ sidebar.remove();
563
+ });
564
+
565
+ it("keeps the dock reachable for fullscreen and left-side chat", () => {
566
+ const sidebar = document.createElement("div");
567
+ sidebar.className = "agent-sidebar-panel";
568
+ sidebar.dataset.agentSidebarPosition = "right";
569
+ sidebar.dataset.agentSidebarState = "open";
570
+ sidebar.dataset.agentSidebarLayout = "fullscreen";
571
+ sidebar.getBoundingClientRect = () =>
572
+ ({
573
+ bottom: 768,
574
+ height: 768,
575
+ left: 0,
576
+ right: 1024,
577
+ top: 0,
578
+ width: 1024,
579
+ x: 0,
580
+ y: 0,
581
+ toJSON: () => ({}),
582
+ }) as DOMRect;
583
+ document.body.appendChild(sidebar);
584
+
585
+ render(
586
+ <RealtimeVoiceModeDock
587
+ state="listening"
588
+ copy={copy}
589
+ chatVisible
590
+ onToggleChat={vi.fn()}
591
+ onEndVoiceMode={vi.fn()}
592
+ />,
593
+ );
594
+ expect(
595
+ document
596
+ .querySelector("[data-realtime-voice-state]")
597
+ ?.getAttribute("data-realtime-voice-chat-offset"),
598
+ ).toBe("0");
599
+
600
+ act(() => {
601
+ document.documentElement.style.direction = "rtl";
602
+ sidebar.dataset.agentSidebarLayout = "desktop";
603
+ sidebar.dataset.agentSidebarPosition = "left";
604
+ sidebar.getBoundingClientRect = () =>
605
+ ({
606
+ bottom: 768,
607
+ height: 768,
608
+ left: 0,
609
+ right: 280,
610
+ top: 0,
611
+ width: 280,
612
+ x: 0,
613
+ y: 0,
614
+ toJSON: () => ({}),
615
+ }) as DOMRect;
616
+ });
617
+ render(
618
+ <RealtimeVoiceModeDock
619
+ key="rtl"
620
+ state="listening"
621
+ copy={copy}
622
+ chatVisible
623
+ onToggleChat={vi.fn()}
624
+ onEndVoiceMode={vi.fn()}
625
+ />,
626
+ );
627
+ expect(
628
+ document
629
+ .querySelector("[data-realtime-voice-state]")
630
+ ?.getAttribute("data-realtime-voice-chat-offset"),
631
+ ).toBe("280");
632
+
633
+ document.documentElement.style.direction = "";
634
+ sidebar.remove();
635
+ });
636
+
637
+ it("shows a live waveform for user and assistant audio", () => {
638
+ const audioLevels = createRealtimeVoiceAudioLevelStore();
639
+ audioLevels.set({ input: 0.7, output: 0 });
640
+ render(
641
+ <RealtimeVoiceModeDock
642
+ state="listening"
643
+ copy={copy}
644
+ chatVisible={false}
645
+ audioLevels={audioLevels}
646
+ onToggleChat={vi.fn()}
647
+ onEndVoiceMode={vi.fn()}
648
+ />,
649
+ );
650
+ expect(
651
+ document.querySelector('[data-realtime-voice-activity="user"]'),
652
+ ).not.toBeNull();
653
+ expect(
654
+ document.querySelector('[data-realtime-voice-waveform="true"]'),
655
+ ).not.toBeNull();
656
+
657
+ act(() => audioLevels.set({ input: 0, output: 0.8 }));
658
+ expect(
659
+ document.querySelector('[data-realtime-voice-activity="assistant"]'),
660
+ ).not.toBeNull();
661
+ expect(
662
+ document.querySelector(
663
+ '[data-realtime-voice-waveform-activity="assistant"]',
664
+ ),
665
+ ).not.toBeNull();
666
+ });
667
+
668
+ it.each(["listening", "speaking", "working", "ending"] as const)(
669
+ "keeps the waveform visible while voice mode is %s and silent",
670
+ (state) => {
671
+ render(
672
+ <RealtimeVoiceModeDock
673
+ state={state}
674
+ copy={copy}
675
+ chatVisible={false}
676
+ onToggleChat={vi.fn()}
677
+ onEndVoiceMode={vi.fn()}
678
+ />,
679
+ );
680
+
681
+ expect(
682
+ document.querySelector(
683
+ '[data-realtime-voice-waveform-activity="idle"]',
684
+ ),
685
+ ).not.toBeNull();
686
+ expect(document.querySelector(".animate-spin")).toBeNull();
687
+ },
688
+ );
689
+
690
+ it("shows an unmistakable loader and ignores early audio until connected", () => {
691
+ const audioLevels = createRealtimeVoiceAudioLevelStore();
692
+ audioLevels.set({ input: 0.8, output: 0 });
693
+
694
+ render(
695
+ <RealtimeVoiceModeDock
696
+ state="connecting"
697
+ copy={copy}
698
+ chatVisible={false}
699
+ audioLevels={audioLevels}
700
+ onToggleChat={vi.fn()}
701
+ onEndVoiceMode={vi.fn()}
702
+ />,
703
+ );
704
+
705
+ expect(
706
+ document
707
+ .querySelector("[data-realtime-voice-state]")
708
+ ?.getAttribute("data-realtime-voice-activity"),
709
+ ).toBe("idle");
710
+ expect(
711
+ document.querySelector('[data-realtime-voice-waveform="true"]'),
712
+ ).toBeNull();
713
+ expect(
714
+ document.querySelector(
715
+ '[data-realtime-voice-connecting-indicator="true"]',
716
+ ),
717
+ ).not.toBeNull();
718
+ expect(document.querySelector(".animate-spin")).not.toBeNull();
719
+ });
720
+
721
+ it("exposes error details and a separate end-session action", () => {
722
+ const onToggleChat = vi.fn();
723
+ const onEndVoiceMode = vi.fn();
724
+
725
+ render(
726
+ <RealtimeVoiceModeDock
727
+ state="error"
728
+ copy={copy}
729
+ chatVisible
730
+ errorMessage="The microphone disconnected."
731
+ onToggleChat={onToggleChat}
732
+ onEndVoiceMode={onEndVoiceMode}
733
+ />,
734
+ );
735
+
736
+ expect(document.body.textContent).toContain("The microphone disconnected.");
737
+ const endVoiceMode = document.querySelector<HTMLButtonElement>(
738
+ 'button[aria-label="End voice mode"]',
739
+ );
740
+ act(() => endVoiceMode?.click());
741
+
742
+ expect(onEndVoiceMode).toHaveBeenCalledOnce();
743
+ expect(onToggleChat).not.toHaveBeenCalled();
744
+ });
745
+
746
+ it("locks the dock while the session is ending", () => {
747
+ render(
748
+ <RealtimeVoiceModeDock
749
+ state="ending"
750
+ copy={copy}
751
+ chatVisible={false}
752
+ onToggleChat={vi.fn()}
753
+ onEndVoiceMode={vi.fn()}
754
+ />,
755
+ );
756
+
757
+ expect(
758
+ document.querySelector<HTMLButtonElement>(
759
+ 'button[aria-label="Show chat"]',
760
+ )?.disabled,
761
+ ).toBe(true);
762
+ expect(
763
+ document.querySelector<HTMLButtonElement>(
764
+ 'button[aria-label="End voice mode"]',
765
+ )?.disabled,
766
+ ).toBe(true);
767
+ const status = document.querySelector('[role="status"]');
768
+ expect(status?.textContent).toBe("Ending voice mode");
769
+ expect(status?.className).toContain("sr-only");
770
+ });
771
+ });