@opengeni/react 0.41.0 → 0.44.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 (58) hide show
  1. package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
  2. package/dist/chunk-23EJ676W.js.map +1 -0
  3. package/dist/{chunk-OMCFRWHL.js → chunk-KC27K42G.js} +1443 -101
  4. package/dist/chunk-KC27K42G.js.map +1 -0
  5. package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
  6. package/dist/chunk-KG5F2OKE.js.map +1 -0
  7. package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
  8. package/dist/chunk-LWR4MXSS.js.map +1 -0
  9. package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
  10. package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
  11. package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
  12. package/dist/components/chat-composer.d.ts +5 -1
  13. package/dist/components/composer-transcription-control.d.ts +16 -1
  14. package/dist/components/composer.d.ts +6 -4
  15. package/dist/components/session-chrome.d.ts +1 -1
  16. package/dist/composer.d.ts +3 -1
  17. package/dist/composer.js +29 -3
  18. package/dist/hooks/use-voice-input.d.ts +25 -4
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.js +95 -20
  21. package/dist/index.js.map +1 -1
  22. package/dist/model-policy.d.ts +2 -0
  23. package/dist/model-policy.js +1 -1
  24. package/dist/realtime/realtime-control.d.ts +29 -1
  25. package/dist/realtime.d.ts +1 -1
  26. package/dist/realtime.js +269 -151
  27. package/dist/realtime.js.map +1 -1
  28. package/dist/session-ui.js +2 -2
  29. package/dist/session.js +2 -2
  30. package/dist/timeline/index.d.ts +1 -1
  31. package/dist/timeline/parsers.d.ts +13 -8
  32. package/dist/voice-recording-owner.d.ts +12 -0
  33. package/dist/voice-recording-store.d.ts +124 -0
  34. package/package.json +2 -2
  35. package/src/components/chat-composer.tsx +58 -19
  36. package/src/components/composer-transcription-control.tsx +258 -117
  37. package/src/components/composer.tsx +78 -14
  38. package/src/components/model-policy-picker.tsx +7 -2
  39. package/src/components/session-chrome.tsx +89 -78
  40. package/src/composer.ts +30 -1
  41. package/src/hooks/use-voice-input.ts +893 -67
  42. package/src/index.ts +32 -1
  43. package/src/model-policy.ts +4 -0
  44. package/src/realtime/realtime-control.tsx +307 -137
  45. package/src/realtime.ts +1 -0
  46. package/src/timeline/index.ts +2 -0
  47. package/src/timeline/parsers.ts +201 -19
  48. package/src/timeline/projection.ts +11 -0
  49. package/src/timeline/tool-renderers.tsx +7 -5
  50. package/src/timeline/turn-summary.tsx +7 -2
  51. package/src/voice-recording-owner.ts +251 -0
  52. package/src/voice-recording-store.ts +528 -0
  53. package/styles/tokens.css +8 -5
  54. package/dist/chunk-4IJCL7YO.js.map +0 -1
  55. package/dist/chunk-KR2SK5GJ.js.map +0 -1
  56. package/dist/chunk-OMCFRWHL.js.map +0 -1
  57. package/dist/chunk-SJKT4TKW.js.map +0 -1
  58. /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
2
+ import { LayoutGroup, motion } from "motion/react";
2
3
  import type { ClipboardEvent, ReactNode } from "react";
3
4
  import type { SlashCommand } from "../commands/types";
4
5
  import type { ComposerState } from "../hooks/use-composer";
@@ -61,6 +62,10 @@ export type ChatComposerProps = {
61
62
  attachButtonClassName?: string | undefined;
62
63
  /** Provider-neutral speech capability. Provider configuration stays in workspace settings. */
63
64
  transcription?: ComposerTranscriptionControlProps | undefined;
65
+ /** Extra classes on the transcription control. */
66
+ transcriptionClassName?: string | undefined;
67
+ /** Soft-hide dictate while realtime voice is active (animated collapse). */
68
+ transcriptionSuppressed?: boolean | undefined;
64
69
  /** Content rendered above the textarea, inside the field chrome. */
65
70
  header?: ReactNode | undefined;
66
71
  /** Paste hook composed with the attachment paste path. */
@@ -99,6 +104,8 @@ export function ChatComposer({
99
104
  actionsStart,
100
105
  attachButtonClassName,
101
106
  transcription,
107
+ transcriptionClassName,
108
+ transcriptionSuppressed = false,
102
109
  header,
103
110
  onPaste,
104
111
  attachments,
@@ -145,25 +152,57 @@ export function ChatComposer({
145
152
  {controller.confirmState ? (
146
153
  <Confirmation />
147
154
  ) : (
148
- <Footer className={stackActions ? "flex-wrap" : undefined}>
149
- {hasControls ? (
150
- <Controls className={stackActions ? "w-full sm:w-auto" : undefined}>
151
- {controlsLeading}
152
- <AttachButton className={attachButtonClassName} />
153
- {transcription ? <ComposerTranscriptionControl {...transcription} /> : null}
154
- {models ? (
155
- <ModelPicker models={models} value={selectedModel} onChange={onSelectModel} />
156
- ) : null}
157
- {controlsStart}
158
- </Controls>
159
- ) : (
160
- <Hint>{hint}</Hint>
161
- )}
162
- <Actions className={stackActions ? "w-full justify-end sm:w-auto" : undefined}>
163
- {actionsStart}
164
- <PauseButton />
165
- <SendButton />
166
- </Actions>
155
+ <Footer className={stackActions ? "max-sm:flex-nowrap sm:flex-wrap" : undefined}>
156
+ <LayoutGroup id="og-composer-footer">
157
+ {hasControls ? (
158
+ <Controls
159
+ className={stackActions ? "min-w-0 max-sm:flex-1 sm:w-auto" : undefined}
160
+ >
161
+ {controlsLeading}
162
+ <AttachButton className={attachButtonClassName} />
163
+ {transcription ? (
164
+ <ComposerTranscriptionControl
165
+ {...transcription}
166
+ suppressed={transcriptionSuppressed}
167
+ className={[transcription.className, transcriptionClassName]
168
+ .filter(Boolean)
169
+ .join(" ")}
170
+ />
171
+ ) : null}
172
+ {models ? (
173
+ <motion.span
174
+ layout
175
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
176
+ className="inline-flex min-w-0"
177
+ >
178
+ <ModelPicker
179
+ models={models}
180
+ value={selectedModel}
181
+ onChange={onSelectModel}
182
+ />
183
+ </motion.span>
184
+ ) : null}
185
+ {controlsStart ? (
186
+ <motion.span
187
+ layout
188
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
189
+ className="inline-flex min-w-0 items-center gap-1.5"
190
+ >
191
+ {controlsStart}
192
+ </motion.span>
193
+ ) : null}
194
+ </Controls>
195
+ ) : (
196
+ <Hint>{hint}</Hint>
197
+ )}
198
+ <Actions
199
+ className={stackActions ? "max-sm:shrink-0 sm:w-auto sm:justify-end" : undefined}
200
+ >
201
+ {actionsStart}
202
+ <PauseButton />
203
+ <SendButton />
204
+ </Actions>
205
+ </LayoutGroup>
167
206
  </Footer>
168
207
  )}
169
208
  </Surface>
@@ -4,11 +4,20 @@ import type {
4
4
  TranscriptionAdapter,
5
5
  WorkspaceTranscriptionPolicy,
6
6
  } from "@opengeni/sdk";
7
- import { LoaderCircleIcon, MicIcon, SquareIcon, XIcon } from "lucide-react";
7
+ import {
8
+ ClipboardPasteIcon,
9
+ LoaderCircleIcon,
10
+ MicIcon,
11
+ RefreshCwIcon,
12
+ SquareIcon,
13
+ Trash2Icon,
14
+ XIcon,
15
+ } from "lucide-react";
8
16
  import { AnimatePresence, motion } from "motion/react";
9
17
  import { useEffect, useState, type MouseEvent, type ReactElement } from "react";
10
18
  import { cn } from "../lib/cn";
11
19
  import { useVoiceInput } from "../hooks/use-voice-input";
20
+ import type { VoiceRecordingStore } from "../voice-recording-store";
12
21
  import { useChatComposer } from "./composer";
13
22
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip";
14
23
 
@@ -28,7 +37,12 @@ export type ComposerTranscriptionMessages = {
28
37
  retry: string;
29
38
  requestingPermission: string;
30
39
  recording: string;
40
+ saving: string;
31
41
  transcribing: string;
42
+ recovered: string;
43
+ recoveredTranscript: string;
44
+ insertRecoveredTranscript: string;
45
+ discardRecovered: string;
32
46
  unavailableDisabled: string;
33
47
  unavailable: string;
34
48
  errorPermissionDenied: string;
@@ -36,6 +50,9 @@ export type ComposerTranscriptionMessages = {
36
50
  errorUnavailable: string;
37
51
  errorTooLarge: string;
38
52
  errorInvalidAudio: string;
53
+ errorStorageUnavailable: string;
54
+ errorRetryable: string;
55
+ errorHandoffUncertain: string;
39
56
  errorUnknown: string;
40
57
  };
41
58
 
@@ -46,7 +63,12 @@ const defaultMessages: ComposerTranscriptionMessages = {
46
63
  retry: "Retry voice input",
47
64
  requestingPermission: "Requesting microphone…",
48
65
  recording: "Recording. Press Escape to cancel.",
66
+ saving: "Saving audio locally…",
49
67
  transcribing: "Transcribing…",
68
+ recovered: "Recording recovered and saved locally.",
69
+ recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
70
+ insertRecoveredTranscript: "Insert saved transcript",
71
+ discardRecovered: "Discard saved recording",
50
72
  unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
51
73
  unavailable: "Voice input is unavailable for this workspace.",
52
74
  errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
@@ -54,6 +76,9 @@ const defaultMessages: ComposerTranscriptionMessages = {
54
76
  errorUnavailable: "Voice input is not configured.",
55
77
  errorTooLarge: "Recording is too large. Try a shorter message.",
56
78
  errorInvalidAudio: "The recording could not be read. Try again.",
79
+ errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
80
+ errorRetryable: "Recording is saved locally. Retry transcription when ready.",
81
+ errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
57
82
  errorUnknown: "Voice input could not start. Try again.",
58
83
  };
59
84
 
@@ -72,6 +97,12 @@ export type ComposerTranscriptionControlProps = {
72
97
  onDiagnostic?: unknown;
73
98
  messages?: Partial<ComposerTranscriptionMessages> | undefined;
74
99
  className?: string | undefined;
100
+ /** Test/embed seam. Production defaults to origin-scoped IndexedDB. */
101
+ createRecordingStore?: (() => VoiceRecordingStore) | undefined;
102
+ /** Test/embed seam. Production acquires a coordinated browser-document owner lease. */
103
+ createOwnerId?: (() => string) | undefined;
104
+ /** Hide while realtime voice owns the mic — collapses with a layout-friendly exit. */
105
+ suppressed?: boolean | undefined;
75
106
  };
76
107
 
77
108
  const WAVEFORM_BARS = 18;
@@ -88,6 +119,9 @@ export function ComposerTranscriptionControl({
88
119
  workspaceEnabled = false,
89
120
  messages: overrides,
90
121
  className,
122
+ createRecordingStore,
123
+ createOwnerId,
124
+ suppressed = false,
91
125
  }: ComposerTranscriptionControlProps) {
92
126
  const composer = useChatComposer();
93
127
  const messages = { ...defaultMessages, ...overrides };
@@ -95,20 +129,39 @@ export function ComposerTranscriptionControl({
95
129
  client,
96
130
  workspaceId,
97
131
  capability,
98
- enabled: workspaceEnabled,
132
+ enabled: workspaceEnabled && !suppressed,
99
133
  value: composer.value,
100
134
  setValue: composer.setValue,
101
135
  focusInput: composer.focusInput,
102
136
  disabled: composer.disabled,
137
+ createRecordingStore,
138
+ createOwnerId,
103
139
  });
104
140
  const { status } = transcription;
141
+
142
+ const cancelTranscription = transcription.cancel;
143
+ useEffect(() => {
144
+ if (!suppressed) return;
145
+ if (status === "recording" || status === "requesting-permission") {
146
+ cancelTranscription();
147
+ }
148
+ }, [cancelTranscription, status, suppressed]);
105
149
  const active =
106
- status === "requesting-permission" || status === "recording" || status === "transcribing";
150
+ status === "requesting-permission" ||
151
+ status === "recording" ||
152
+ status === "saving" ||
153
+ status === "transcribing";
154
+ const recoverable =
155
+ transcription.hasRecoverableRecording &&
156
+ (status === "recovered" || status === "transcript-ready" || status === "error");
157
+ const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
107
158
  const unavailableMessage = composer.disabled
108
159
  ? messages.unavailableDisabled
109
160
  : !capability?.available || !workspaceEnabled
110
161
  ? messages.unavailable
111
- : null;
162
+ : !transcription.available
163
+ ? messages.errorStorageUnavailable
164
+ : null;
112
165
  const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
113
166
  const errorMessage = transcription.error
114
167
  ? transcriptionErrorMessage(transcription.error, messages)
@@ -118,11 +171,17 @@ export function ComposerTranscriptionControl({
118
171
  ? messages.requestingPermission
119
172
  : status === "recording"
120
173
  ? messages.recording
121
- : status === "transcribing"
122
- ? messages.transcribing
123
- : status === "error"
124
- ? (errorMessage ?? messages.errorUnknown)
125
- : unavailableMessage;
174
+ : status === "saving"
175
+ ? messages.saving
176
+ : status === "transcribing"
177
+ ? messages.transcribing
178
+ : status === "recovered"
179
+ ? messages.recovered
180
+ : status === "transcript-ready"
181
+ ? messages.recoveredTranscript
182
+ : status === "error"
183
+ ? (errorMessage ?? messages.errorUnknown)
184
+ : unavailableMessage;
126
185
 
127
186
  function start(event: MouseEvent<HTMLButtonElement>) {
128
187
  if (unavailableMessage) {
@@ -133,123 +192,197 @@ export function ComposerTranscriptionControl({
133
192
  }
134
193
 
135
194
  return (
136
- <span
137
- className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
138
- data-transcription-status={status}
139
- >
140
- <AnimatePresence mode="popLayout" initial={false}>
141
- {active ? (
142
- <motion.span
143
- key={status === "transcribing" ? "transcribing" : "capture"}
144
- initial={{ opacity: 0, scale: 0.96 }}
145
- animate={{ opacity: 1, scale: 1 }}
146
- exit={{ opacity: 0, scale: 0.96 }}
147
- transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
148
- className={cn(
149
- "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
150
- "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-9",
151
- )}
152
- >
153
- {status === "requesting-permission" ? (
154
- <LoaderCircleIcon
155
- className="size-3.5 shrink-0 text-og-fg-muted animate-og-spin motion-reduce:animate-none"
156
- aria-hidden
157
- />
158
- ) : (
159
- <span className="flex items-center gap-1.5">
160
- {status === "recording" ? (
161
- <span
162
- aria-hidden
163
- className="size-1.5 shrink-0 rounded-full bg-og-status-failed animate-og-pulse motion-reduce:animate-none"
164
- />
165
- ) : null}
166
- <VoiceWaveform
167
- stream={status === "recording" ? transcription.stream : null}
168
- mode={status === "transcribing" ? "transcribing" : "recording"}
169
- />
170
- </span>
171
- )}
172
- {status === "recording" ? (
173
- <>
174
- <Tip tip={messages.cancel}>
175
- <button
195
+ <AnimatePresence initial={false}>
196
+ {suppressed ? null : (
197
+ <motion.span
198
+ key="composer-dictate"
199
+ initial={{ opacity: 0, width: 0 }}
200
+ animate={{ opacity: 1, width: "auto" }}
201
+ exit={{ opacity: 0, width: 0 }}
202
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
203
+ className={cn("inline-flex min-w-0 overflow-hidden", className)}
204
+ data-transcription-status={status}
205
+ >
206
+ <span className="inline-flex min-w-0 items-center gap-1.5">
207
+ <AnimatePresence mode="popLayout" initial={false}>
208
+ {recoverable ? (
209
+ <motion.span
210
+ key="recovered"
211
+ initial={{ opacity: 0, scale: 0.96 }}
212
+ animate={{ opacity: 1, scale: 1 }}
213
+ exit={{ opacity: 0, scale: 0.96 }}
214
+ transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
215
+ className={cn(
216
+ "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
217
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
218
+ )}
219
+ >
220
+ <span className="max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28">
221
+ {savedTranscript
222
+ ? (errorMessage ?? messages.recoveredTranscript)
223
+ : status === "error"
224
+ ? (errorMessage ?? messages.errorRetryable)
225
+ : messages.recovered}
226
+ </span>
227
+ <Tip tip={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}>
228
+ <button
229
+ type="button"
230
+ onClick={() =>
231
+ savedTranscript
232
+ ? void transcription.insertSavedTranscript()
233
+ : transcription.retry()
234
+ }
235
+ aria-label={
236
+ savedTranscript ? messages.insertRecoveredTranscript : messages.retry
237
+ }
238
+ className={cn(
239
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
240
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
241
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
242
+ )}
243
+ >
244
+ {savedTranscript ? (
245
+ <ClipboardPasteIcon className="size-3.5" />
246
+ ) : (
247
+ <RefreshCwIcon className="size-3.5" />
248
+ )}
249
+ </button>
250
+ </Tip>
251
+ <Tip tip={messages.discardRecovered}>
252
+ <button
253
+ type="button"
254
+ onClick={() => void transcription.discard()}
255
+ aria-label={messages.discardRecovered}
256
+ className={cn(
257
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
258
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
259
+ "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11",
260
+ )}
261
+ >
262
+ <Trash2Icon className="size-3.5" />
263
+ </button>
264
+ </Tip>
265
+ </motion.span>
266
+ ) : active ? (
267
+ <motion.span
268
+ key={status === "transcribing" || status === "saving" ? "processing" : "capture"}
269
+ initial={{ opacity: 0, scale: 0.96 }}
270
+ animate={{ opacity: 1, scale: 1 }}
271
+ exit={{ opacity: 0, scale: 0.96 }}
272
+ transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
273
+ className={cn(
274
+ "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
275
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
276
+ )}
277
+ >
278
+ {status === "requesting-permission" ? (
279
+ <LoaderCircleIcon
280
+ className="size-3.5 shrink-0 text-og-fg-muted animate-og-spin motion-reduce:animate-none"
281
+ aria-hidden
282
+ />
283
+ ) : (
284
+ <span className="flex items-center gap-1.5">
285
+ {status === "recording" ? (
286
+ <span
287
+ aria-hidden
288
+ className="size-1.5 shrink-0 rounded-full bg-og-status-failed animate-og-pulse motion-reduce:animate-none"
289
+ />
290
+ ) : null}
291
+ <VoiceWaveform
292
+ stream={status === "recording" ? transcription.stream : null}
293
+ mode={status === "recording" ? "recording" : "transcribing"}
294
+ />
295
+ </span>
296
+ )}
297
+ {status === "recording" ? (
298
+ <>
299
+ <Tip tip={messages.cancel}>
300
+ <button
301
+ type="button"
302
+ onClick={() => transcription.cancel()}
303
+ aria-label={messages.cancel}
304
+ aria-keyshortcuts="Escape"
305
+ className={cn(
306
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
307
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
308
+ "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11",
309
+ )}
310
+ >
311
+ <XIcon className="size-3.5" />
312
+ </button>
313
+ </Tip>
314
+ <Tip tip={messages.stop}>
315
+ <button
316
+ type="button"
317
+ onClick={() => transcription.stop()}
318
+ aria-label={messages.stop}
319
+ className={cn(
320
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
321
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
322
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
323
+ )}
324
+ >
325
+ <SquareIcon className="size-2.5 fill-current" />
326
+ </button>
327
+ </Tip>
328
+ </>
329
+ ) : status === "transcribing" || status === "saving" ? (
330
+ <span className="og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap">
331
+ {status === "saving" ? messages.saving : messages.transcribing}
332
+ </span>
333
+ ) : (
334
+ <span className="px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap">
335
+ {messages.requestingPermission}
336
+ </span>
337
+ )}
338
+ </motion.span>
339
+ ) : (
340
+ <Tip key="idle" tip={idleLabel}>
341
+ <motion.button
176
342
  type="button"
177
- onClick={() => transcription.cancel()}
178
- aria-label={messages.cancel}
179
- aria-keyshortcuts="Escape"
343
+ data-og-composer-dictate
344
+ initial={{ opacity: 0, scale: 0.96 }}
345
+ animate={{ opacity: 1, scale: 1 }}
346
+ exit={{ opacity: 0, scale: 0.96 }}
347
+ transition={{ duration: 0.14, ease: [0.22, 1, 0.36, 1] }}
348
+ onClick={start}
349
+ aria-label={idleLabel}
350
+ aria-pressed={false}
351
+ aria-disabled={unavailableMessage !== null}
180
352
  className={cn(
181
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
353
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
182
354
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
183
- "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-9",
355
+ unavailableMessage
356
+ ? "cursor-not-allowed opacity-45"
357
+ : "hover:bg-og-surface-2 hover:text-og-fg",
184
358
  )}
185
359
  >
186
- <XIcon className="size-3.5" />
187
- </button>
360
+ <MicIcon className="size-4" />
361
+ </motion.button>
188
362
  </Tip>
189
- <Tip tip={messages.stop}>
190
- <button
191
- type="button"
192
- onClick={() => transcription.stop()}
193
- aria-label={messages.stop}
194
- className={cn(
195
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
196
- "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
197
- "hover:bg-og-fg-muted pointer-coarse:size-9",
198
- )}
199
- >
200
- <SquareIcon className="size-2.5 fill-current" />
201
- </button>
202
- </Tip>
203
- </>
204
- ) : status === "transcribing" ? (
205
- <span className="og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap">
206
- {messages.transcribing}
207
- </span>
208
- ) : (
209
- <span className="px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap">
210
- {messages.requestingPermission}
211
- </span>
212
- )}
213
- </motion.span>
214
- ) : (
215
- <Tip key="idle" tip={idleLabel}>
216
- <motion.button
217
- type="button"
218
- initial={{ opacity: 0, scale: 0.96 }}
219
- animate={{ opacity: 1, scale: 1 }}
220
- exit={{ opacity: 0, scale: 0.96 }}
221
- transition={{ duration: 0.14, ease: [0.22, 1, 0.36, 1] }}
222
- onClick={start}
223
- aria-label={idleLabel}
224
- aria-pressed={false}
225
- aria-disabled={unavailableMessage !== null}
226
- className={cn(
227
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-9",
228
- "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
229
- unavailableMessage
230
- ? "cursor-not-allowed opacity-45"
231
- : "hover:bg-og-surface-2 hover:text-og-fg",
232
363
  )}
364
+ </AnimatePresence>
365
+ {status === "error" && errorMessage && !recoverable ? (
366
+ <Tip tip={errorMessage}>
367
+ <span
368
+ aria-hidden="true"
369
+ className="max-w-40 truncate text-og-xs text-og-status-failed max-sm:max-w-24"
370
+ >
371
+ {errorMessage}
372
+ </span>
373
+ </Tip>
374
+ ) : null}
375
+ <span
376
+ className="sr-only"
377
+ role={status === "error" ? "alert" : "status"}
378
+ aria-live="polite"
233
379
  >
234
- <MicIcon className="size-4" />
235
- </motion.button>
236
- </Tip>
237
- )}
238
- </AnimatePresence>
239
- {status === "error" && errorMessage ? (
240
- <Tip tip={errorMessage}>
241
- <span
242
- aria-hidden="true"
243
- className="max-w-40 truncate text-og-xs text-og-status-failed max-sm:max-w-24"
244
- >
245
- {errorMessage}
380
+ {announcement}
381
+ </span>
246
382
  </span>
247
- </Tip>
248
- ) : null}
249
- <span className="sr-only" role={status === "error" ? "alert" : "status"} aria-live="polite">
250
- {announcement}
251
- </span>
252
- </span>
383
+ </motion.span>
384
+ )}
385
+ </AnimatePresence>
253
386
  );
254
387
  }
255
388
 
@@ -364,6 +497,14 @@ function transcriptionErrorMessage(code: string, messages: ComposerTranscription
364
497
  return messages.errorTooLarge;
365
498
  case "invalid_audio":
366
499
  return messages.errorInvalidAudio;
500
+ case "storage_unavailable":
501
+ return messages.errorStorageUnavailable;
502
+ case "network":
503
+ case "provider":
504
+ case "timeout":
505
+ return messages.errorRetryable;
506
+ case "handoff_uncertain":
507
+ return messages.errorHandoffUncertain;
367
508
  case "unknown":
368
509
  return messages.errorUnknown;
369
510
  default: