@epicbagel/saas-kit 1.11.0 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,17 +21,18 @@ Pass a controlled `voiceSession` to `Chat` or `ChatInput` to show start, mute,
21
21
  end, connection and speaking controls alongside a separate dictation mic.
22
22
  Dictation is disabled during a live call and the typed draft is preserved.
23
23
  The active session appears as a compact horizontal strip above the text field:
24
- microphone badge, audio bars, status, latest utterance, **Mute** and **End voice**.
25
- It wraps its controls on narrow screens. Pass the latest user utterance as
26
- `voiceSession.transcript`; this is plain preview text and never submits or replaces
27
- the typed draft. `VoiceSessionControls` is also exported on its own.
24
+ microphone badge, audio bars, status, **Mute** and **End voice**.
25
+ It wraps its controls on narrow screens. Transcripts belong in the conversation
26
+ thread and are not repeated in the strip. The legacy `transcript` prop and
27
+ `labels.transcriptPlaceholder` are accepted but ignored.
28
+ `VoiceSessionControls` is also exported on its own.
28
29
  The host owns audio, authentication, transcripts, retries and cleanup; the kit
29
30
  does not import a voice provider. Stop and release audio tracks before setting
30
31
  `state: "error"` or unmounting. Every label is replaceable through `labels`.
31
32
 
32
33
  ```tsx
33
34
  <Chat messages={messages} onSend={sendMessage} voiceSession={{
34
- state: voiceState, muted, error, transcript: latestUserUtterance, audioLevels,
35
+ state: voiceState, muted, error, audioLevels,
35
36
  onStart: startVoice, onEnd: endVoice, onMutedChange: setMuted,
36
37
  }} />
37
38
  ```
@@ -40,9 +41,15 @@ does not import a voice provider. Stop and release audio tracks before setting
40
41
 
41
42
  Wrap the application in `DictationProvider` to use an authenticated transcription
42
43
  API for every `VoiceDictationButton`, including buttons nested in chat, forms or
43
- another component library. A compact recorder shows the elapsed time and LiveKit
44
- audio bars measured from the recording stream, without opening a second microphone.
45
- It also works with hidden/programmatic triggers and inside another dialog.
44
+ another component library. The recorder opens beside its microphone on desktop
45
+ and fills the screen below 768px, with elapsed time, LiveKit audio bars and
46
+ Done/Cancel controls. The bars use the recording stream without opening a second
47
+ microphone. Resizing keeps the current recording alive.
48
+
49
+ For hidden/programmatic triggers, it uses the focused visible button as the
50
+ desktop anchor. Hosts can pass an explicit `anchorRef` for a custom trigger.
51
+ Without a visible anchor it opens full-screen. Nested and sibling dialogs keep
52
+ their focus and cancellation behavior.
46
53
  The recorder runs until **Done**, shows
47
54
  **Transcribing…**, then commits the returned text once. Failed requests keep the
48
55
  take available for retry. Cancel stops microphone tracks, aborts the request and
@@ -0,0 +1,293 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }"use client";
2
+
3
+
4
+ var _chunkNKWHDI4Ccjs = require('./chunk-NKWHDI4C.cjs');
5
+
6
+
7
+ var _chunkJDEXZGIYcjs = require('./chunk-JDEXZGIY.cjs');
8
+
9
+
10
+ var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
11
+
12
+
13
+ var _chunkZZ7ICQR2cjs = require('./chunk-ZZ7ICQR2.cjs');
14
+
15
+ // src/primitives/voice-dictation-button.tsx
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+ var _react = require('react');
24
+ var _reactdialog = require('@radix-ui/react-dialog'); var DialogPrimitive = _interopRequireWildcard(_reactdialog);
25
+ var _reactpopover = require('@radix-ui/react-popover'); var PopoverPrimitive = _interopRequireWildcard(_reactpopover);
26
+ var _lucidereact = require('lucide-react');
27
+ var _jsxruntime = require('react/jsx-runtime');
28
+ function VoiceDictationButton({
29
+ onCommit,
30
+ onCancel,
31
+ title = "Dictate your message",
32
+ caption = "Recording",
33
+ lang = "en-US",
34
+ continuous = true,
35
+ className,
36
+ size = "sm",
37
+ showLabel = false,
38
+ children,
39
+ disabled = false,
40
+ visibility = "visible",
41
+ shrink = true,
42
+ anchorRef
43
+ }) {
44
+ const [open, setOpen] = _react.useState.call(void 0, false);
45
+ const [anchor, setAnchor] = _react.useState.call(void 0, null);
46
+ const desktop = _react.useSyncExternalStore.call(void 0, subscribeDesktop, isDesktop, () => false);
47
+ const anchored = desktop && isVisibleAnchor(anchor);
48
+ const placement = _react.useRef.call(void 0, anchored);
49
+ placement.current = anchored;
50
+ const isOpen = _react.useRef.call(void 0, open);
51
+ isOpen.current = open;
52
+ const descriptionId = _react.useId.call(void 0, );
53
+ const capture = _chunkZZ7ICQR2cjs.useDictation.call(void 0, { lang, continuous });
54
+ const { transcript, interimTranscript: interim, supported, error } = capture;
55
+ const generation = _react.useRef.call(void 0, 0);
56
+ const finishing = _react.useRef.call(void 0, false);
57
+ const previousFocus = _react.useRef.call(void 0, null);
58
+ const cancelControl = _react.useRef.call(void 0, null);
59
+ const elapsed = useRecordingTime(capture.state);
60
+ const busy = capture.state === "requesting" || capture.state === "transcribing";
61
+ const openOverlay = _react.useCallback.call(void 0, (event) => {
62
+ const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
63
+ const explicit = _optionalChain([anchorRef, 'optionalAccess', _ => _.current]);
64
+ const invokingButton = _optionalChain([focused, 'optionalAccess', _2 => _2.matches, 'call', _3 => _3("button:not(:disabled), [role='button']:not([aria-disabled='true'])")]) ? focused : null;
65
+ const resolved = isVisibleAnchor(explicit) ? explicit : isVisibleAnchor(event.currentTarget) ? event.currentTarget : isVisibleAnchor(invokingButton) ? invokingButton : null;
66
+ previousFocus.current = _optionalChain([resolved, 'optionalAccess', _4 => _4.matches, 'call', _5 => _5("button, a[href], input, select, textarea, [tabindex]")]) ? resolved : focused;
67
+ setAnchor(resolved);
68
+ generation.current++;
69
+ finishing.current = false;
70
+ setOpen(true);
71
+ void capture.start();
72
+ }, [anchorRef, capture.start]);
73
+ const closeOverlay = _react.useCallback.call(void 0, () => {
74
+ generation.current++;
75
+ finishing.current = false;
76
+ capture.cancel();
77
+ setOpen(false);
78
+ }, [capture.cancel]);
79
+ const handleDone = _react.useCallback.call(void 0, async () => {
80
+ if (finishing.current || busy) return;
81
+ _optionalChain([cancelControl, 'access', _6 => _6.current, 'optionalAccess', _7 => _7.focus, 'call', _8 => _8({ preventScroll: true })]);
82
+ if (capture.state === "error" && !capture.canRetry) {
83
+ void capture.start();
84
+ return;
85
+ }
86
+ finishing.current = true;
87
+ const started = generation.current;
88
+ const final = await (capture.canRetry ? capture.retry() : capture.stop());
89
+ if (started !== generation.current) return;
90
+ finishing.current = false;
91
+ if (final === null) return;
92
+ closeOverlay();
93
+ if (final) onCommit(final);
94
+ else _optionalChain([onCancel, 'optionalCall', _9 => _9()]);
95
+ }, [busy, capture.canRetry, capture.retry, capture.start, capture.state, capture.stop, onCommit, onCancel, closeOverlay]);
96
+ const handleCancel = _react.useCallback.call(void 0, () => {
97
+ closeOverlay();
98
+ _optionalChain([onCancel, 'optionalCall', _10 => _10()]);
99
+ }, [closeOverlay, onCancel]);
100
+ _react.useEffect.call(void 0, () => {
101
+ if (open && capture.state === "idle") handleCancel();
102
+ }, [capture.state, handleCancel, open]);
103
+ _react.useEffect.call(void 0, () => {
104
+ return () => {
105
+ generation.current++;
106
+ };
107
+ }, []);
108
+ if (!supported) return null;
109
+ const displayTranscript = `${transcript}${interim ? (transcript ? " " : "") + interim : ""}`;
110
+ const closeAutoFocus = (event) => {
111
+ event.preventDefault();
112
+ if (!isOpen.current && _optionalChain([previousFocus, 'access', _11 => _11.current, 'optionalAccess', _12 => _12.isConnected])) previousFocus.current.focus();
113
+ };
114
+ const onKeyDown = (event) => {
115
+ if (event.key === "Enter" && !event.shiftKey && !(event.target instanceof HTMLElement && event.target.closest("button, input, textarea, select"))) {
116
+ event.preventDefault();
117
+ void handleDone();
118
+ }
119
+ };
120
+ const content = /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
121
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: _chunkKWLSBTB2cjs.cn.call(void 0, "flex min-w-0 items-start justify-between gap-3", !anchored && "w-full max-w-xl self-center"), children: [
122
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "min-w-0 space-y-1", children: [
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
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { role: "status", className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
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
127
+ ] })
128
+ ] }),
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: [
130
+ Math.floor(elapsed / 60).toString().padStart(2, "0"),
131
+ ":",
132
+ (elapsed % 60).toString().padStart(2, "0")
133
+ ] })
134
+ ] }),
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
+ 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." }),
138
+ displayTranscript && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "p", { className: "max-h-40 overflow-y-auto text-sm leading-relaxed", children: [
139
+ transcript,
140
+ interim && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { className: "text-muted-foreground", children: [
141
+ transcript ? " " : "",
142
+ interim
143
+ ] })
144
+ ] }),
145
+ error && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "p", { role: "alert", className: "text-xs text-destructive", children: error })
146
+ ] }),
147
+ /* @__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
+ /* @__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" }),
149
+ capture.canRetry && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkJDEXZGIYcjs.Button, { type: "button", variant: "outline", size: "sm", className: anchored ? "min-h-[44px]" : "order-first col-span-2 min-h-[48px]", onClick: () => {
150
+ _optionalChain([cancelControl, 'access', _13 => _13.current, 'optionalAccess', _14 => _14.focus, 'call', _15 => _15({ preventScroll: true })]);
151
+ void capture.start();
152
+ }, children: "Record again" }),
153
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkJDEXZGIYcjs.Button, { type: "button", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: () => void handleDone(), disabled: busy, children: capture.state === "transcribing" ? "Transcribing\u2026" : capture.canRetry ? "Retry transcription" : error ? "Try again" : "Done" })
154
+ ] })
155
+ ] });
156
+ return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, _jsxruntime.Fragment, { children: [
157
+ children ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
158
+ "button",
159
+ {
160
+ type: "button",
161
+ onClick: openOverlay,
162
+ disabled,
163
+ "aria-label": "Start dictation",
164
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, visibility === "hidden" && "hidden", className),
165
+ children
166
+ }
167
+ ) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
168
+ _chunkJDEXZGIYcjs.Button,
169
+ {
170
+ type: "button",
171
+ onClick: openOverlay,
172
+ disabled,
173
+ variant: "outline",
174
+ size,
175
+ "aria-label": "Start dictation",
176
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, "transition-colors", visibility === "hidden" && "hidden", !shrink && "shrink-0", className),
177
+ children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { className: "flex items-center justify-center gap-2", children: [
178
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Mic, { className: "h-4 w-4" }),
179
+ showLabel && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { children: "Speak" })
180
+ ] })
181
+ }
182
+ ),
183
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, PopoverPrimitive.Root, { modal: true, open: open && anchored, onOpenChange: (next) => {
184
+ if (!next && placement.current) handleCancel();
185
+ }, children: [
186
+ anchored && /* @__PURE__ */ _jsxruntime.jsx.call(void 0, PopoverPrimitive.Anchor, { virtualRef: { current: anchor } }),
187
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, PopoverPrimitive.Portal, { children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
188
+ PopoverPrimitive.Content,
189
+ {
190
+ "aria-label": "Voice dictation",
191
+ "aria-describedby": descriptionId,
192
+ "data-slot": "voice-dictation-panel",
193
+ "data-state": capture.state,
194
+ "data-placement": "anchored",
195
+ side: "top",
196
+ align: "center",
197
+ sideOffset: 8,
198
+ collisionPadding: 12,
199
+ avoidCollisions: true,
200
+ onCloseAutoFocus: closeAutoFocus,
201
+ onKeyDown,
202
+ onEscapeKeyDown: (event) => event.stopPropagation(),
203
+ className: "z-[200] flex max-h-[var(--radix-popover-content-available-height)] w-96 max-w-[calc(100vw-1.5rem)] flex-col gap-3 overflow-y-auto rounded-2xl border border-border bg-background p-4 text-foreground shadow-lg outline-hidden",
204
+ children: content
205
+ }
206
+ ) })
207
+ ] }),
208
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, DialogPrimitive.Root, { open: open && !anchored, onOpenChange: (next) => {
209
+ if (!next && !placement.current) handleCancel();
210
+ }, children: /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, DialogPrimitive.Portal, { children: [
211
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, DialogPrimitive.Overlay, { className: "fixed inset-0 z-[199] bg-background" }),
212
+ /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
213
+ DialogPrimitive.Content,
214
+ {
215
+ "aria-label": "Voice dictation",
216
+ "aria-labelledby": void 0,
217
+ "data-slot": "voice-dictation-panel",
218
+ "data-state": capture.state,
219
+ "data-placement": "fullscreen",
220
+ onCloseAutoFocus: closeAutoFocus,
221
+ onKeyDown,
222
+ onEscapeKeyDown: (event) => event.stopPropagation(),
223
+ className: "fixed inset-0 z-[200] flex h-dvh w-screen flex-col gap-6 overflow-y-auto overscroll-contain bg-background px-6 pt-[max(1.5rem,env(safe-area-inset-top))] pb-[max(1.5rem,env(safe-area-inset-bottom))] text-foreground outline-hidden",
224
+ children: [
225
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, DialogPrimitive.Title, { className: "sr-only", children: "Voice dictation" }),
226
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, DialogPrimitive.Description, { className: "sr-only", children: "Record your message, then choose Done to add the text. Cancel discards the recording." }),
227
+ content
228
+ ]
229
+ }
230
+ )
231
+ ] }) })
232
+ ] });
233
+ }
234
+ var SILENCE = [];
235
+ var silence = () => SILENCE;
236
+ function DictationWaveform({ source, state, className }) {
237
+ const bands = _react.useSyncExternalStore.call(void 0, source.subscribe, source.getSnapshot, silence);
238
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
239
+ _chunkNKWHDI4Ccjs.AgentAudioVisualizerBar,
240
+ {
241
+ "aria-hidden": "true",
242
+ "data-slot": "dictation-waveform",
243
+ size: "icon",
244
+ barCount: 24,
245
+ state: state === "recording" ? "listening" : state === "transcribing" ? "thinking" : state === "requesting" ? "connecting" : "disconnected",
246
+ volumeBands: state === "recording" ? bands : void 0,
247
+ className: _chunkKWLSBTB2cjs.cn.call(void 0, "h-9 w-full text-foreground", className)
248
+ }
249
+ );
250
+ }
251
+ function useRecordingTime(state) {
252
+ const [elapsed, setElapsed] = _react.useState.call(void 0, 0);
253
+ const startedAt = _react.useRef.call(void 0, null);
254
+ _react.useEffect.call(void 0, () => {
255
+ if (state !== "recording") {
256
+ if (state === "idle" || state === "requesting") setElapsed(0);
257
+ else if (startedAt.current !== null) setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
258
+ startedAt.current = null;
259
+ return;
260
+ }
261
+ startedAt.current = performance.now();
262
+ const update = () => setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
263
+ update();
264
+ const timer = setInterval(update, 1e3);
265
+ return () => {
266
+ clearInterval(timer);
267
+ };
268
+ }, [state]);
269
+ return elapsed;
270
+ }
271
+ var DESKTOP_QUERY = "(min-width: 768px)";
272
+ function isDesktop() {
273
+ return typeof window !== "undefined" && !!_optionalChain([window, 'access', _16 => _16.matchMedia, 'optionalCall', _17 => _17(DESKTOP_QUERY), 'access', _18 => _18.matches]);
274
+ }
275
+ function subscribeDesktop(changed) {
276
+ const media = _optionalChain([window, 'access', _19 => _19.matchMedia, 'optionalCall', _20 => _20(DESKTOP_QUERY)]);
277
+ _optionalChain([media, 'optionalAccess', _21 => _21.addEventListener, 'call', _22 => _22("change", changed)]);
278
+ return () => _optionalChain([media, 'optionalAccess', _23 => _23.removeEventListener, 'call', _24 => _24("change", changed)]);
279
+ }
280
+ function isVisibleAnchor(element) {
281
+ if (!_optionalChain([element, 'optionalAccess', _25 => _25.isConnected]) || !element.getClientRects().length) return false;
282
+ const rect = element.getBoundingClientRect();
283
+ if (!rect.width || !rect.height) return false;
284
+ for (let parent = element; parent; parent = parent.parentElement) {
285
+ const style = getComputedStyle(parent);
286
+ if (parent.hidden || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity) === 0) return false;
287
+ }
288
+ return true;
289
+ }
290
+
291
+
292
+
293
+ exports.VoiceDictationButton = VoiceDictationButton;
@@ -0,0 +1,293 @@
1
+ "use client";
2
+ import {
3
+ AgentAudioVisualizerBar
4
+ } from "./chunk-5OF6D3GH.js";
5
+ import {
6
+ Button
7
+ } from "./chunk-W326GKML.js";
8
+ import {
9
+ cn
10
+ } from "./chunk-G6SRSWXB.js";
11
+ import {
12
+ useDictation
13
+ } from "./chunk-HLAHOVEI.js";
14
+
15
+ // src/primitives/voice-dictation-button.tsx
16
+ import {
17
+ useCallback,
18
+ useEffect,
19
+ useId,
20
+ useRef,
21
+ useState,
22
+ useSyncExternalStore
23
+ } from "react";
24
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
25
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
26
+ import { Mic, LoaderCircle } from "lucide-react";
27
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
28
+ function VoiceDictationButton({
29
+ onCommit,
30
+ onCancel,
31
+ title = "Dictate your message",
32
+ caption = "Recording",
33
+ lang = "en-US",
34
+ continuous = true,
35
+ className,
36
+ size = "sm",
37
+ showLabel = false,
38
+ children,
39
+ disabled = false,
40
+ visibility = "visible",
41
+ shrink = true,
42
+ anchorRef
43
+ }) {
44
+ const [open, setOpen] = useState(false);
45
+ const [anchor, setAnchor] = useState(null);
46
+ const desktop = useSyncExternalStore(subscribeDesktop, isDesktop, () => false);
47
+ const anchored = desktop && isVisibleAnchor(anchor);
48
+ const placement = useRef(anchored);
49
+ placement.current = anchored;
50
+ const isOpen = useRef(open);
51
+ isOpen.current = open;
52
+ const descriptionId = useId();
53
+ const capture = useDictation({ lang, continuous });
54
+ const { transcript, interimTranscript: interim, supported, error } = capture;
55
+ const generation = useRef(0);
56
+ const finishing = useRef(false);
57
+ const previousFocus = useRef(null);
58
+ const cancelControl = useRef(null);
59
+ const elapsed = useRecordingTime(capture.state);
60
+ const busy = capture.state === "requesting" || capture.state === "transcribing";
61
+ const openOverlay = useCallback((event) => {
62
+ const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
63
+ const explicit = anchorRef?.current;
64
+ const invokingButton = focused?.matches("button:not(:disabled), [role='button']:not([aria-disabled='true'])") ? focused : null;
65
+ const resolved = isVisibleAnchor(explicit) ? explicit : isVisibleAnchor(event.currentTarget) ? event.currentTarget : isVisibleAnchor(invokingButton) ? invokingButton : null;
66
+ previousFocus.current = resolved?.matches("button, a[href], input, select, textarea, [tabindex]") ? resolved : focused;
67
+ setAnchor(resolved);
68
+ generation.current++;
69
+ finishing.current = false;
70
+ setOpen(true);
71
+ void capture.start();
72
+ }, [anchorRef, capture.start]);
73
+ const closeOverlay = useCallback(() => {
74
+ generation.current++;
75
+ finishing.current = false;
76
+ capture.cancel();
77
+ setOpen(false);
78
+ }, [capture.cancel]);
79
+ const handleDone = useCallback(async () => {
80
+ if (finishing.current || busy) return;
81
+ cancelControl.current?.focus({ preventScroll: true });
82
+ if (capture.state === "error" && !capture.canRetry) {
83
+ void capture.start();
84
+ return;
85
+ }
86
+ finishing.current = true;
87
+ const started = generation.current;
88
+ const final = await (capture.canRetry ? capture.retry() : capture.stop());
89
+ if (started !== generation.current) return;
90
+ finishing.current = false;
91
+ if (final === null) return;
92
+ closeOverlay();
93
+ if (final) onCommit(final);
94
+ else onCancel?.();
95
+ }, [busy, capture.canRetry, capture.retry, capture.start, capture.state, capture.stop, onCommit, onCancel, closeOverlay]);
96
+ const handleCancel = useCallback(() => {
97
+ closeOverlay();
98
+ onCancel?.();
99
+ }, [closeOverlay, onCancel]);
100
+ useEffect(() => {
101
+ if (open && capture.state === "idle") handleCancel();
102
+ }, [capture.state, handleCancel, open]);
103
+ useEffect(() => {
104
+ return () => {
105
+ generation.current++;
106
+ };
107
+ }, []);
108
+ if (!supported) return null;
109
+ const displayTranscript = `${transcript}${interim ? (transcript ? " " : "") + interim : ""}`;
110
+ const closeAutoFocus = (event) => {
111
+ event.preventDefault();
112
+ if (!isOpen.current && previousFocus.current?.isConnected) previousFocus.current.focus();
113
+ };
114
+ const onKeyDown = (event) => {
115
+ if (event.key === "Enter" && !event.shiftKey && !(event.target instanceof HTMLElement && event.target.closest("button, input, textarea, select"))) {
116
+ event.preventDefault();
117
+ void handleDone();
118
+ }
119
+ };
120
+ const content = /* @__PURE__ */ jsxs(Fragment, { children: [
121
+ /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 items-start justify-between gap-3", !anchored && "w-full max-w-xl self-center"), children: [
122
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1", children: [
123
+ /* @__PURE__ */ jsx("h2", { className: cn("font-medium", anchored ? "text-sm" : "text-lg"), children: title || "Voice dictation" }),
124
+ /* @__PURE__ */ jsxs("span", { role: "status", className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: [
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
127
+ ] })
128
+ ] }),
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: [
130
+ Math.floor(elapsed / 60).toString().padStart(2, "0"),
131
+ ":",
132
+ (elapsed % 60).toString().padStart(2, "0")
133
+ ] })
134
+ ] }),
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
+ 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." }),
138
+ displayTranscript && /* @__PURE__ */ jsxs("p", { className: "max-h-40 overflow-y-auto text-sm leading-relaxed", children: [
139
+ transcript,
140
+ interim && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
141
+ transcript ? " " : "",
142
+ interim
143
+ ] })
144
+ ] }),
145
+ error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-xs text-destructive", children: error })
146
+ ] }),
147
+ /* @__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
+ /* @__PURE__ */ jsx(Button, { ref: cancelControl, type: "button", variant: "ghost", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: handleCancel, children: "Cancel" }),
149
+ capture.canRetry && /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", className: anchored ? "min-h-[44px]" : "order-first col-span-2 min-h-[48px]", onClick: () => {
150
+ cancelControl.current?.focus({ preventScroll: true });
151
+ void capture.start();
152
+ }, children: "Record again" }),
153
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", className: anchored ? "min-h-[44px]" : "min-h-[48px]", onClick: () => void handleDone(), disabled: busy, children: capture.state === "transcribing" ? "Transcribing\u2026" : capture.canRetry ? "Retry transcription" : error ? "Try again" : "Done" })
154
+ ] })
155
+ ] });
156
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
157
+ children ? /* @__PURE__ */ jsx(
158
+ "button",
159
+ {
160
+ type: "button",
161
+ onClick: openOverlay,
162
+ disabled,
163
+ "aria-label": "Start dictation",
164
+ className: cn(visibility === "hidden" && "hidden", className),
165
+ children
166
+ }
167
+ ) : /* @__PURE__ */ jsx(
168
+ Button,
169
+ {
170
+ type: "button",
171
+ onClick: openOverlay,
172
+ disabled,
173
+ variant: "outline",
174
+ size,
175
+ "aria-label": "Start dictation",
176
+ className: cn("transition-colors", visibility === "hidden" && "hidden", !shrink && "shrink-0", className),
177
+ children: /* @__PURE__ */ jsxs("span", { className: "flex items-center justify-center gap-2", children: [
178
+ /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" }),
179
+ showLabel && /* @__PURE__ */ jsx("span", { children: "Speak" })
180
+ ] })
181
+ }
182
+ ),
183
+ /* @__PURE__ */ jsxs(PopoverPrimitive.Root, { modal: true, open: open && anchored, onOpenChange: (next) => {
184
+ if (!next && placement.current) handleCancel();
185
+ }, children: [
186
+ anchored && /* @__PURE__ */ jsx(PopoverPrimitive.Anchor, { virtualRef: { current: anchor } }),
187
+ /* @__PURE__ */ jsx(PopoverPrimitive.Portal, { children: /* @__PURE__ */ jsx(
188
+ PopoverPrimitive.Content,
189
+ {
190
+ "aria-label": "Voice dictation",
191
+ "aria-describedby": descriptionId,
192
+ "data-slot": "voice-dictation-panel",
193
+ "data-state": capture.state,
194
+ "data-placement": "anchored",
195
+ side: "top",
196
+ align: "center",
197
+ sideOffset: 8,
198
+ collisionPadding: 12,
199
+ avoidCollisions: true,
200
+ onCloseAutoFocus: closeAutoFocus,
201
+ onKeyDown,
202
+ onEscapeKeyDown: (event) => event.stopPropagation(),
203
+ className: "z-[200] flex max-h-[var(--radix-popover-content-available-height)] w-96 max-w-[calc(100vw-1.5rem)] flex-col gap-3 overflow-y-auto rounded-2xl border border-border bg-background p-4 text-foreground shadow-lg outline-hidden",
204
+ children: content
205
+ }
206
+ ) })
207
+ ] }),
208
+ /* @__PURE__ */ jsx(DialogPrimitive.Root, { open: open && !anchored, onOpenChange: (next) => {
209
+ if (!next && !placement.current) handleCancel();
210
+ }, children: /* @__PURE__ */ jsxs(DialogPrimitive.Portal, { children: [
211
+ /* @__PURE__ */ jsx(DialogPrimitive.Overlay, { className: "fixed inset-0 z-[199] bg-background" }),
212
+ /* @__PURE__ */ jsxs(
213
+ DialogPrimitive.Content,
214
+ {
215
+ "aria-label": "Voice dictation",
216
+ "aria-labelledby": void 0,
217
+ "data-slot": "voice-dictation-panel",
218
+ "data-state": capture.state,
219
+ "data-placement": "fullscreen",
220
+ onCloseAutoFocus: closeAutoFocus,
221
+ onKeyDown,
222
+ onEscapeKeyDown: (event) => event.stopPropagation(),
223
+ className: "fixed inset-0 z-[200] flex h-dvh w-screen flex-col gap-6 overflow-y-auto overscroll-contain bg-background px-6 pt-[max(1.5rem,env(safe-area-inset-top))] pb-[max(1.5rem,env(safe-area-inset-bottom))] text-foreground outline-hidden",
224
+ children: [
225
+ /* @__PURE__ */ jsx(DialogPrimitive.Title, { className: "sr-only", children: "Voice dictation" }),
226
+ /* @__PURE__ */ jsx(DialogPrimitive.Description, { className: "sr-only", children: "Record your message, then choose Done to add the text. Cancel discards the recording." }),
227
+ content
228
+ ]
229
+ }
230
+ )
231
+ ] }) })
232
+ ] });
233
+ }
234
+ var SILENCE = [];
235
+ var silence = () => SILENCE;
236
+ function DictationWaveform({ source, state, className }) {
237
+ const bands = useSyncExternalStore(source.subscribe, source.getSnapshot, silence);
238
+ return /* @__PURE__ */ jsx(
239
+ AgentAudioVisualizerBar,
240
+ {
241
+ "aria-hidden": "true",
242
+ "data-slot": "dictation-waveform",
243
+ size: "icon",
244
+ barCount: 24,
245
+ state: state === "recording" ? "listening" : state === "transcribing" ? "thinking" : state === "requesting" ? "connecting" : "disconnected",
246
+ volumeBands: state === "recording" ? bands : void 0,
247
+ className: cn("h-9 w-full text-foreground", className)
248
+ }
249
+ );
250
+ }
251
+ function useRecordingTime(state) {
252
+ const [elapsed, setElapsed] = useState(0);
253
+ const startedAt = useRef(null);
254
+ useEffect(() => {
255
+ if (state !== "recording") {
256
+ if (state === "idle" || state === "requesting") setElapsed(0);
257
+ else if (startedAt.current !== null) setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
258
+ startedAt.current = null;
259
+ return;
260
+ }
261
+ startedAt.current = performance.now();
262
+ const update = () => setElapsed(Math.floor((performance.now() - startedAt.current) / 1e3));
263
+ update();
264
+ const timer = setInterval(update, 1e3);
265
+ return () => {
266
+ clearInterval(timer);
267
+ };
268
+ }, [state]);
269
+ return elapsed;
270
+ }
271
+ var DESKTOP_QUERY = "(min-width: 768px)";
272
+ function isDesktop() {
273
+ return typeof window !== "undefined" && !!window.matchMedia?.(DESKTOP_QUERY).matches;
274
+ }
275
+ function subscribeDesktop(changed) {
276
+ const media = window.matchMedia?.(DESKTOP_QUERY);
277
+ media?.addEventListener("change", changed);
278
+ return () => media?.removeEventListener("change", changed);
279
+ }
280
+ function isVisibleAnchor(element) {
281
+ if (!element?.isConnected || !element.getClientRects().length) return false;
282
+ const rect = element.getBoundingClientRect();
283
+ if (!rect.width || !rect.height) return false;
284
+ for (let parent = element; parent; parent = parent.parentElement) {
285
+ const style = getComputedStyle(parent);
286
+ if (parent.hidden || style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity) === 0) return false;
287
+ }
288
+ return true;
289
+ }
290
+
291
+ export {
292
+ VoiceDictationButton
293
+ };