@epicbagel/saas-kit 1.11.1 → 1.12.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 CHANGED
@@ -50,8 +50,7 @@ For hidden/programmatic triggers, it uses the focused visible button as the
50
50
  desktop anchor. Hosts can pass an explicit `anchorRef` for a custom trigger.
51
51
  Without a visible anchor it opens full-screen. Nested and sibling dialogs keep
52
52
  their focus and cancellation behavior.
53
- The recorder runs until **Done**, shows
54
- **Transcribing…**, then commits the returned text once. Failed requests keep the
53
+ The recorder runs until **Done**, finishes the last words, then commits the returned text once. Failed requests keep the
55
54
  take available for retry. Cancel stops microphone tracks, aborts the request and
56
55
  ignores a late answer. The kit never persists audio or knows provider credentials.
57
56
 
@@ -67,6 +66,21 @@ import { DictationProvider } from "@epicbagel/saas-kit/primitives/dictation-prov
67
66
  </DictationProvider>
68
67
  ```
69
68
 
69
+ For words that appear while speaking, supply `stream` as well (or on its own).
70
+ The host connects the provided `MediaStream` to its speech service; the kit
71
+ captures the microphone only once. `onTranscript` replaces the live preview
72
+ with `{ transcript, interimTranscript }`. The adapter resolves when ready and
73
+ returns `{ finish, cancel }`: `finish()` drains the final audio and returns the
74
+ complete transcript; `cancel()` releases the transport without saving. The
75
+ adapter must honor `signal` during startup, streaming and finalization, and
76
+ report unexpected disconnection through `onError`.
77
+
78
+ Preview updates never invoke a control's commit callback. Done waits for
79
+ `finish()` before saving. If streaming fails and `transcribe` is also supplied,
80
+ the recorder shows a warning and uses its complete recording on Done. Retry
81
+ uses that same recording. With no batch adapter, an interrupted stream is an
82
+ error; partial words are never presented as a complete note.
83
+
70
84
  Set `scopeKey` from the acting user and workspace (including impersonation, if
71
85
  the app supports it). A changed scope discards the active take, stops recording
72
86
  and aborts transcription without remounting the application. Responses from the
@@ -83,8 +97,9 @@ native recognition has no audio-level source.
83
97
  failure. Choose either that result or `onTranscript` to commit; using both would
84
98
  duplicate the host action. The callback receives one complete API transcript,
85
99
  or finalized chunks with native recognition. API recording requires HTTPS (or
86
- localhost), microphone permission and MediaRecorder. It does not produce live
87
- interim text; the live voice controls are a separate feature.
100
+ localhost), microphone permission and MediaRecorder. Live interim text requires
101
+ the host's `stream` adapter; the batch `transcribe` adapter alone returns text
102
+ after Done. Live voice conversation controls remain a separate feature.
88
103
 
89
104
  Without a provider, controls retain browser-native speech recognition. The
90
105
  private ui-kit's `VoiceInput` uses this same provider and capture hook, and its
@@ -3,11 +3,11 @@
3
3
  // src/hooks/use-dictation.ts
4
4
  var _react = require('react');
5
5
  var DictationContext = _react.createContext.call(void 0, null);
6
- function DictationProvider({ transcribe, scopeKey, children }) {
6
+ function DictationProvider({ transcribe, stream, scopeKey, children }) {
7
7
  const scope = _react.useRef.call(void 0, { key: scopeKey, generation: 0 });
8
8
  if (scope.current.key !== scopeKey) scope.current = { key: scopeKey, generation: scope.current.generation + 1 };
9
9
  const generation = scope.current.generation;
10
- const value = _react.useMemo.call(void 0, () => ({ transcribe, scope, generation }), [transcribe, generation]);
10
+ const value = _react.useMemo.call(void 0, () => ({ transcribe, stream, scope, generation }), [transcribe, stream, generation]);
11
11
  return _react.createElement.call(void 0, DictationContext.Provider, { value }, children);
12
12
  }
13
13
  var EMPTY = { state: "idle", transcript: "", interimTranscript: "", error: null, canRetry: false };
@@ -128,6 +128,8 @@ function releaseTracks(session) {
128
128
  }
129
129
  function dispose(session) {
130
130
  session.controller.abort();
131
+ _optionalChain([session, 'access', _18 => _18.streamController, 'optionalAccess', _19 => _19.abort, 'call', _20 => _20()]);
132
+ _optionalChain([session, 'access', _21 => _21.live, 'optionalAccess', _22 => _22.cancel, 'call', _23 => _23()]);
131
133
  if (session.nativeTimer) clearTimeout(session.nativeTimer);
132
134
  const recorder = session.recorder;
133
135
  if (recorder) {
@@ -157,9 +159,10 @@ function dispose(session) {
157
159
  }
158
160
  function useDictation({ lang = "en-US", continuous = true, onTranscript } = {}) {
159
161
  const provider = _react.useContext.call(void 0, DictationContext);
160
- const transcribe = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _18 => _18.transcribe]), () => ( null));
161
- const scope = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _19 => _19.scope]), () => ( null));
162
- const scopeGeneration = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _20 => _20.generation]), () => ( 0));
162
+ const transcribe = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _24 => _24.transcribe]), () => ( null));
163
+ const startStream = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _25 => _25.stream]), () => ( null));
164
+ const scope = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _26 => _26.scope]), () => ( null));
165
+ const scopeGeneration = _nullishCoalesce(_optionalChain([provider, 'optionalAccess', _27 => _27.generation]), () => ( 0));
163
166
  const [snapshot, setSnapshot] = _react.useState.call(void 0, EMPTY);
164
167
  const [supported, setSupported] = _react.useState.call(void 0, false);
165
168
  const current = _react.useRef.call(void 0, null);
@@ -169,8 +172,8 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
169
172
  activeScope.current = scope;
170
173
  callback.current = onTranscript;
171
174
  _react.useEffect.call(void 0, () => {
172
- setSupported(transcribe ? typeof navigator !== "undefined" && !!_optionalChain([navigator, 'access', _21 => _21.mediaDevices, 'optionalAccess', _22 => _22.getUserMedia]) && typeof MediaRecorder !== "undefined" : typeof recognitionConstructor() === "function");
173
- }, [transcribe]);
175
+ setSupported(transcribe || startStream ? typeof navigator !== "undefined" && !!_optionalChain([navigator, 'access', _28 => _28.mediaDevices, 'optionalAccess', _29 => _29.getUserMedia]) && typeof MediaRecorder !== "undefined" : typeof recognitionConstructor() === "function");
176
+ }, [transcribe, startStream]);
174
177
  const cancel = _react.useCallback.call(void 0, () => {
175
178
  const session = current.current;
176
179
  current.current = null;
@@ -190,9 +193,9 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
190
193
  if (session) dispose(session);
191
194
  };
192
195
  }, []);
193
- const isCurrent = _react.useCallback.call(void 0, (session) => mounted.current && current.current === session && session.scope === activeScope.current && session.scopeGeneration === (_nullishCoalesce(_optionalChain([session, 'access', _23 => _23.scope, 'optionalAccess', _24 => _24.current, 'access', _25 => _25.generation]), () => ( 0))) && !session.controller.signal.aborted, []);
196
+ const isCurrent = _react.useCallback.call(void 0, (session) => mounted.current && current.current === session && session.scope === activeScope.current && session.scopeGeneration === (_nullishCoalesce(_optionalChain([session, 'access', _30 => _30.scope, 'optionalAccess', _31 => _31.current, 'access', _32 => _32.generation]), () => ( 0))) && !session.controller.signal.aborted, []);
194
197
  const start = _react.useCallback.call(void 0, async () => {
195
- if (scope !== activeScope.current || scopeGeneration !== (_nullishCoalesce(_optionalChain([scope, 'optionalAccess', _26 => _26.current, 'access', _27 => _27.generation]), () => ( 0)))) return;
198
+ if (scope !== activeScope.current || scopeGeneration !== (_nullishCoalesce(_optionalChain([scope, 'optionalAccess', _33 => _33.current, 'access', _34 => _34.generation]), () => ( 0)))) return;
196
199
  if (current.current && isCurrent(current.current) && ["requesting", "recording", "transcribing"].includes(current.current.phase)) return;
197
200
  cancel();
198
201
  let resolveCapture;
@@ -200,9 +203,12 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
200
203
  const session = {
201
204
  language: lang,
202
205
  transcribe,
206
+ startStream,
207
+ liveFailed: false,
203
208
  scope,
204
209
  scopeGeneration,
205
210
  controller: new AbortController(),
211
+ streamController: startStream ? new AbortController() : void 0,
206
212
  phase: "requesting",
207
213
  chunks: [],
208
214
  capture: new Promise((resolve) => {
@@ -217,20 +223,20 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
217
223
  transcript: "",
218
224
  interim: "",
219
225
  notified: /* @__PURE__ */ new Set(),
220
- audioLevels: transcribe ? createAudioLevels() : void 0
226
+ audioLevels: transcribe || startStream ? createAudioLevels() : void 0
221
227
  };
222
228
  current.current = session;
223
229
  setSnapshot({ ...EMPTY, state: "requesting" });
224
- if (transcribe) {
230
+ if (transcribe || startStream) {
225
231
  try {
226
- if (!_optionalChain([navigator, 'access', _28 => _28.mediaDevices, 'optionalAccess', _29 => _29.getUserMedia]) || typeof MediaRecorder === "undefined") throw new Error("unsupported");
232
+ if (!_optionalChain([navigator, 'access', _35 => _35.mediaDevices, 'optionalAccess', _36 => _36.getUserMedia]) || typeof MediaRecorder === "undefined") throw new Error("unsupported");
227
233
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
228
234
  if (!isCurrent(session)) {
229
235
  stream.getTracks().forEach((track) => track.stop());
230
236
  return;
231
237
  }
232
238
  session.stream = stream;
233
- const mimeType = MIME_TYPES.find((mime) => _optionalChain([MediaRecorder, 'access', _30 => _30.isTypeSupported, 'optionalCall', _31 => _31(mime)]));
239
+ const mimeType = MIME_TYPES.find((mime) => _optionalChain([MediaRecorder, 'access', _37 => _37.isTypeSupported, 'optionalCall', _38 => _38(mime)]));
234
240
  const recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
235
241
  session.recorder = recorder;
236
242
  recorder.ondataavailable = (event) => {
@@ -242,7 +248,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
242
248
  session.resolveCapture(null);
243
249
  return;
244
250
  }
245
- const type = recorder.mimeType || _optionalChain([session, 'access', _32 => _32.chunks, 'access', _33 => _33[0], 'optionalAccess', _34 => _34.type]) || mimeType || "audio/webm";
251
+ const type = recorder.mimeType || _optionalChain([session, 'access', _39 => _39.chunks, 'access', _40 => _40[0], 'optionalAccess', _41 => _41.type]) || mimeType || "audio/webm";
246
252
  const blob = new Blob(session.chunks, { type });
247
253
  session.blob = blob;
248
254
  session.chunks = [];
@@ -250,16 +256,53 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
250
256
  };
251
257
  recorder.onerror = () => {
252
258
  if (!isCurrent(session)) return;
259
+ session.liveFailed = true;
260
+ _optionalChain([session, 'access', _42 => _42.live, 'optionalAccess', _43 => _43.cancel, 'call', _44 => _44()]);
253
261
  releaseTracks(session);
254
262
  session.phase = "error";
255
263
  session.resolveCapture(null);
256
264
  setSnapshot({ ...EMPTY, state: "error", error: "Recording was interrupted. Try again." });
257
265
  };
258
266
  recorder.start();
267
+ if (startStream) {
268
+ const failed = (error) => {
269
+ if (!isCurrent(session) || session.phase === "ready" || session.phase === "error") return;
270
+ session.liveFailed = true;
271
+ _optionalChain([session, 'access', _45 => _45.live, 'optionalAccess', _46 => _46.cancel, 'call', _47 => _47()]);
272
+ if (transcribe) {
273
+ setSnapshot((previous) => ({ ...previous, previewError: "Live text is unavailable. Choose Done to transcribe this recording." }));
274
+ } else {
275
+ releaseTracks(session);
276
+ if (recorder.state !== "inactive") recorder.stop();
277
+ session.phase = "error";
278
+ setSnapshot((previous) => ({ ...previous, state: "error", error: error.message || "Live transcription stopped. Try again." }));
279
+ }
280
+ };
281
+ try {
282
+ const live = await startStream(stream, {
283
+ language: lang,
284
+ signal: session.streamController.signal,
285
+ onTranscript: (preview) => {
286
+ if (!isCurrent(session) || session.liveFailed || session.phase === "error" || session.phase === "ready") return;
287
+ session.transcript = preview.transcript;
288
+ session.interim = preview.interimTranscript;
289
+ setSnapshot((previous) => ({ ...previous, transcript: preview.transcript, interimTranscript: preview.interimTranscript }));
290
+ },
291
+ onError: failed
292
+ });
293
+ if (!isCurrent(session) || session.liveFailed || session.phase === "error") {
294
+ live.cancel();
295
+ } else session.live = live;
296
+ } catch (error) {
297
+ failed(error instanceof Error ? error : new Error("Live transcription could not start. Try again."));
298
+ }
299
+ if (!isCurrent(session) || session.phase !== "requesting") return;
300
+ }
259
301
  session.phase = "recording";
260
302
  session.stopMeter = meterStream(stream, session.audioLevels, () => isCurrent(session) && session.phase === "recording");
261
- setSnapshot({ ...EMPTY, state: "recording" });
303
+ setSnapshot((previous) => ({ ...previous, state: "recording" }));
262
304
  } catch (error) {
305
+ _optionalChain([session, 'access', _48 => _48.live, 'optionalAccess', _49 => _49.cancel, 'call', _50 => _50()]);
263
306
  releaseTracks(session);
264
307
  if (!isCurrent(session)) return;
265
308
  session.phase = "error";
@@ -295,7 +338,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
295
338
  session.transcript = final.join(" ");
296
339
  session.interim = interim.join(" ");
297
340
  setSnapshot((previous) => ({ ...previous, transcript: session.transcript, interimTranscript: session.interim }));
298
- if (chunks.length) _optionalChain([callback, 'access', _35 => _35.current, 'optionalCall', _36 => _36(chunks.join(" "))]);
341
+ if (chunks.length) _optionalChain([callback, 'access', _51 => _51.current, 'optionalCall', _52 => _52(chunks.join(" "))]);
299
342
  };
300
343
  recognition.onend = () => {
301
344
  session.nativeEnded = true;
@@ -320,23 +363,23 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
320
363
  session.phase = "error";
321
364
  setSnapshot({ ...EMPTY, state: "error", error: microphoneError(error) });
322
365
  }
323
- }, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe]);
366
+ }, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe, startStream]);
324
367
  const finish = _react.useCallback.call(void 0, async (session) => {
325
368
  if (!isCurrent(session)) return null;
326
369
  releaseMeter(session);
327
370
  session.phase = "transcribing";
328
371
  setSnapshot((previous) => ({ ...previous, state: "transcribing", error: null, canRetry: false }));
329
- if (!session.transcribe) {
372
+ if (!session.transcribe && !session.startStream) {
330
373
  if (!session.nativeEnded) {
331
374
  session.nativeTimer = setTimeout(() => {
332
375
  try {
333
- _optionalChain([session, 'access', _37 => _37.recognition, 'optionalAccess', _38 => _38.abort, 'call', _39 => _39()]);
376
+ _optionalChain([session, 'access', _53 => _53.recognition, 'optionalAccess', _54 => _54.abort, 'call', _55 => _55()]);
334
377
  } catch (e8) {
335
378
  }
336
379
  session.resolveNativeEnd();
337
380
  }, 1e3);
338
381
  try {
339
- _optionalChain([session, 'access', _40 => _40.recognition, 'optionalAccess', _41 => _41.stop, 'call', _42 => _42()]);
382
+ _optionalChain([session, 'access', _56 => _56.recognition, 'optionalAccess', _57 => _57.stop, 'call', _58 => _58()]);
340
383
  } catch (e9) {
341
384
  session.resolveNativeEnd();
342
385
  }
@@ -352,13 +395,29 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
352
395
  let text;
353
396
  try {
354
397
  if (!session.blob) {
355
- if (_optionalChain([session, 'access', _43 => _43.recorder, 'optionalAccess', _44 => _44.state]) !== "inactive") _optionalChain([session, 'access', _45 => _45.recorder, 'optionalAccess', _46 => _46.stop, 'call', _47 => _47()]);
398
+ if (_optionalChain([session, 'access', _59 => _59.recorder, 'optionalAccess', _60 => _60.state]) !== "inactive") _optionalChain([session, 'access', _61 => _61.recorder, 'optionalAccess', _62 => _62.stop, 'call', _63 => _63()]);
356
399
  const blob = await session.capture;
357
400
  if (!isCurrent(session) || !blob) return null;
358
401
  session.blob = blob;
359
402
  }
360
- if (!session.blob.size) throw new Error("No audio was recorded. Try again.");
361
- text = (await session.transcribe(session.blob, { language: session.language, signal: session.controller.signal })).trim();
403
+ if (session.live && !session.liveFailed) {
404
+ try {
405
+ const final = await session.live.finish();
406
+ if (session.liveFailed) throw new Error("Live transcription was interrupted. Please retry this recording.");
407
+ text = final.trim();
408
+ } catch (error) {
409
+ session.liveFailed = true;
410
+ if (!session.transcribe) throw error;
411
+ } finally {
412
+ session.live.cancel();
413
+ }
414
+ }
415
+ if (!isCurrent(session)) return null;
416
+ if (text === void 0) {
417
+ if (!session.transcribe) throw new Error("Live transcription could not finish. Please record again.");
418
+ if (!session.blob.size) throw new Error("No audio was recorded. Try again.");
419
+ text = (await session.transcribe(session.blob, { language: session.language, signal: session.controller.signal })).trim();
420
+ }
362
421
  if (!isCurrent(session)) return null;
363
422
  if (!text) throw new Error("No speech was detected. Record again.");
364
423
  } catch (error) {
@@ -366,13 +425,14 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
366
425
  releaseTracks(session);
367
426
  session.phase = "error";
368
427
  const message = error instanceof Error && error.message ? error.message : "Transcription failed. Try again.";
369
- setSnapshot({ ...EMPTY, state: "error", error: message, canRetry: !!_optionalChain([session, 'access', _48 => _48.blob, 'optionalAccess', _49 => _49.size]) });
428
+ _optionalChain([session, 'access', _64 => _64.live, 'optionalAccess', _65 => _65.cancel, 'call', _66 => _66()]);
429
+ setSnapshot((previous) => ({ ...previous, state: "error", error: message, canRetry: !!session.transcribe && !!_optionalChain([session, 'access', _67 => _67.blob, 'optionalAccess', _68 => _68.size]) }));
370
430
  return null;
371
431
  }
372
432
  session.transcript = text;
373
433
  session.phase = "ready";
374
434
  setSnapshot({ ...EMPTY, state: "ready", transcript: text });
375
- _optionalChain([callback, 'access', _50 => _50.current, 'optionalCall', _51 => _51(text)]);
435
+ _optionalChain([callback, 'access', _69 => _69.current, 'optionalCall', _70 => _70(text)]);
376
436
  return text;
377
437
  }, [isCurrent]);
378
438
  const stop = _react.useCallback.call(void 0, () => {
@@ -383,8 +443,13 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
383
443
  return Promise.resolve(null);
384
444
  }
385
445
  if (session.phase === "requesting") {
386
- cancel();
387
- return Promise.resolve(null);
446
+ if (!session.transcribe || _optionalChain([session, 'access', _71 => _71.recorder, 'optionalAccess', _72 => _72.state]) !== "recording") {
447
+ cancel();
448
+ return Promise.resolve(null);
449
+ }
450
+ session.liveFailed = true;
451
+ _optionalChain([session, 'access', _73 => _73.streamController, 'optionalAccess', _74 => _74.abort, 'call', _75 => _75()]);
452
+ _optionalChain([session, 'access', _76 => _76.live, 'optionalAccess', _77 => _77.cancel, 'call', _78 => _78()]);
388
453
  }
389
454
  if (session.finish) return session.finish;
390
455
  if (session.phase === "error") return Promise.resolve(null);
@@ -397,7 +462,7 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
397
462
  cancel();
398
463
  return Promise.resolve(null);
399
464
  }
400
- if (!_optionalChain([session, 'optionalAccess', _52 => _52.transcribe]) || !_optionalChain([session, 'access', _53 => _53.blob, 'optionalAccess', _54 => _54.size])) return Promise.resolve(null);
465
+ if (!_optionalChain([session, 'optionalAccess', _79 => _79.transcribe]) || !_optionalChain([session, 'access', _80 => _80.blob, 'optionalAccess', _81 => _81.size])) return Promise.resolve(null);
401
466
  if (session.phase !== "error") return _nullishCoalesce(session.finish, () => ( Promise.resolve(null)));
402
467
  session.finish = finish(session);
403
468
  return session.finish;
@@ -10,7 +10,7 @@ import {
10
10
  } from "./chunk-G6SRSWXB.js";
11
11
  import {
12
12
  useDictation
13
- } from "./chunk-HLAHOVEI.js";
13
+ } from "./chunk-RPSRCB5O.js";
14
14
 
15
15
  // src/primitives/voice-dictation-button.tsx
16
16
  import {
@@ -123,7 +123,7 @@ function VoiceDictationButton({
123
123
  /* @__PURE__ */ jsx("h2", { className: cn("font-medium", anchored ? "text-sm" : "text-lg"), children: title || "Voice dictation" }),
124
124
  /* @__PURE__ */ jsxs("span", { role: "status", className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
125
125
  busy ? /* @__PURE__ */ jsx(LoaderCircle, { "aria-hidden": "true", className: "size-3.5 animate-spin motion-reduce:animate-none" }) : /* @__PURE__ */ jsx(Mic, { "aria-hidden": "true", className: "size-3.5" }),
126
- capture.state === "transcribing" ? "Transcribing\u2026" : capture.state === "requesting" ? "Waiting for microphone\u2026" : error ? "Recording not saved" : capture.state === "ready" ? "Ready" : caption
126
+ capture.state === "transcribing" ? "Finishing transcript\u2026" : capture.state === "requesting" ? "Starting dictation\u2026" : error ? "Recording not saved" : capture.state === "ready" ? "Ready" : caption
127
127
  ] })
128
128
  ] }),
129
129
  /* @__PURE__ */ jsxs("time", { "aria-label": "Recording duration", dateTime: `PT${elapsed}S`, className: "pt-0.5 font-mono text-xs tabular-nums text-muted-foreground", children: [
@@ -134,7 +134,7 @@ function VoiceDictationButton({
134
134
  ] }),
135
135
  /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-0 flex-col gap-3", !anchored && "w-full max-w-xl flex-1 justify-center self-center py-8 text-center"), children: [
136
136
  capture.audioLevels && /* @__PURE__ */ jsx(DictationWaveform, { source: capture.audioLevels, state: capture.state, className: !anchored ? "h-20" : void 0 }),
137
- /* @__PURE__ */ jsx("p", { id: descriptionId, className: "text-xs text-muted-foreground", children: capture.state === "transcribing" ? "Converting your recording to text\u2026" : capture.state === "requesting" ? "Allow microphone access to start." : error ? capture.canRetry ? "Retry this take or record again." : "Check microphone access, then try again." : "Speak, then choose Done to add the text." }),
137
+ /* @__PURE__ */ jsx("p", { id: descriptionId, className: "text-xs text-muted-foreground", children: capture.state === "transcribing" ? "Finishing your last words\u2026" : capture.state === "requesting" ? "Allow microphone access to start." : error ? capture.canRetry ? "Retry this take or record again." : "Try recording again." : "Speak, then choose Done to add the text." }),
138
138
  displayTranscript && /* @__PURE__ */ jsxs("p", { className: "max-h-40 overflow-y-auto text-sm leading-relaxed", children: [
139
139
  transcript,
140
140
  interim && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
@@ -142,7 +142,8 @@ function VoiceDictationButton({
142
142
  interim
143
143
  ] })
144
144
  ] }),
145
- error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs text-destructive", children: error })
145
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs text-destructive", children: error }),
146
+ !error && capture.previewError && /* @__PURE__ */ jsx("p", { role: "status", className: "text-xs text-muted-foreground", children: capture.previewError })
146
147
  ] }),
147
148
  /* @__PURE__ */ jsxs("div", { className: cn("gap-2", anchored ? "flex flex-wrap items-center justify-end" : "mt-auto grid w-full max-w-xl grid-cols-2 gap-3 self-center"), children: [
148
149
  /* @__PURE__ */ jsx(Button, { ref: cancelControl, type: "button", variant: "ghost", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: handleCancel, children: "Cancel" }),
@@ -10,7 +10,7 @@ var _chunkJDEXZGIYcjs = require('./chunk-JDEXZGIY.cjs');
10
10
  var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
11
11
 
12
12
 
13
- var _chunkZZ7ICQR2cjs = require('./chunk-ZZ7ICQR2.cjs');
13
+ var _chunk7Q4EFCUIcjs = require('./chunk-7Q4EFCUI.cjs');
14
14
 
15
15
  // src/primitives/voice-dictation-button.tsx
16
16
 
@@ -50,7 +50,7 @@ function VoiceDictationButton({
50
50
  const isOpen = _react.useRef.call(void 0, open);
51
51
  isOpen.current = open;
52
52
  const descriptionId = _react.useId.call(void 0, );
53
- const capture = _chunkZZ7ICQR2cjs.useDictation.call(void 0, { lang, continuous });
53
+ const capture = _chunk7Q4EFCUIcjs.useDictation.call(void 0, { lang, continuous });
54
54
  const { transcript, interimTranscript: interim, supported, error } = capture;
55
55
  const generation = _react.useRef.call(void 0, 0);
56
56
  const finishing = _react.useRef.call(void 0, false);
@@ -123,7 +123,7 @@ function VoiceDictationButton({
123
123
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "h2", { className: _chunkKWLSBTB2cjs.cn.call(void 0, "font-medium", anchored ? "text-sm" : "text-lg"), children: title || "Voice dictation" }),
124
124
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { role: "status", className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
125
125
  busy ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.LoaderCircle, { "aria-hidden": "true", className: "size-3.5 animate-spin motion-reduce:animate-none" }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Mic, { "aria-hidden": "true", className: "size-3.5" }),
126
- capture.state === "transcribing" ? "Transcribing\u2026" : capture.state === "requesting" ? "Waiting for microphone\u2026" : error ? "Recording not saved" : capture.state === "ready" ? "Ready" : caption
126
+ capture.state === "transcribing" ? "Finishing transcript\u2026" : capture.state === "requesting" ? "Starting dictation\u2026" : error ? "Recording not saved" : capture.state === "ready" ? "Ready" : caption
127
127
  ] })
128
128
  ] }),
129
129
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "time", { "aria-label": "Recording duration", dateTime: `PT${elapsed}S`, className: "pt-0.5 font-mono text-xs tabular-nums text-muted-foreground", children: [
@@ -134,7 +134,7 @@ function VoiceDictationButton({
134
134
  ] }),
135
135
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: _chunkKWLSBTB2cjs.cn.call(void 0, "flex min-h-0 flex-col gap-3", !anchored && "w-full max-w-xl flex-1 justify-center self-center py-8 text-center"), children: [
136
136
  capture.audioLevels && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, DictationWaveform, { source: capture.audioLevels, state: capture.state, className: !anchored ? "h-20" : void 0 }),
137
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { id: descriptionId, className: "text-xs text-muted-foreground", children: capture.state === "transcribing" ? "Converting your recording to text\u2026" : capture.state === "requesting" ? "Allow microphone access to start." : error ? capture.canRetry ? "Retry this take or record again." : "Check microphone access, then try again." : "Speak, then choose Done to add the text." }),
137
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { id: descriptionId, className: "text-xs text-muted-foreground", children: capture.state === "transcribing" ? "Finishing your last words\u2026" : capture.state === "requesting" ? "Allow microphone access to start." : error ? capture.canRetry ? "Retry this take or record again." : "Try recording again." : "Speak, then choose Done to add the text." }),
138
138
  displayTranscript && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "p", { className: "max-h-40 overflow-y-auto text-sm leading-relaxed", children: [
139
139
  transcript,
140
140
  interim && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { className: "text-muted-foreground", children: [
@@ -142,7 +142,8 @@ function VoiceDictationButton({
142
142
  interim
143
143
  ] })
144
144
  ] }),
145
- error && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { role: "alert", className: "text-xs text-destructive", children: error })
145
+ error && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { role: "alert", className: "text-xs text-destructive", children: error }),
146
+ !error && capture.previewError && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { role: "status", className: "text-xs text-muted-foreground", children: capture.previewError })
146
147
  ] }),
147
148
  /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: _chunkKWLSBTB2cjs.cn.call(void 0, "gap-2", anchored ? "flex flex-wrap items-center justify-end" : "mt-auto grid w-full max-w-xl grid-cols-2 gap-3 self-center"), children: [
148
149
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkJDEXZGIYcjs.Button, { ref: cancelControl, type: "button", variant: "ghost", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: handleCancel, children: "Cancel" }),
@@ -3,11 +3,11 @@
3
3
  // src/hooks/use-dictation.ts
4
4
  import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
5
  var DictationContext = createContext(null);
6
- function DictationProvider({ transcribe, scopeKey, children }) {
6
+ function DictationProvider({ transcribe, stream, scopeKey, children }) {
7
7
  const scope = useRef({ key: scopeKey, generation: 0 });
8
8
  if (scope.current.key !== scopeKey) scope.current = { key: scopeKey, generation: scope.current.generation + 1 };
9
9
  const generation = scope.current.generation;
10
- const value = useMemo(() => ({ transcribe, scope, generation }), [transcribe, generation]);
10
+ const value = useMemo(() => ({ transcribe, stream, scope, generation }), [transcribe, stream, generation]);
11
11
  return createElement(DictationContext.Provider, { value }, children);
12
12
  }
13
13
  var EMPTY = { state: "idle", transcript: "", interimTranscript: "", error: null, canRetry: false };
@@ -128,6 +128,8 @@ function releaseTracks(session) {
128
128
  }
129
129
  function dispose(session) {
130
130
  session.controller.abort();
131
+ session.streamController?.abort();
132
+ session.live?.cancel();
131
133
  if (session.nativeTimer) clearTimeout(session.nativeTimer);
132
134
  const recorder = session.recorder;
133
135
  if (recorder) {
@@ -158,6 +160,7 @@ function dispose(session) {
158
160
  function useDictation({ lang = "en-US", continuous = true, onTranscript } = {}) {
159
161
  const provider = useContext(DictationContext);
160
162
  const transcribe = provider?.transcribe ?? null;
163
+ const startStream = provider?.stream ?? null;
161
164
  const scope = provider?.scope ?? null;
162
165
  const scopeGeneration = provider?.generation ?? 0;
163
166
  const [snapshot, setSnapshot] = useState(EMPTY);
@@ -169,8 +172,8 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
169
172
  activeScope.current = scope;
170
173
  callback.current = onTranscript;
171
174
  useEffect(() => {
172
- setSupported(transcribe ? typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" : typeof recognitionConstructor() === "function");
173
- }, [transcribe]);
175
+ setSupported(transcribe || startStream ? typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" : typeof recognitionConstructor() === "function");
176
+ }, [transcribe, startStream]);
174
177
  const cancel = useCallback(() => {
175
178
  const session = current.current;
176
179
  current.current = null;
@@ -200,9 +203,12 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
200
203
  const session = {
201
204
  language: lang,
202
205
  transcribe,
206
+ startStream,
207
+ liveFailed: false,
203
208
  scope,
204
209
  scopeGeneration,
205
210
  controller: new AbortController(),
211
+ streamController: startStream ? new AbortController() : void 0,
206
212
  phase: "requesting",
207
213
  chunks: [],
208
214
  capture: new Promise((resolve) => {
@@ -217,11 +223,11 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
217
223
  transcript: "",
218
224
  interim: "",
219
225
  notified: /* @__PURE__ */ new Set(),
220
- audioLevels: transcribe ? createAudioLevels() : void 0
226
+ audioLevels: transcribe || startStream ? createAudioLevels() : void 0
221
227
  };
222
228
  current.current = session;
223
229
  setSnapshot({ ...EMPTY, state: "requesting" });
224
- if (transcribe) {
230
+ if (transcribe || startStream) {
225
231
  try {
226
232
  if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") throw new Error("unsupported");
227
233
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
@@ -250,16 +256,53 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
250
256
  };
251
257
  recorder.onerror = () => {
252
258
  if (!isCurrent(session)) return;
259
+ session.liveFailed = true;
260
+ session.live?.cancel();
253
261
  releaseTracks(session);
254
262
  session.phase = "error";
255
263
  session.resolveCapture(null);
256
264
  setSnapshot({ ...EMPTY, state: "error", error: "Recording was interrupted. Try again." });
257
265
  };
258
266
  recorder.start();
267
+ if (startStream) {
268
+ const failed = (error) => {
269
+ if (!isCurrent(session) || session.phase === "ready" || session.phase === "error") return;
270
+ session.liveFailed = true;
271
+ session.live?.cancel();
272
+ if (transcribe) {
273
+ setSnapshot((previous) => ({ ...previous, previewError: "Live text is unavailable. Choose Done to transcribe this recording." }));
274
+ } else {
275
+ releaseTracks(session);
276
+ if (recorder.state !== "inactive") recorder.stop();
277
+ session.phase = "error";
278
+ setSnapshot((previous) => ({ ...previous, state: "error", error: error.message || "Live transcription stopped. Try again." }));
279
+ }
280
+ };
281
+ try {
282
+ const live = await startStream(stream, {
283
+ language: lang,
284
+ signal: session.streamController.signal,
285
+ onTranscript: (preview) => {
286
+ if (!isCurrent(session) || session.liveFailed || session.phase === "error" || session.phase === "ready") return;
287
+ session.transcript = preview.transcript;
288
+ session.interim = preview.interimTranscript;
289
+ setSnapshot((previous) => ({ ...previous, transcript: preview.transcript, interimTranscript: preview.interimTranscript }));
290
+ },
291
+ onError: failed
292
+ });
293
+ if (!isCurrent(session) || session.liveFailed || session.phase === "error") {
294
+ live.cancel();
295
+ } else session.live = live;
296
+ } catch (error) {
297
+ failed(error instanceof Error ? error : new Error("Live transcription could not start. Try again."));
298
+ }
299
+ if (!isCurrent(session) || session.phase !== "requesting") return;
300
+ }
259
301
  session.phase = "recording";
260
302
  session.stopMeter = meterStream(stream, session.audioLevels, () => isCurrent(session) && session.phase === "recording");
261
- setSnapshot({ ...EMPTY, state: "recording" });
303
+ setSnapshot((previous) => ({ ...previous, state: "recording" }));
262
304
  } catch (error) {
305
+ session.live?.cancel();
263
306
  releaseTracks(session);
264
307
  if (!isCurrent(session)) return;
265
308
  session.phase = "error";
@@ -320,13 +363,13 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
320
363
  session.phase = "error";
321
364
  setSnapshot({ ...EMPTY, state: "error", error: microphoneError(error) });
322
365
  }
323
- }, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe]);
366
+ }, [cancel, continuous, isCurrent, lang, scope, scopeGeneration, transcribe, startStream]);
324
367
  const finish = useCallback(async (session) => {
325
368
  if (!isCurrent(session)) return null;
326
369
  releaseMeter(session);
327
370
  session.phase = "transcribing";
328
371
  setSnapshot((previous) => ({ ...previous, state: "transcribing", error: null, canRetry: false }));
329
- if (!session.transcribe) {
372
+ if (!session.transcribe && !session.startStream) {
330
373
  if (!session.nativeEnded) {
331
374
  session.nativeTimer = setTimeout(() => {
332
375
  try {
@@ -357,8 +400,24 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
357
400
  if (!isCurrent(session) || !blob) return null;
358
401
  session.blob = blob;
359
402
  }
360
- if (!session.blob.size) throw new Error("No audio was recorded. Try again.");
361
- text = (await session.transcribe(session.blob, { language: session.language, signal: session.controller.signal })).trim();
403
+ if (session.live && !session.liveFailed) {
404
+ try {
405
+ const final = await session.live.finish();
406
+ if (session.liveFailed) throw new Error("Live transcription was interrupted. Please retry this recording.");
407
+ text = final.trim();
408
+ } catch (error) {
409
+ session.liveFailed = true;
410
+ if (!session.transcribe) throw error;
411
+ } finally {
412
+ session.live.cancel();
413
+ }
414
+ }
415
+ if (!isCurrent(session)) return null;
416
+ if (text === void 0) {
417
+ if (!session.transcribe) throw new Error("Live transcription could not finish. Please record again.");
418
+ if (!session.blob.size) throw new Error("No audio was recorded. Try again.");
419
+ text = (await session.transcribe(session.blob, { language: session.language, signal: session.controller.signal })).trim();
420
+ }
362
421
  if (!isCurrent(session)) return null;
363
422
  if (!text) throw new Error("No speech was detected. Record again.");
364
423
  } catch (error) {
@@ -366,7 +425,8 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
366
425
  releaseTracks(session);
367
426
  session.phase = "error";
368
427
  const message = error instanceof Error && error.message ? error.message : "Transcription failed. Try again.";
369
- setSnapshot({ ...EMPTY, state: "error", error: message, canRetry: !!session.blob?.size });
428
+ session.live?.cancel();
429
+ setSnapshot((previous) => ({ ...previous, state: "error", error: message, canRetry: !!session.transcribe && !!session.blob?.size }));
370
430
  return null;
371
431
  }
372
432
  session.transcript = text;
@@ -383,8 +443,13 @@ function useDictation({ lang = "en-US", continuous = true, onTranscript } = {})
383
443
  return Promise.resolve(null);
384
444
  }
385
445
  if (session.phase === "requesting") {
386
- cancel();
387
- return Promise.resolve(null);
446
+ if (!session.transcribe || session.recorder?.state !== "recording") {
447
+ cancel();
448
+ return Promise.resolve(null);
449
+ }
450
+ session.liveFailed = true;
451
+ session.streamController?.abort();
452
+ session.live?.cancel();
388
453
  }
389
454
  if (session.finish) return session.finish;
390
455
  if (session.phase === "error") return Promise.resolve(null);
@@ -3,8 +3,8 @@
3
3
 
4
4
 
5
5
 
6
- var _chunkZZ7ICQR2cjs = require('../chunk-ZZ7ICQR2.cjs');
6
+ var _chunk7Q4EFCUIcjs = require('../chunk-7Q4EFCUI.cjs');
7
7
 
8
8
 
9
9
 
10
- exports.DictationProvider = _chunkZZ7ICQR2cjs.DictationProvider; exports.useDictation = _chunkZZ7ICQR2cjs.useDictation;
10
+ exports.DictationProvider = _chunk7Q4EFCUIcjs.DictationProvider; exports.useDictation = _chunk7Q4EFCUIcjs.useDictation;
@@ -9,8 +9,27 @@ interface DictationTranscribeOptions {
9
9
  }
10
10
  /** The host authenticates this request; provider credentials never belong in the kit. */
11
11
  type DictationTranscribe = (audio: Blob, options: DictationTranscribeOptions) => Promise<string>;
12
+ interface DictationStreamOptions extends DictationTranscribeOptions {
13
+ /** Replace the current preview. These updates must never save a note. */
14
+ onTranscript: (preview: {
15
+ transcript: string;
16
+ interimTranscript: string;
17
+ }) => void;
18
+ onError: (error: Error) => void;
19
+ }
20
+ interface DictationStreamSession {
21
+ /** Drain the final audio and resolve the complete, final transcript. */
22
+ finish: () => Promise<string>;
23
+ /** Release the transport without committing any text. Must be idempotent. */
24
+ cancel: () => void;
25
+ }
26
+ /** The host owns transport/authentication; use the supplied microphone stream. */
27
+ type DictationStream = (audio: MediaStream, options: DictationStreamOptions) => Promise<DictationStreamSession>;
12
28
  interface DictationProviderProps {
13
- transcribe: DictationTranscribe;
29
+ /** Complete-recording transcription, also used to retry a failed stream. */
30
+ transcribe?: DictationTranscribe;
31
+ /** Optional live transcription. Preview events never invoke onTranscript. */
32
+ stream?: DictationStream;
14
33
  /** Change when the acting user or workspace changes to discard its active take. */
15
34
  scopeKey?: string;
16
35
  children: ReactNode;
@@ -22,12 +41,13 @@ type DictationScope = {
22
41
  };
23
42
  };
24
43
  type DictationContextValue = {
25
- transcribe: DictationTranscribe;
44
+ transcribe?: DictationTranscribe;
45
+ stream?: DictationStream;
26
46
  scope: DictationScope;
27
47
  generation: number;
28
48
  };
29
- /** Use recorded audio + the host's API for every descendant dictation control. */
30
- declare function DictationProvider({ transcribe, scopeKey, children }: DictationProviderProps): React.FunctionComponentElement<React.ProviderProps<DictationContextValue | null>>;
49
+ /** Use the host's live or recorded-audio API for every descendant control. */
50
+ declare function DictationProvider({ transcribe, stream, scopeKey, children }: DictationProviderProps): React.FunctionComponentElement<React.ProviderProps<DictationContextValue | null>>;
31
51
  type DictationState = "idle" | "requesting" | "recording" | "transcribing" | "ready" | "error";
32
52
  interface UseDictationOptions {
33
53
  lang?: string;
@@ -42,6 +62,8 @@ interface DictationCapture {
42
62
  transcript: string;
43
63
  interimTranscript: string;
44
64
  error: string | null;
65
+ /** A live preview failed, but the recorded take can still be transcribed on Done. */
66
+ previewError?: string;
45
67
  canRetry: boolean;
46
68
  /** Live bands from the recording stream. Native speech recognition exposes no audio meter. */
47
69
  audioLevels?: {
@@ -58,4 +80,4 @@ interface DictationCapture {
58
80
  /** Shared microphone lifecycle. Without a provider, existing native dictation keeps working. */
59
81
  declare function useDictation({ lang, continuous, onTranscript }?: UseDictationOptions): DictationCapture;
60
82
 
61
- export { type DictationCapture, DictationProvider, type DictationProviderProps, type DictationState, type DictationTranscribe, type DictationTranscribeOptions, type UseDictationOptions, useDictation };
83
+ export { type DictationCapture, DictationProvider, type DictationProviderProps, type DictationState, type DictationStream, type DictationStreamOptions, type DictationStreamSession, type DictationTranscribe, type DictationTranscribeOptions, type UseDictationOptions, useDictation };
@@ -9,8 +9,27 @@ interface DictationTranscribeOptions {
9
9
  }
10
10
  /** The host authenticates this request; provider credentials never belong in the kit. */
11
11
  type DictationTranscribe = (audio: Blob, options: DictationTranscribeOptions) => Promise<string>;
12
+ interface DictationStreamOptions extends DictationTranscribeOptions {
13
+ /** Replace the current preview. These updates must never save a note. */
14
+ onTranscript: (preview: {
15
+ transcript: string;
16
+ interimTranscript: string;
17
+ }) => void;
18
+ onError: (error: Error) => void;
19
+ }
20
+ interface DictationStreamSession {
21
+ /** Drain the final audio and resolve the complete, final transcript. */
22
+ finish: () => Promise<string>;
23
+ /** Release the transport without committing any text. Must be idempotent. */
24
+ cancel: () => void;
25
+ }
26
+ /** The host owns transport/authentication; use the supplied microphone stream. */
27
+ type DictationStream = (audio: MediaStream, options: DictationStreamOptions) => Promise<DictationStreamSession>;
12
28
  interface DictationProviderProps {
13
- transcribe: DictationTranscribe;
29
+ /** Complete-recording transcription, also used to retry a failed stream. */
30
+ transcribe?: DictationTranscribe;
31
+ /** Optional live transcription. Preview events never invoke onTranscript. */
32
+ stream?: DictationStream;
14
33
  /** Change when the acting user or workspace changes to discard its active take. */
15
34
  scopeKey?: string;
16
35
  children: ReactNode;
@@ -22,12 +41,13 @@ type DictationScope = {
22
41
  };
23
42
  };
24
43
  type DictationContextValue = {
25
- transcribe: DictationTranscribe;
44
+ transcribe?: DictationTranscribe;
45
+ stream?: DictationStream;
26
46
  scope: DictationScope;
27
47
  generation: number;
28
48
  };
29
- /** Use recorded audio + the host's API for every descendant dictation control. */
30
- declare function DictationProvider({ transcribe, scopeKey, children }: DictationProviderProps): React.FunctionComponentElement<React.ProviderProps<DictationContextValue | null>>;
49
+ /** Use the host's live or recorded-audio API for every descendant control. */
50
+ declare function DictationProvider({ transcribe, stream, scopeKey, children }: DictationProviderProps): React.FunctionComponentElement<React.ProviderProps<DictationContextValue | null>>;
31
51
  type DictationState = "idle" | "requesting" | "recording" | "transcribing" | "ready" | "error";
32
52
  interface UseDictationOptions {
33
53
  lang?: string;
@@ -42,6 +62,8 @@ interface DictationCapture {
42
62
  transcript: string;
43
63
  interimTranscript: string;
44
64
  error: string | null;
65
+ /** A live preview failed, but the recorded take can still be transcribed on Done. */
66
+ previewError?: string;
45
67
  canRetry: boolean;
46
68
  /** Live bands from the recording stream. Native speech recognition exposes no audio meter. */
47
69
  audioLevels?: {
@@ -58,4 +80,4 @@ interface DictationCapture {
58
80
  /** Shared microphone lifecycle. Without a provider, existing native dictation keeps working. */
59
81
  declare function useDictation({ lang, continuous, onTranscript }?: UseDictationOptions): DictationCapture;
60
82
 
61
- export { type DictationCapture, DictationProvider, type DictationProviderProps, type DictationState, type DictationTranscribe, type DictationTranscribeOptions, type UseDictationOptions, useDictation };
83
+ export { type DictationCapture, DictationProvider, type DictationProviderProps, type DictationState, type DictationStream, type DictationStreamOptions, type DictationStreamSession, type DictationTranscribe, type DictationTranscribeOptions, type UseDictationOptions, useDictation };
@@ -3,7 +3,7 @@
3
3
  import {
4
4
  DictationProvider,
5
5
  useDictation
6
- } from "../chunk-HLAHOVEI.js";
6
+ } from "../chunk-RPSRCB5O.js";
7
7
  export {
8
8
  DictationProvider,
9
9
  useDictation
@@ -10,7 +10,7 @@ var _chunkYMI3X2YLcjs = require('../chunk-YMI3X2YL.cjs');
10
10
  var _chunk2BTDBZYNcjs = require('../chunk-2BTDBZYN.cjs');
11
11
 
12
12
 
13
- var _chunkD47AMKT4cjs = require('../chunk-D47AMKT4.cjs');
13
+ var _chunkQWUMIAYDcjs = require('../chunk-QWUMIAYD.cjs');
14
14
 
15
15
 
16
16
 
@@ -86,7 +86,7 @@ require('../chunk-2BSFYU22.cjs');
86
86
 
87
87
 
88
88
  var _chunk4LXN6GLZcjs = require('../chunk-4LXN6GLZ.cjs');
89
- require('../chunk-ZZ7ICQR2.cjs');
89
+ require('../chunk-7Q4EFCUI.cjs');
90
90
 
91
91
 
92
92
  var _chunkR35FT53Gcjs = require('../chunk-R35FT53G.cjs');
@@ -402,8 +402,9 @@ function ChatInput({
402
402
  const [editing, setEditing] = _react.useState.call(void 0, false);
403
403
  const focusVersion = _react.useRef.call(void 0, 0);
404
404
  const isDisabled = pending || disabled;
405
+ const hasText = Boolean(value.trim());
405
406
  const attachmentsReady = attachments.length > 0 && attachments.every((item) => item.state !== "uploading");
406
- const canSubmit = !isDisabled && (Boolean(value.trim()) || attachmentsReady);
407
+ const canSubmit = !isDisabled && (hasText || attachmentsReady);
407
408
  const liveVoiceActive = voiceSession && voiceSession.state !== "disconnected" && voiceSession.state !== "error";
408
409
  const submit = () => {
409
410
  const trimmed = value.trim();
@@ -541,7 +542,7 @@ function ChatInput({
541
542
  ) : null,
542
543
  (!helperText || compact) && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "flex-1" }),
543
544
  voice && /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
544
- _chunkD47AMKT4cjs.VoiceDictationButton,
545
+ _chunkQWUMIAYDcjs.VoiceDictationButton,
545
546
  {
546
547
  size: "icon",
547
548
  title: voiceTitle,
@@ -555,8 +556,7 @@ function ChatInput({
555
556
  )
556
557
  }
557
558
  ),
558
- voiceSession && !liveVoiceActive && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkYMI3X2YLcjs.VoiceSessionControls, { ...voiceSession, disabled: voiceSession.disabled || isDisabled }),
559
- submitLabel ? /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
559
+ voiceSession && !liveVoiceActive && !hasText && attachments.length === 0 ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkYMI3X2YLcjs.VoiceSessionControls, { ...voiceSession, disabled: voiceSession.disabled || isDisabled }) : submitLabel ? /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
560
560
  _chunkJDEXZGIYcjs.Button,
561
561
  {
562
562
  type: "submit",
@@ -572,11 +572,15 @@ function ChatInput({
572
572
  _chunkJDEXZGIYcjs.Button,
573
573
  {
574
574
  type: "submit",
575
- size: spacious && !compact ? "icon-lg" : "icon",
575
+ size: !voiceSession && spacious && !compact ? "icon-lg" : "icon",
576
+ shape: voiceSession ? "circle" : void 0,
576
577
  disabled: !canSubmit,
577
578
  "aria-label": "Send",
578
579
  "data-chat-action": "submit",
579
- className: _chunkKWLSBTB2cjs.cn.call(void 0, spacious && "rounded-xl", compactOnBlur && "min-h-[44px] min-w-[44px] shrink-0"),
580
+ className: _chunkKWLSBTB2cjs.cn.call(void 0,
581
+ spacious && !voiceSession && "rounded-xl",
582
+ (voiceSession || compactOnBlur) && "min-h-[44px] min-w-[44px] shrink-0"
583
+ ),
580
584
  children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.ArrowUp, { className: _chunkKWLSBTB2cjs.cn.call(void 0, spacious && "h-5 w-5") })
581
585
  }
582
586
  )
@@ -616,7 +620,7 @@ function VoiceHero({
616
620
  voiceSession ? /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
617
621
  /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkYMI3X2YLcjs.VoiceSessionControls, { ...voiceSession, size: "hero" }),
618
622
  voice && /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
619
- _chunkD47AMKT4cjs.VoiceDictationButton,
623
+ _chunkQWUMIAYDcjs.VoiceDictationButton,
620
624
  {
621
625
  size: "icon",
622
626
  title: voiceTitle,
@@ -626,7 +630,7 @@ function VoiceHero({
626
630
  }
627
631
  )
628
632
  ] }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
629
- _chunkD47AMKT4cjs.VoiceDictationButton,
633
+ _chunkQWUMIAYDcjs.VoiceDictationButton,
630
634
  {
631
635
  size: "lg",
632
636
  title: voiceTitle,
@@ -154,7 +154,11 @@ interface ChatInputProps {
154
154
  uploadingLabel?: string;
155
155
  /** Show the inline voice dictation button. Defaults to true. */
156
156
  voice?: boolean;
157
- /** A separate live voice control alongside dictation. The host owns its transport and lifecycle. */
157
+ /**
158
+ * Live voice controls, separate from dictation. With no text or staged files,
159
+ * the round voice action replaces Send; an active call keeps its controls
160
+ * above the input. The host owns the transport and lifecycle.
161
+ */
158
162
  voiceSession?: VoiceSessionControlsProps;
159
163
  /** Title shown inside the voice overlay. */
160
164
  voiceTitle?: string;
@@ -164,6 +168,7 @@ interface ChatInputProps {
164
168
  * Label for the submit button. When set, the button renders as a labelled
165
169
  * primary action (`{label} →`) instead of the default icon-only send — use
166
170
  * it to name the outcome, e.g. "Propose 3 times", "Create link", "Send".
171
+ * An empty composer with live voice still shows the voice action first.
167
172
  */
168
173
  submitLabel?: ReactNode;
169
174
  /** Auto-focus the textarea on mount. */
@@ -200,9 +205,10 @@ interface ChatInputProps {
200
205
  /**
201
206
  * Compact text input: a form around one bordered input group, with any
202
207
  * staged files on top, a textarea that grows with its text, then an action
203
- * bar (the file menu and voice mic on the left, the submit button on the
204
- * right). Always text-first; voice is a secondary affordance. Enter sends;
205
- * Shift+Enter newlines, and Enter that commits an IME candidate never sends.
208
+ * bar ending in one primary action. When live voice is available, an empty
209
+ * composer shows its round start action; text or staged files replace it
210
+ * with Send. Dictation remains a separate mic. Enter sends; Shift+Enter
211
+ * newlines, and Enter that commits an IME candidate never sends or starts a call.
206
212
  *
207
213
  * The submit button is icon-only by default; pass `submitLabel` to name the
208
214
  * outcome ("Propose 3 times", "Create link") — it then renders as a labelled
@@ -294,7 +300,8 @@ interface ChatProps {
294
300
  * Label for the submit button (empty-state input). When set, the button
295
301
  * names the outcome (`{label} →`) instead of the icon-only send — e.g.
296
302
  * "Propose 3 times". Once the conversation is active the input reverts to
297
- * the compact icon send.
303
+ * the compact icon send. With live voice available, an empty composer shows
304
+ * the voice action until text or files are staged.
298
305
  */
299
306
  submitLabel?: ReactNode;
300
307
  /** Files currently staged in the composer. */
@@ -357,7 +364,7 @@ interface ChatProps {
357
364
  fileDropDescription?: ReactNode;
358
365
  /** Enable voice dictation. Defaults to true. */
359
366
  voice?: boolean;
360
- /** Controlled live voice UI, used instead of browser dictation when provided. */
367
+ /** Controlled live voice UI; its empty-input action is separate from the dictation mic. */
361
368
  voiceSession?: VoiceSessionControlsProps;
362
369
  /** Title shown inside the voice overlay. */
363
370
  voiceTitle?: string;
@@ -154,7 +154,11 @@ interface ChatInputProps {
154
154
  uploadingLabel?: string;
155
155
  /** Show the inline voice dictation button. Defaults to true. */
156
156
  voice?: boolean;
157
- /** A separate live voice control alongside dictation. The host owns its transport and lifecycle. */
157
+ /**
158
+ * Live voice controls, separate from dictation. With no text or staged files,
159
+ * the round voice action replaces Send; an active call keeps its controls
160
+ * above the input. The host owns the transport and lifecycle.
161
+ */
158
162
  voiceSession?: VoiceSessionControlsProps;
159
163
  /** Title shown inside the voice overlay. */
160
164
  voiceTitle?: string;
@@ -164,6 +168,7 @@ interface ChatInputProps {
164
168
  * Label for the submit button. When set, the button renders as a labelled
165
169
  * primary action (`{label} →`) instead of the default icon-only send — use
166
170
  * it to name the outcome, e.g. "Propose 3 times", "Create link", "Send".
171
+ * An empty composer with live voice still shows the voice action first.
167
172
  */
168
173
  submitLabel?: ReactNode;
169
174
  /** Auto-focus the textarea on mount. */
@@ -200,9 +205,10 @@ interface ChatInputProps {
200
205
  /**
201
206
  * Compact text input: a form around one bordered input group, with any
202
207
  * staged files on top, a textarea that grows with its text, then an action
203
- * bar (the file menu and voice mic on the left, the submit button on the
204
- * right). Always text-first; voice is a secondary affordance. Enter sends;
205
- * Shift+Enter newlines, and Enter that commits an IME candidate never sends.
208
+ * bar ending in one primary action. When live voice is available, an empty
209
+ * composer shows its round start action; text or staged files replace it
210
+ * with Send. Dictation remains a separate mic. Enter sends; Shift+Enter
211
+ * newlines, and Enter that commits an IME candidate never sends or starts a call.
206
212
  *
207
213
  * The submit button is icon-only by default; pass `submitLabel` to name the
208
214
  * outcome ("Propose 3 times", "Create link") — it then renders as a labelled
@@ -294,7 +300,8 @@ interface ChatProps {
294
300
  * Label for the submit button (empty-state input). When set, the button
295
301
  * names the outcome (`{label} →`) instead of the icon-only send — e.g.
296
302
  * "Propose 3 times". Once the conversation is active the input reverts to
297
- * the compact icon send.
303
+ * the compact icon send. With live voice available, an empty composer shows
304
+ * the voice action until text or files are staged.
298
305
  */
299
306
  submitLabel?: ReactNode;
300
307
  /** Files currently staged in the composer. */
@@ -357,7 +364,7 @@ interface ChatProps {
357
364
  fileDropDescription?: ReactNode;
358
365
  /** Enable voice dictation. Defaults to true. */
359
366
  voice?: boolean;
360
- /** Controlled live voice UI, used instead of browser dictation when provided. */
367
+ /** Controlled live voice UI; its empty-input action is separate from the dictation mic. */
361
368
  voiceSession?: VoiceSessionControlsProps;
362
369
  /** Title shown inside the voice overlay. */
363
370
  voiceTitle?: string;
@@ -10,7 +10,7 @@ import {
10
10
  } from "../chunk-Z45YKVAM.js";
11
11
  import {
12
12
  VoiceDictationButton
13
- } from "../chunk-EDEN7TAL.js";
13
+ } from "../chunk-JKVVTFAP.js";
14
14
  import {
15
15
  Suggestion,
16
16
  Suggestions
@@ -86,7 +86,7 @@ import "../chunk-GJ7S3L6R.js";
86
86
  import {
87
87
  useCopyToClipboard
88
88
  } from "../chunk-ZWZAAGQF.js";
89
- import "../chunk-HLAHOVEI.js";
89
+ import "../chunk-RPSRCB5O.js";
90
90
  import {
91
91
  useIsMobile
92
92
  } from "../chunk-PIU5SSUV.js";
@@ -402,8 +402,9 @@ function ChatInput({
402
402
  const [editing, setEditing] = useState(false);
403
403
  const focusVersion = useRef(0);
404
404
  const isDisabled = pending || disabled;
405
+ const hasText = Boolean(value.trim());
405
406
  const attachmentsReady = attachments.length > 0 && attachments.every((item) => item.state !== "uploading");
406
- const canSubmit = !isDisabled && (Boolean(value.trim()) || attachmentsReady);
407
+ const canSubmit = !isDisabled && (hasText || attachmentsReady);
407
408
  const liveVoiceActive = voiceSession && voiceSession.state !== "disconnected" && voiceSession.state !== "error";
408
409
  const submit = () => {
409
410
  const trimmed = value.trim();
@@ -555,8 +556,7 @@ function ChatInput({
555
556
  )
556
557
  }
557
558
  ),
558
- voiceSession && !liveVoiceActive && /* @__PURE__ */ jsx(VoiceSessionControls, { ...voiceSession, disabled: voiceSession.disabled || isDisabled }),
559
- submitLabel ? /* @__PURE__ */ jsxs(
559
+ voiceSession && !liveVoiceActive && !hasText && attachments.length === 0 ? /* @__PURE__ */ jsx(VoiceSessionControls, { ...voiceSession, disabled: voiceSession.disabled || isDisabled }) : submitLabel ? /* @__PURE__ */ jsxs(
560
560
  Button,
561
561
  {
562
562
  type: "submit",
@@ -572,11 +572,15 @@ function ChatInput({
572
572
  Button,
573
573
  {
574
574
  type: "submit",
575
- size: spacious && !compact ? "icon-lg" : "icon",
575
+ size: !voiceSession && spacious && !compact ? "icon-lg" : "icon",
576
+ shape: voiceSession ? "circle" : void 0,
576
577
  disabled: !canSubmit,
577
578
  "aria-label": "Send",
578
579
  "data-chat-action": "submit",
579
- className: cn(spacious && "rounded-xl", compactOnBlur && "min-h-[44px] min-w-[44px] shrink-0"),
580
+ className: cn(
581
+ spacious && !voiceSession && "rounded-xl",
582
+ (voiceSession || compactOnBlur) && "min-h-[44px] min-w-[44px] shrink-0"
583
+ ),
580
584
  children: /* @__PURE__ */ jsx(ArrowUp, { className: cn(spacious && "h-5 w-5") })
581
585
  }
582
586
  )
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
 
4
4
 
5
- var _chunkZZ7ICQR2cjs = require('../chunk-ZZ7ICQR2.cjs');
5
+ var _chunk7Q4EFCUIcjs = require('../chunk-7Q4EFCUI.cjs');
6
6
 
7
7
 
8
- exports.DictationProvider = _chunkZZ7ICQR2cjs.DictationProvider;
8
+ exports.DictationProvider = _chunk7Q4EFCUIcjs.DictationProvider;
@@ -1,2 +1,2 @@
1
- export { DictationProvider, DictationProviderProps, DictationTranscribe, DictationTranscribeOptions } from '../hooks/use-dictation.cjs';
1
+ export { DictationProvider, DictationProviderProps, DictationStream, DictationStreamOptions, DictationStreamSession, DictationTranscribe, DictationTranscribeOptions } from '../hooks/use-dictation.cjs';
2
2
  import 'react';
@@ -1,2 +1,2 @@
1
- export { DictationProvider, DictationProviderProps, DictationTranscribe, DictationTranscribeOptions } from '../hooks/use-dictation.js';
1
+ export { DictationProvider, DictationProviderProps, DictationStream, DictationStreamOptions, DictationStreamSession, DictationTranscribe, DictationTranscribeOptions } from '../hooks/use-dictation.js';
2
2
  import 'react';
@@ -2,7 +2,7 @@
2
2
  "use client";
3
3
  import {
4
4
  DictationProvider
5
- } from "../chunk-HLAHOVEI.js";
5
+ } from "../chunk-RPSRCB5O.js";
6
6
  export {
7
7
  DictationProvider
8
8
  };
@@ -2,12 +2,12 @@
2
2
  "use client";
3
3
 
4
4
 
5
- var _chunkD47AMKT4cjs = require('../chunk-D47AMKT4.cjs');
5
+ var _chunkQWUMIAYDcjs = require('../chunk-QWUMIAYD.cjs');
6
6
  require('../chunk-NKWHDI4C.cjs');
7
7
  require('../chunk-JDEXZGIY.cjs');
8
8
  require('../chunk-KWLSBTB2.cjs');
9
9
  require('../chunk-2BSFYU22.cjs');
10
- require('../chunk-ZZ7ICQR2.cjs');
10
+ require('../chunk-7Q4EFCUI.cjs');
11
11
 
12
12
 
13
- exports.VoiceDictationButton = _chunkD47AMKT4cjs.VoiceDictationButton;
13
+ exports.VoiceDictationButton = _chunkQWUMIAYDcjs.VoiceDictationButton;
@@ -2,12 +2,12 @@
2
2
  "use client";
3
3
  import {
4
4
  VoiceDictationButton
5
- } from "../chunk-EDEN7TAL.js";
5
+ } from "../chunk-JKVVTFAP.js";
6
6
  import "../chunk-5OF6D3GH.js";
7
7
  import "../chunk-W326GKML.js";
8
8
  import "../chunk-G6SRSWXB.js";
9
9
  import "../chunk-GJ7S3L6R.js";
10
- import "../chunk-HLAHOVEI.js";
10
+ import "../chunk-RPSRCB5O.js";
11
11
  export {
12
12
  VoiceDictationButton
13
13
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicbagel/saas-kit",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Accessible React primitives, layout components and design tokens for SaaS apps, built on Radix UI and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "keywords": [