@epicbagel/saas-kit 1.10.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.
Files changed (42) hide show
  1. package/README.md +21 -3
  2. package/dist/{chunk-VBKJXNEL.js → chunk-5OF6D3GH.js} +4 -3
  3. package/dist/chunk-D47AMKT4.cjs +293 -0
  4. package/dist/chunk-EDEN7TAL.js +293 -0
  5. package/dist/{chunk-M2ZNC3TK.js → chunk-HLAHOVEI.js} +98 -2
  6. package/dist/{chunk-KUG4ONYC.js → chunk-KF4X37DP.js} +40 -27
  7. package/dist/{chunk-XB2D4NQK.cjs → chunk-NKWHDI4C.cjs} +4 -3
  8. package/dist/{chunk-UW67GSZ4.cjs → chunk-YMI3X2YL.cjs} +38 -25
  9. package/dist/{chunk-O6F3LVZY.cjs → chunk-ZZ7ICQR2.cjs} +120 -24
  10. package/dist/hooks/use-dictation.cjs +2 -2
  11. package/dist/hooks/use-dictation.d.cts +5 -0
  12. package/dist/hooks/use-dictation.d.ts +5 -0
  13. package/dist/hooks/use-dictation.js +1 -1
  14. package/dist/primitives/agent-audio-visualizer-bar.cjs +2 -2
  15. package/dist/primitives/agent-audio-visualizer-bar.d.cts +1 -1
  16. package/dist/primitives/agent-audio-visualizer-bar.d.ts +1 -1
  17. package/dist/primitives/agent-audio-visualizer-bar.js +1 -1
  18. package/dist/primitives/alert.d.cts +1 -1
  19. package/dist/primitives/alert.d.ts +1 -1
  20. package/dist/primitives/badge.d.cts +1 -1
  21. package/dist/primitives/badge.d.ts +1 -1
  22. package/dist/primitives/bubble.d.cts +1 -1
  23. package/dist/primitives/bubble.d.ts +1 -1
  24. package/dist/primitives/button.d.cts +2 -2
  25. package/dist/primitives/button.d.ts +2 -2
  26. package/dist/primitives/chat.cjs +15 -15
  27. package/dist/primitives/chat.d.cts +2 -2
  28. package/dist/primitives/chat.d.ts +2 -2
  29. package/dist/primitives/chat.js +10 -10
  30. package/dist/primitives/dictation-provider.cjs +2 -2
  31. package/dist/primitives/dictation-provider.js +1 -1
  32. package/dist/primitives/voice-dictation-button.cjs +5 -3
  33. package/dist/primitives/voice-dictation-button.d.cts +9 -11
  34. package/dist/primitives/voice-dictation-button.d.ts +9 -11
  35. package/dist/primitives/voice-dictation-button.js +4 -2
  36. package/dist/primitives/voice-session-controls.cjs +3 -3
  37. package/dist/primitives/voice-session-controls.d.cts +3 -1
  38. package/dist/primitives/voice-session-controls.d.ts +3 -1
  39. package/dist/primitives/voice-session-controls.js +2 -2
  40. package/package.json +1 -1
  41. package/dist/chunk-HTCW6AEH.cjs +0 -223
  42. package/dist/chunk-KWV5KTOA.js +0 -223
package/README.md CHANGED
@@ -20,6 +20,11 @@ Peer dependencies: `react` and `react-dom` (18 or 19), `lucide-react`
20
20
  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
+ The active session appears as a compact horizontal strip above the text field:
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.
23
28
  `VoiceSessionControls` is also exported on its own.
24
29
  The host owns audio, authentication, transcripts, retries and cleanup; the kit
25
30
  does not import a voice provider. Stop and release audio tracks before setting
@@ -27,7 +32,7 @@ does not import a voice provider. Stop and release audio tracks before setting
27
32
 
28
33
  ```tsx
29
34
  <Chat messages={messages} onSend={sendMessage} voiceSession={{
30
- state: voiceState, muted, error,
35
+ state: voiceState, muted, error, audioLevels,
31
36
  onStart: startVoice, onEnd: endVoice, onMutedChange: setMuted,
32
37
  }} />
33
38
  ```
@@ -36,7 +41,16 @@ does not import a voice provider. Stop and release audio tracks before setting
36
41
 
37
42
  Wrap the application in `DictationProvider` to use an authenticated transcription
38
43
  API for every `VoiceDictationButton`, including buttons nested in chat, forms or
39
- another component library. The existing dialog records until **Done**, shows
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.
53
+ The recorder runs until **Done**, shows
40
54
  **Transcribing…**, then commits the returned text once. Failed requests keep the
41
55
  take available for retry. Cancel stops microphone tracks, aborts the request and
42
56
  ignores a late answer. The kit never persists audio or knows provider credentials.
@@ -62,6 +76,9 @@ cannot be retried under the new scope.
62
76
  `useDictation` (`hooks/use-dictation`) exposes the same capture lifecycle for a
63
77
  custom trigger such as press-and-hold: `start()`, `stop()`, `retry()`, `cancel()`,
64
78
  `state`, `supported`, `transcript`, `interimTranscript`, `error` and `canRetry`.
79
+ For API recording, `audioLevels` exposes a subscribable snapshot of real microphone
80
+ levels. Subscribe only the waveform to keep audio sampling out of form renders;
81
+ native recognition has no audio-level source.
65
82
  `stop()` and `retry()` return the completed text, or `null` after cancellation or
66
83
  failure. Choose either that result or `onTranscript` to commit; using both would
67
84
  duplicate the host action. The callback receives one complete API transcript,
@@ -164,7 +181,8 @@ public in 1.1.0, and these were the decisions about its props then:
164
181
  for the textarea's starting height; there is no class-name or `rows` prop.
165
182
  The textarea grows with its text (`field-sizing: content`).
166
183
  - **Compact mobile replies.** Opt into `Chat.mobileCompactComposer` to fold
167
- an active conversation's empty composer into one row below 768px. Focusing
184
+ an active conversation's empty composer into a short text row above its
185
+ controls below 768px, keeping all four actions clear of the text. Focusing
168
186
  its textarea expands it; leaving the composer folds it again only when
169
187
  there is no draft or staged file. The same textarea stays mounted, and
170
188
  its file menu and voice dialog keep an editing session open. New
@@ -110,14 +110,15 @@ function AgentAudioVisualizerBar({
110
110
  return 1e3;
111
111
  }
112
112
  }, [state, _barCount]);
113
+ const meteredListening = state === "listening" && volumeBands !== void 0;
113
114
  const highlightedIndices = useAgentAudioVisualizerBarAnimator(
114
- state,
115
+ meteredListening ? "speaking" : state,
115
116
  _barCount,
116
117
  sequencerInterval
117
118
  );
118
119
  const bands = useMemo(
119
- () => state === "speaking" ? resolvedVolumeBands : new Array(_barCount).fill(0),
120
- [state, resolvedVolumeBands, _barCount]
120
+ () => state === "speaking" || meteredListening ? resolvedVolumeBands : new Array(_barCount).fill(0),
121
+ [state, meteredListening, resolvedVolumeBands, _barCount]
121
122
  );
122
123
  if (children && Array.isArray(children)) {
123
124
  throw new Error("AgentAudioVisualizerBar children must be a single element.");
@@ -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
+ };