@agent-native/toolkit 0.13.2 → 0.13.4

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 (33) hide show
  1. package/dist/composer/MentionPopover.d.ts.map +1 -1
  2. package/dist/composer/MentionPopover.js +12 -6
  3. package/dist/composer/MentionPopover.js.map +1 -1
  4. package/dist/composer/PromptComposer.d.ts +4 -0
  5. package/dist/composer/PromptComposer.d.ts.map +1 -1
  6. package/dist/composer/PromptComposer.js +2 -2
  7. package/dist/composer/PromptComposer.js.map +1 -1
  8. package/dist/composer/TiptapComposer.d.ts +9 -2
  9. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  10. package/dist/composer/TiptapComposer.js +11 -6
  11. package/dist/composer/TiptapComposer.js.map +1 -1
  12. package/dist/composer/TiptapComposer.spec.js +15 -1
  13. package/dist/composer/TiptapComposer.spec.js.map +1 -1
  14. package/dist/composer/useRealtimeVoiceMode.d.ts +3 -0
  15. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
  16. package/dist/composer/useRealtimeVoiceMode.js +2 -0
  17. package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
  18. package/dist/composer/useVoiceDictation.browser.spec.d.ts +2 -0
  19. package/dist/composer/useVoiceDictation.browser.spec.d.ts.map +1 -0
  20. package/dist/composer/useVoiceDictation.browser.spec.js +220 -0
  21. package/dist/composer/useVoiceDictation.browser.spec.js.map +1 -0
  22. package/dist/composer/useVoiceDictation.d.ts +1 -0
  23. package/dist/composer/useVoiceDictation.d.ts.map +1 -1
  24. package/dist/composer/useVoiceDictation.js +76 -22
  25. package/dist/composer/useVoiceDictation.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/composer/MentionPopover.tsx +62 -27
  28. package/src/composer/PromptComposer.tsx +8 -0
  29. package/src/composer/TiptapComposer.spec.ts +26 -1
  30. package/src/composer/TiptapComposer.tsx +22 -5
  31. package/src/composer/useRealtimeVoiceMode.tsx +4 -0
  32. package/src/composer/useVoiceDictation.browser.spec.tsx +265 -0
  33. package/src/composer/useVoiceDictation.ts +85 -20
@@ -0,0 +1,265 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ useVoiceDictation,
9
+ type VoiceDictationApi,
10
+ } from "./useVoiceDictation.js";
11
+
12
+ class FakeSpeechRecognition {
13
+ static last: FakeSpeechRecognition | undefined;
14
+ continuous = false;
15
+ interimResults = false;
16
+ lang = "";
17
+ onaudiostart: (() => void) | undefined;
18
+ onresult: ((event: unknown) => void) | undefined;
19
+ onerror: ((event: { error?: string }) => void) | undefined;
20
+ onend: (() => void) | undefined;
21
+ started = false;
22
+
23
+ constructor() {
24
+ FakeSpeechRecognition.last = this;
25
+ }
26
+
27
+ start() {
28
+ this.started = true;
29
+ }
30
+ stop() {}
31
+ abort() {}
32
+ }
33
+
34
+ class FakeMediaRecorder {
35
+ static last: FakeMediaRecorder | undefined;
36
+ static isTypeSupported = () => true;
37
+ ondataavailable: ((event: unknown) => void) | undefined;
38
+ onstop: (() => void) | undefined;
39
+ mimeType = "audio/webm";
40
+ started = false;
41
+
42
+ constructor() {
43
+ FakeMediaRecorder.last = this;
44
+ }
45
+
46
+ start() {
47
+ this.started = true;
48
+ }
49
+ stop() {}
50
+ }
51
+
52
+ function stubSpeechEnvironment(getUserMedia = vi.fn()) {
53
+ FakeSpeechRecognition.last = undefined;
54
+ FakeMediaRecorder.last = undefined;
55
+ vi.stubGlobal("SpeechRecognition", FakeSpeechRecognition);
56
+ vi.stubGlobal("webkitSpeechRecognition", FakeSpeechRecognition);
57
+ vi.stubGlobal("MediaRecorder", undefined);
58
+ Object.defineProperty(navigator, "mediaDevices", {
59
+ configurable: true,
60
+ value: { getUserMedia },
61
+ });
62
+ vi.stubGlobal(
63
+ "fetch",
64
+ vi.fn().mockResolvedValue(new Response("{}", { status: 404 })),
65
+ );
66
+ return getUserMedia;
67
+ }
68
+
69
+ async function renderVoiceDictation() {
70
+ const seen: VoiceDictationApi[] = [];
71
+ function Probe() {
72
+ seen.push(useVoiceDictation({ onTranscript: vi.fn() }));
73
+ return null;
74
+ }
75
+ const container = document.createElement("div");
76
+ document.body.append(container);
77
+ const root = createRoot(container);
78
+ await act(async () => {
79
+ root.render(<Probe />);
80
+ });
81
+ return {
82
+ latest: () => seen[seen.length - 1]!,
83
+ async cleanup() {
84
+ await act(async () => {
85
+ root.unmount();
86
+ });
87
+ container.remove();
88
+ },
89
+ };
90
+ }
91
+
92
+ beforeEach(() => {
93
+ (globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
94
+ });
95
+
96
+ afterEach(() => {
97
+ vi.unstubAllGlobals();
98
+ });
99
+
100
+ describe("useVoiceDictation — browser speech path", () => {
101
+ it("reports a session that ends before the microphone ever opened", async () => {
102
+ stubSpeechEnvironment();
103
+ const probe = await renderVoiceDictation();
104
+ await act(async () => {
105
+ await probe.latest().start();
106
+ });
107
+
108
+ const recognition = FakeSpeechRecognition.last!;
109
+ expect(recognition.started).toBe(true);
110
+ await act(async () => {
111
+ recognition.onerror?.({ error: "aborted" });
112
+ recognition.onend?.();
113
+ });
114
+
115
+ expect(probe.latest().state).toBe("error");
116
+ expect(probe.latest().errorMessage).toContain(
117
+ "stopped before it captured any audio",
118
+ );
119
+ await probe.cleanup();
120
+ });
121
+
122
+ it("treats a silent but live session as an ordinary empty result", async () => {
123
+ stubSpeechEnvironment(vi.fn().mockRejectedValue(new Error("no device")));
124
+ const probe = await renderVoiceDictation();
125
+ await act(async () => {
126
+ await probe.latest().start();
127
+ });
128
+
129
+ const recognition = FakeSpeechRecognition.last!;
130
+ await act(async () => {
131
+ recognition.onaudiostart?.();
132
+ recognition.onerror?.({ error: "no-speech" });
133
+ recognition.onend?.();
134
+ });
135
+
136
+ expect(probe.latest().state).toBe("idle");
137
+ expect(probe.latest().errorMessage).toBeNull();
138
+ await probe.cleanup();
139
+ });
140
+
141
+ it("keeps a reported speech error visible when `end` follows `error`", async () => {
142
+ stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
143
+ const probe = await renderVoiceDictation();
144
+ await act(async () => {
145
+ await probe.latest().start();
146
+ });
147
+
148
+ const recognition = FakeSpeechRecognition.last!;
149
+ await act(async () => {
150
+ recognition.onaudiostart?.();
151
+ recognition.onerror?.({ error: "network" });
152
+ recognition.onend?.();
153
+ });
154
+
155
+ expect(probe.latest().state).toBe("error");
156
+ expect(probe.latest().errorMessage).toContain("couldn't reach its service");
157
+ await probe.cleanup();
158
+ });
159
+
160
+ it("falls back to the upload path when the speech service is unreachable", async () => {
161
+ stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
162
+ vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
163
+ const probe = await renderVoiceDictation();
164
+ await act(async () => {
165
+ await probe.latest().start();
166
+ });
167
+
168
+ await act(async () => {
169
+ FakeSpeechRecognition.last!.onaudiostart?.();
170
+ FakeSpeechRecognition.last!.onerror?.({ error: "network" });
171
+ FakeSpeechRecognition.last!.onend?.();
172
+ });
173
+ await act(async () => {});
174
+
175
+ expect(FakeMediaRecorder.last?.started).toBe(true);
176
+ expect(probe.latest().state).toBe("recording");
177
+ expect(probe.latest().errorMessage).toBeNull();
178
+ await probe.cleanup();
179
+ });
180
+
181
+ it("delivers partial speech instead of failing over after a mid-session drop", async () => {
182
+ stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
183
+ vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
184
+ const probe = await renderVoiceDictation();
185
+ await act(async () => {
186
+ await probe.latest().start();
187
+ });
188
+
189
+ await act(async () => {
190
+ const recognition = FakeSpeechRecognition.last!;
191
+ recognition.onaudiostart?.();
192
+ recognition.onresult?.({
193
+ resultIndex: 0,
194
+ results: [{ 0: { transcript: "make it blue" }, isFinal: true }],
195
+ });
196
+ recognition.onerror?.({ error: "network" });
197
+ recognition.onend?.();
198
+ });
199
+ await act(async () => {});
200
+
201
+ expect(FakeMediaRecorder.last).toBeUndefined();
202
+ expect(probe.latest().state).toBe("idle");
203
+ await probe.cleanup();
204
+ });
205
+
206
+ it("falls back to the upload path when the recognizer has no speech backend", async () => {
207
+ const getUserMedia = stubSpeechEnvironment(
208
+ vi.fn().mockResolvedValue({ getTracks: () => [] }),
209
+ );
210
+ vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
211
+ const probe = await renderVoiceDictation();
212
+ await act(async () => {
213
+ await probe.latest().start();
214
+ });
215
+
216
+ await act(async () => {
217
+ FakeSpeechRecognition.last!.onerror?.({ error: "aborted" });
218
+ FakeSpeechRecognition.last!.onend?.();
219
+ });
220
+ await act(async () => {});
221
+
222
+ expect(getUserMedia).toHaveBeenCalled();
223
+ expect(FakeMediaRecorder.last?.started).toBe(true);
224
+ expect(probe.latest().state).toBe("recording");
225
+ expect(probe.latest().errorMessage).toBeNull();
226
+ await probe.cleanup();
227
+ });
228
+
229
+ it("does not retry through the upload path after a denied microphone", async () => {
230
+ stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
231
+ vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
232
+ const probe = await renderVoiceDictation();
233
+ await act(async () => {
234
+ await probe.latest().start();
235
+ });
236
+
237
+ await act(async () => {
238
+ FakeSpeechRecognition.last!.onerror?.({ error: "not-allowed" });
239
+ FakeSpeechRecognition.last!.onend?.();
240
+ });
241
+ await act(async () => {});
242
+
243
+ expect(FakeMediaRecorder.last).toBeUndefined();
244
+ expect(probe.latest().state).toBe("error");
245
+ expect(probe.latest().errorMessage).toContain("site controls icon");
246
+ await probe.cleanup();
247
+ });
248
+
249
+ it("opens the meter capture only after recognition owns the microphone", async () => {
250
+ const getUserMedia = stubSpeechEnvironment(
251
+ vi.fn().mockResolvedValue({ getTracks: () => [] }),
252
+ );
253
+ const probe = await renderVoiceDictation();
254
+ await act(async () => {
255
+ await probe.latest().start();
256
+ });
257
+
258
+ expect(getUserMedia).not.toHaveBeenCalled();
259
+ await act(async () => {
260
+ FakeSpeechRecognition.last!.onaudiostart?.();
261
+ });
262
+ expect(getUserMedia).toHaveBeenCalledTimes(1);
263
+ await probe.cleanup();
264
+ });
265
+ });
@@ -348,7 +348,18 @@ export function voiceDictationStartErrorMessage(error: unknown): string {
348
348
  return message || "Could not start recording";
349
349
  }
350
350
 
351
- function voiceDictationSpeechErrorMessage(error: string | undefined): string {
351
+ /** Retrying these through another provider re-prompts and fails the same way. */
352
+ function isMicPermissionError(error: string | undefined): boolean {
353
+ return (
354
+ error === "not-allowed" ||
355
+ error === "service-not-allowed" ||
356
+ error === "audio-capture"
357
+ );
358
+ }
359
+
360
+ export function voiceDictationSpeechErrorMessage(
361
+ error: string | undefined,
362
+ ): string {
352
363
  if (error === "not-allowed" || error === "service-not-allowed") {
353
364
  return voiceDictationStartErrorMessage({
354
365
  name: "NotAllowedError",
@@ -358,7 +369,13 @@ function voiceDictationSpeechErrorMessage(error: string | undefined): string {
358
369
  if (error === "audio-capture") {
359
370
  return "No microphone was found. Plug one in or choose a different input, then try again.";
360
371
  }
361
- return `Speech recognition error: ${error ?? "unknown"}`;
372
+ if (error === "network") {
373
+ return "Speech recognition couldn't reach its service. Check your connection, or pick a different source in Settings → Voice Transcription.";
374
+ }
375
+ if (error === "aborted" || error === undefined) {
376
+ return "Dictation stopped before it captured any audio. Another app or tab may be holding the microphone — close it, or pick a different source in Settings → Voice Transcription.";
377
+ }
378
+ return `Speech recognition error: ${error}`;
362
379
  }
363
380
 
364
381
  export function useVoiceDictation(
@@ -688,28 +705,20 @@ export function useVoiceDictation(
688
705
  );
689
706
 
690
707
  const startBrowser = useCallback(
691
- async (prefs: VoicePrefs) => {
708
+ async (
709
+ prefs: VoicePrefs,
710
+ /** Return true to take over when the recognizer never opened the mic.
711
+ * Brave ships `webkitSpeechRecognition` with no speech backend, so
712
+ * feature detection alone cannot tell dictation will work. */
713
+ onUnavailable?: (error: string | undefined) => boolean,
714
+ ) => {
692
715
  const Ctor = getSpeechRecognitionCtor();
693
716
  if (!Ctor) {
694
717
  throw new Error(
695
718
  "Your browser doesn't support speech recognition. Add an OpenAI API key in settings for Whisper transcription.",
696
719
  );
697
720
  }
698
- // Still request mic to drive the amplitude meter, so the UI doesn't look
699
- // dead while the user talks. SpeechRecognition manages its own capture
700
- // under the hood in most browsers.
701
- let stream: MediaStream | null = null;
702
- try {
703
- stream = await navigator.mediaDevices.getUserMedia({ audio: true });
704
- mediaStreamRef.current = stream;
705
- startMeter(stream);
706
- } catch {
707
- /* non-fatal — recognition can still work without our analyser */
708
- }
709
-
710
721
  if (cancelledRef.current) {
711
- if (stream) for (const track of stream.getTracks()) track.stop();
712
- mediaStreamRef.current = null;
713
722
  cancelledRef.current = false;
714
723
  setState("idle");
715
724
  return;
@@ -723,7 +732,34 @@ export function useVoiceDictation(
723
732
  speechRef.current = recognition;
724
733
  speechTranscriptRef.current = "";
725
734
 
735
+ let capturing = false;
736
+ let fatal = false;
737
+ let lastError: string | undefined;
738
+
739
+ // Opening our own capture before the speech service has claimed the
740
+ // device makes Chrome abort the session outright. Attach the meter only
741
+ // once recognition is actually listening.
742
+ recognition.onaudiostart = () => {
743
+ capturing = true;
744
+ if (cancelledRef.current) return;
745
+ void Promise.resolve()
746
+ .then(() => navigator.mediaDevices?.getUserMedia({ audio: true }))
747
+ .then((stream) => {
748
+ if (!stream) return;
749
+ if (cancelledRef.current || speechRef.current !== recognition) {
750
+ for (const track of stream.getTracks()) track.stop();
751
+ return;
752
+ }
753
+ mediaStreamRef.current = stream;
754
+ startMeter(stream);
755
+ })
756
+ .catch(() => {
757
+ /* the meter is decoration; recognition owns the real capture */
758
+ });
759
+ };
760
+
726
761
  recognition.onresult = (event: any) => {
762
+ capturing = true;
727
763
  let interim = "";
728
764
  for (let i = event.resultIndex; i < event.results.length; i++) {
729
765
  const result = event.results[i];
@@ -736,16 +772,30 @@ export function useVoiceDictation(
736
772
  }
737
773
  onLiveUpdateRef.current?.(speechTranscriptRef.current, interim);
738
774
  };
775
+ // `end` always follows `error`, so every outcome is decided there. Acting
776
+ // here too would either pre-empt the fallback or be overwritten by it.
739
777
  recognition.onerror = (event: any) => {
778
+ lastError = event?.error;
740
779
  if (event?.error === "no-speech" || event?.error === "aborted") return;
741
- failWith(voiceDictationSpeechErrorMessage(event?.error));
780
+ fatal = true;
742
781
  };
743
782
  recognition.onend = () => {
744
783
  const text = speechTranscriptRef.current.trim();
745
784
  const wasCancelled = cancelledRef.current;
746
785
  cancelledRef.current = false;
747
786
  teardown();
748
- if (wasCancelled || !text) {
787
+ if (wasCancelled) {
788
+ setState("idle");
789
+ return;
790
+ }
791
+ if (!text) {
792
+ // A recognizer that failed, or that ended before the mic ever opened,
793
+ // produced nothing usable — that is not the same as hearing silence.
794
+ if (fatal || !capturing) {
795
+ if (onUnavailable?.(lastError)) return;
796
+ failWith(voiceDictationSpeechErrorMessage(lastError));
797
+ return;
798
+ }
749
799
  setState("idle");
750
800
  return;
751
801
  }
@@ -1082,7 +1132,22 @@ export function useVoiceDictation(
1082
1132
  }
1083
1133
  await startGoogleRealtime(prefs);
1084
1134
  } else {
1085
- await startBrowser(prefs);
1135
+ // Only "auto" promised a working recognizer of any kind; an explicit
1136
+ // browser preference must surface its own failure instead.
1137
+ await startBrowser(
1138
+ prefs,
1139
+ pref === "auto" && mediaRecorderSupported
1140
+ ? (error) => {
1141
+ if (isMicPermissionError(error)) return false;
1142
+ activeProviderRef.current = "openai";
1143
+ setState("starting");
1144
+ void startOpenAi("auto", prefs.instructions).catch((err) =>
1145
+ failWith(voiceDictationStartErrorMessage(err)),
1146
+ );
1147
+ return true;
1148
+ }
1149
+ : undefined,
1150
+ );
1086
1151
  }
1087
1152
  } catch (err) {
1088
1153
  if (cancelledRef.current) {