@opengeni/react 0.41.0 → 0.42.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.
@@ -20,6 +20,346 @@ import {
20
20
  cn
21
21
  } from "./chunk-TK7G6XLT.js";
22
22
 
23
+ // src/voice-recording-store.ts
24
+ var DEFAULT_DATABASE_NAME = "opengeni-voice-recordings-v1";
25
+ var DATABASE_VERSION = 1;
26
+ var MANIFEST_STORE = "recordings";
27
+ var CHUNK_STORE = "chunks";
28
+ var CHUNKS_BY_RECORDING = "by-recording";
29
+ var VoiceRecordingStorageUnavailableError = class extends Error {
30
+ constructor() {
31
+ super("Durable voice recording storage is unavailable.");
32
+ this.name = "VoiceRecordingStorageUnavailableError";
33
+ }
34
+ };
35
+ var VoiceRecordingNotFoundError = class extends Error {
36
+ constructor(recordingId) {
37
+ super(`Voice recording ${recordingId} was not found.`);
38
+ this.name = "VoiceRecordingNotFoundError";
39
+ }
40
+ };
41
+ var VoiceRecordingChunkConflictError = class extends Error {
42
+ constructor(recordingId, chunkNumber) {
43
+ super(`Voice recording ${recordingId} chunk ${chunkNumber} conflicts with persisted audio.`);
44
+ this.name = "VoiceRecordingChunkConflictError";
45
+ }
46
+ };
47
+ var VoiceRecordingChunkSequenceError = class extends Error {
48
+ constructor(expected, received) {
49
+ super(`Expected voice recording chunk ${expected}, received ${received}.`);
50
+ this.name = "VoiceRecordingChunkSequenceError";
51
+ }
52
+ };
53
+ var VoiceRecordingOwnedError = class extends Error {
54
+ constructor(recordingId) {
55
+ super(`Voice recording ${recordingId} is active in another browser tab.`);
56
+ this.name = "VoiceRecordingOwnedError";
57
+ }
58
+ };
59
+ function createVoiceRecordingManifest(input) {
60
+ return {
61
+ version: 1,
62
+ recordingId: input.recordingId,
63
+ workspaceId: input.workspaceId,
64
+ createdAt: input.createdAt,
65
+ updatedAt: input.createdAt,
66
+ mimeType: input.mimeType,
67
+ codec: codecForMimeType(input.mimeType),
68
+ captureState: "capturing",
69
+ uploadState: "pending",
70
+ transcriptionState: "pending",
71
+ finalizationState: "pending",
72
+ ownerId: input.ownerId ?? null,
73
+ ownerHeartbeatAt: input.ownerId ? input.createdAt : null,
74
+ transcriptText: null,
75
+ nextChunkNumber: 0,
76
+ chunkCount: 0,
77
+ totalBytes: 0,
78
+ totalDurationMilliseconds: 0
79
+ };
80
+ }
81
+ async function prepareVoiceRecordingChunk(input) {
82
+ if (!Number.isSafeInteger(input.chunkNumber) || input.chunkNumber < 0) {
83
+ throw new VoiceRecordingChunkSequenceError(0, input.chunkNumber);
84
+ }
85
+ if (!Number.isFinite(input.startMilliseconds) || input.startMilliseconds < 0) {
86
+ throw new RangeError("Voice recording chunk start must be non-negative.");
87
+ }
88
+ if (!Number.isFinite(input.durationMilliseconds) || input.durationMilliseconds < 0) {
89
+ throw new RangeError("Voice recording chunk duration must be non-negative.");
90
+ }
91
+ const bytes = new Uint8Array(await input.audio.arrayBuffer());
92
+ if (bytes.byteLength === 0) throw new RangeError("Voice recording chunks cannot be empty.");
93
+ return {
94
+ recordingId: input.recordingId,
95
+ chunkNumber: input.chunkNumber,
96
+ capturedAt: input.capturedAt,
97
+ startMilliseconds: Math.round(input.startMilliseconds),
98
+ durationMilliseconds: Math.round(input.durationMilliseconds),
99
+ mimeType: input.mimeType,
100
+ codec: codecForMimeType(input.mimeType),
101
+ byteLength: bytes.byteLength,
102
+ sha256: await sha256Hex(bytes),
103
+ uploadState: "pending",
104
+ audio: input.audio
105
+ };
106
+ }
107
+ function planVoiceRecordingChunkCommit(input) {
108
+ const { manifest, chunk, existingChunk } = input;
109
+ if (manifest.recordingId !== chunk.recordingId) {
110
+ throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
111
+ }
112
+ if (existingChunk) {
113
+ if (existingChunk.sha256 !== chunk.sha256 || existingChunk.byteLength !== chunk.byteLength || existingChunk.mimeType !== chunk.mimeType) {
114
+ throw new VoiceRecordingChunkConflictError(chunk.recordingId, chunk.chunkNumber);
115
+ }
116
+ return { manifest, chunk: existingChunk, deduplicated: true };
117
+ }
118
+ if (chunk.chunkNumber !== manifest.nextChunkNumber) {
119
+ throw new VoiceRecordingChunkSequenceError(manifest.nextChunkNumber, chunk.chunkNumber);
120
+ }
121
+ const updatedManifest = {
122
+ ...manifest,
123
+ updatedAt: chunk.capturedAt,
124
+ ownerHeartbeatAt: manifest.ownerId ? chunk.capturedAt : manifest.ownerHeartbeatAt,
125
+ nextChunkNumber: manifest.nextChunkNumber + 1,
126
+ chunkCount: manifest.chunkCount + 1,
127
+ totalBytes: manifest.totalBytes + chunk.byteLength,
128
+ totalDurationMilliseconds: Math.max(
129
+ manifest.totalDurationMilliseconds,
130
+ chunk.startMilliseconds + chunk.durationMilliseconds
131
+ )
132
+ };
133
+ return { manifest: updatedManifest, chunk, deduplicated: false };
134
+ }
135
+ var IndexedDbVoiceRecordingStore = class {
136
+ database;
137
+ constructor(options) {
138
+ const factory = options && "indexedDB" in options ? options.indexedDB : globalThis.indexedDB;
139
+ if (!factory) throw new VoiceRecordingStorageUnavailableError();
140
+ this.database = openDatabase(factory, options?.databaseName ?? DEFAULT_DATABASE_NAME);
141
+ }
142
+ async createManifest(manifest) {
143
+ const database = await this.database;
144
+ const transaction = database.transaction(MANIFEST_STORE, "readwrite");
145
+ transaction.objectStore(MANIFEST_STORE).add(manifest);
146
+ await transactionComplete(transaction);
147
+ }
148
+ async getManifest(recordingId) {
149
+ const database = await this.database;
150
+ const transaction = database.transaction(MANIFEST_STORE, "readonly");
151
+ const manifest = await requestResult(
152
+ transaction.objectStore(MANIFEST_STORE).get(recordingId)
153
+ );
154
+ await transactionComplete(transaction);
155
+ return manifest ? normalizeManifest(manifest) : null;
156
+ }
157
+ async listRecoverableManifests(workspaceId, ownership) {
158
+ const database = await this.database;
159
+ const transaction = database.transaction(MANIFEST_STORE, "readonly");
160
+ const manifests = await requestResult(
161
+ transaction.objectStore(MANIFEST_STORE).getAll()
162
+ );
163
+ await transactionComplete(transaction);
164
+ return manifests.map(normalizeManifest).filter(
165
+ (manifest) => manifest.workspaceId === workspaceId && manifest.captureState !== "discarded" && manifest.finalizationState !== "handed-off" && manifestAvailableToOwner(manifest, ownership)
166
+ ).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
167
+ }
168
+ async claimManifest(recordingId, ownerId, claimedAt, staleBefore) {
169
+ const database = await this.database;
170
+ const transaction = database.transaction(MANIFEST_STORE, "readwrite");
171
+ const manifests = transaction.objectStore(MANIFEST_STORE);
172
+ const stored = await requestResult(
173
+ manifests.get(recordingId)
174
+ );
175
+ if (!stored) {
176
+ transaction.abort();
177
+ throw new VoiceRecordingNotFoundError(recordingId);
178
+ }
179
+ const manifest = normalizeManifest(stored);
180
+ if (manifest.finalizationState === "handed-off" || !manifestAvailableToOwner(manifest, { ownerId, staleBefore })) {
181
+ transaction.abort();
182
+ throw new VoiceRecordingOwnedError(recordingId);
183
+ }
184
+ const updated = {
185
+ ...manifest,
186
+ captureState: manifest.captureState === "capturing" ? "stopped" : manifest.captureState,
187
+ ownerId,
188
+ ownerHeartbeatAt: claimedAt,
189
+ updatedAt: claimedAt
190
+ };
191
+ manifests.put(updated);
192
+ await transactionComplete(transaction);
193
+ return updated;
194
+ }
195
+ async listChunks(recordingId) {
196
+ const database = await this.database;
197
+ const transaction = database.transaction(CHUNK_STORE, "readonly");
198
+ const chunks = await requestResult(
199
+ transaction.objectStore(CHUNK_STORE).index(CHUNKS_BY_RECORDING).getAll(recordingId)
200
+ );
201
+ await transactionComplete(transaction);
202
+ return chunks.sort((left, right) => left.chunkNumber - right.chunkNumber);
203
+ }
204
+ async persistChunk(input) {
205
+ const chunk = await prepareVoiceRecordingChunk(input);
206
+ const database = await this.database;
207
+ const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
208
+ const manifests = transaction.objectStore(MANIFEST_STORE);
209
+ const chunks = transaction.objectStore(CHUNK_STORE);
210
+ const [manifest, existingChunk] = await Promise.all([
211
+ requestResult(manifests.get(input.recordingId)),
212
+ requestResult(
213
+ chunks.get([input.recordingId, input.chunkNumber])
214
+ )
215
+ ]);
216
+ if (!manifest) {
217
+ transaction.abort();
218
+ throw new VoiceRecordingNotFoundError(input.recordingId);
219
+ }
220
+ const normalizedManifest = normalizeManifest(manifest);
221
+ assertManifestOwnership(normalizedManifest, input.ownerId);
222
+ const result = planVoiceRecordingChunkCommit({
223
+ manifest: normalizedManifest,
224
+ chunk,
225
+ existingChunk: existingChunk ?? null
226
+ });
227
+ if (!result.deduplicated) {
228
+ chunks.add(result.chunk);
229
+ manifests.put(result.manifest);
230
+ }
231
+ await transactionComplete(transaction);
232
+ return result;
233
+ }
234
+ async updateManifest(recordingId, update, updatedAt, ownerId) {
235
+ const database = await this.database;
236
+ const transaction = database.transaction(MANIFEST_STORE, "readwrite");
237
+ const manifests = transaction.objectStore(MANIFEST_STORE);
238
+ const manifest = await requestResult(
239
+ manifests.get(recordingId)
240
+ );
241
+ if (!manifest) {
242
+ transaction.abort();
243
+ throw new VoiceRecordingNotFoundError(recordingId);
244
+ }
245
+ const normalizedManifest = normalizeManifest(manifest);
246
+ assertManifestOwnership(normalizedManifest, ownerId);
247
+ const updated = { ...normalizedManifest, ...update, updatedAt };
248
+ manifests.put(updated);
249
+ await transactionComplete(transaction);
250
+ return updated;
251
+ }
252
+ async discard(recordingId, ownerId) {
253
+ const database = await this.database;
254
+ const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
255
+ const manifests = transaction.objectStore(MANIFEST_STORE);
256
+ const stored = await requestResult(
257
+ manifests.get(recordingId)
258
+ );
259
+ if (!stored) {
260
+ transaction.abort();
261
+ throw new VoiceRecordingNotFoundError(recordingId);
262
+ }
263
+ assertManifestOwnership(normalizeManifest(stored), ownerId);
264
+ const chunks = transaction.objectStore(CHUNK_STORE);
265
+ const chunkKeys = await requestResult(
266
+ chunks.index(CHUNKS_BY_RECORDING).getAllKeys(recordingId)
267
+ );
268
+ for (const key of chunkKeys) chunks.delete(key);
269
+ manifests.delete(recordingId);
270
+ await transactionComplete(transaction);
271
+ }
272
+ async cleanupHandedOffManifests(ownership) {
273
+ const database = await this.database;
274
+ const transaction = database.transaction([MANIFEST_STORE, CHUNK_STORE], "readwrite");
275
+ const manifests = transaction.objectStore(MANIFEST_STORE);
276
+ const chunks = transaction.objectStore(CHUNK_STORE);
277
+ const handedOff = (await requestResult(manifests.getAll())).map(normalizeManifest).filter(
278
+ (manifest) => manifest.finalizationState === "handed-off" && manifestAvailableToOwner(manifest, ownership)
279
+ );
280
+ const chunkKeys = await Promise.all(
281
+ handedOff.map(
282
+ (manifest) => requestResult(
283
+ chunks.index(CHUNKS_BY_RECORDING).getAllKeys(manifest.recordingId)
284
+ )
285
+ )
286
+ );
287
+ handedOff.forEach((manifest, index) => {
288
+ for (const key of chunkKeys[index] ?? []) chunks.delete(key);
289
+ manifests.delete(manifest.recordingId);
290
+ });
291
+ await transactionComplete(transaction);
292
+ return handedOff.length;
293
+ }
294
+ async close() {
295
+ (await this.database).close();
296
+ }
297
+ };
298
+ function normalizeManifest(manifest) {
299
+ return {
300
+ ...manifest,
301
+ ownerId: manifest.ownerId ?? null,
302
+ ownerHeartbeatAt: manifest.ownerHeartbeatAt ?? null,
303
+ transcriptText: manifest.transcriptText ?? null
304
+ };
305
+ }
306
+ function manifestAvailableToOwner(manifest, ownership) {
307
+ if (!manifest.ownerId) return true;
308
+ if (!ownership) return false;
309
+ if (manifest.ownerId === ownership.ownerId) return true;
310
+ return !manifest.ownerHeartbeatAt || manifest.ownerHeartbeatAt <= ownership.staleBefore;
311
+ }
312
+ function assertManifestOwnership(manifest, ownerId) {
313
+ if (manifest.ownerId !== (ownerId ?? null)) {
314
+ throw new VoiceRecordingOwnedError(manifest.recordingId);
315
+ }
316
+ }
317
+ function codecForMimeType(mimeType) {
318
+ const match = /(?:^|;)\s*codecs?\s*=\s*"?([^;"]+)/iu.exec(mimeType);
319
+ return match?.[1]?.trim().toLowerCase() ?? null;
320
+ }
321
+ async function sha256Hex(bytes) {
322
+ const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer);
323
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
324
+ }
325
+ function openDatabase(factory, name) {
326
+ return new Promise((resolve, reject) => {
327
+ const request = factory.open(name, DATABASE_VERSION);
328
+ request.onupgradeneeded = () => {
329
+ const database = request.result;
330
+ if (!database.objectStoreNames.contains(MANIFEST_STORE)) {
331
+ database.createObjectStore(MANIFEST_STORE, { keyPath: "recordingId" });
332
+ }
333
+ if (!database.objectStoreNames.contains(CHUNK_STORE)) {
334
+ const chunks = database.createObjectStore(CHUNK_STORE, {
335
+ keyPath: ["recordingId", "chunkNumber"]
336
+ });
337
+ chunks.createIndex(CHUNKS_BY_RECORDING, "recordingId", { unique: false });
338
+ }
339
+ };
340
+ request.onsuccess = () => {
341
+ const database = request.result;
342
+ database.onversionchange = () => database.close();
343
+ resolve(database);
344
+ };
345
+ request.onerror = () => reject(request.error ?? new Error("Failed to open voice storage."));
346
+ request.onblocked = () => reject(new Error("Voice recording storage upgrade is blocked."));
347
+ });
348
+ }
349
+ function requestResult(request) {
350
+ return new Promise((resolve, reject) => {
351
+ request.onsuccess = () => resolve(request.result);
352
+ request.onerror = () => reject(request.error ?? new Error("Voice recording storage failed."));
353
+ });
354
+ }
355
+ function transactionComplete(transaction) {
356
+ return new Promise((resolve, reject) => {
357
+ transaction.oncomplete = () => resolve();
358
+ transaction.onabort = () => reject(transaction.error ?? new Error("Voice storage aborted."));
359
+ transaction.onerror = () => reject(transaction.error ?? new Error("Voice storage failed."));
360
+ });
361
+ }
362
+
23
363
  // src/hooks/use-transcription.ts
24
364
  import {
25
365
  authorizeTranscriptionAdapter,
@@ -590,7 +930,192 @@ function transcriptionPolicyRevision(policy, selection) {
590
930
 
591
931
  // src/hooks/use-voice-input.ts
592
932
  import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
933
+
934
+ // src/voice-recording-owner.ts
935
+ var VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
936
+ var VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
937
+ var VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
938
+ var OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
939
+ var OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5e3;
940
+ var OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
941
+ var OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
942
+ var sharedLeasePromise = null;
943
+ var sharedLeaseConsumers = 0;
944
+ async function acquireDefaultVoiceRecordingOwnerLease() {
945
+ sharedLeaseConsumers += 1;
946
+ sharedLeasePromise ??= createUnderlyingOwnerLease();
947
+ let underlying;
948
+ try {
949
+ underlying = await sharedLeasePromise;
950
+ } catch (error) {
951
+ sharedLeaseConsumers -= 1;
952
+ if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
953
+ throw error;
954
+ }
955
+ let released = false;
956
+ return {
957
+ ownerId: underlying.ownerId,
958
+ release: () => {
959
+ if (released) return;
960
+ released = true;
961
+ sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
962
+ if (sharedLeaseConsumers === 0) {
963
+ sharedLeasePromise = null;
964
+ underlying.release();
965
+ }
966
+ }
967
+ };
968
+ }
969
+ async function createUnderlyingOwnerLease() {
970
+ const candidate = readSessionOwnerId() ?? crypto.randomUUID();
971
+ const reloadNavigation = isReloadNavigation();
972
+ if (hasWebLocks()) {
973
+ const retained = await tryAcquireWebLock(
974
+ candidate,
975
+ reloadNavigation ? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS : OWNER_LOCK_RELOAD_GRACE_MILLISECONDS
976
+ );
977
+ if (retained) {
978
+ writeSessionOwnerId(candidate);
979
+ return retained;
980
+ }
981
+ const rotated = crypto.randomUUID();
982
+ writeSessionOwnerId(rotated);
983
+ const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
984
+ if (acquired) return acquired;
985
+ }
986
+ const broadcastLease = await tryAcquireBroadcastLease(
987
+ readSessionOwnerId() ?? candidate,
988
+ reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0
989
+ );
990
+ if (broadcastLease) return broadcastLease;
991
+ const ownerId = crypto.randomUUID();
992
+ writeSessionOwnerId(ownerId);
993
+ return { ownerId, release: () => void 0 };
994
+ }
995
+ function hasWebLocks() {
996
+ return typeof navigator !== "undefined" && navigator.locks !== null && navigator.locks !== void 0 && typeof navigator.locks.request === "function";
997
+ }
998
+ async function tryAcquireWebLock(ownerId, waitMilliseconds) {
999
+ if (!hasWebLocks()) return null;
1000
+ return await new Promise((resolve) => {
1001
+ const controller = new AbortController();
1002
+ let settled = false;
1003
+ const settle = (lease) => {
1004
+ if (settled) return;
1005
+ settled = true;
1006
+ resolve(lease);
1007
+ };
1008
+ const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
1009
+ void navigator.locks.request(
1010
+ `${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
1011
+ { mode: "exclusive", signal: controller.signal },
1012
+ async () => {
1013
+ clearTimeout(timeout);
1014
+ let releaseLock = null;
1015
+ const held = new Promise((release) => {
1016
+ releaseLock = release;
1017
+ });
1018
+ settle({
1019
+ ownerId,
1020
+ release: () => releaseLock?.()
1021
+ });
1022
+ await held;
1023
+ }
1024
+ ).catch(() => {
1025
+ clearTimeout(timeout);
1026
+ settle(null);
1027
+ });
1028
+ });
1029
+ }
1030
+ async function tryAcquireBroadcastLease(initialOwnerId, retainCandidateAttempts) {
1031
+ const BroadcastChannelConstructor = typeof window !== "undefined" ? window.BroadcastChannel : void 0;
1032
+ if (!BroadcastChannelConstructor) return null;
1033
+ let ownerId = initialOwnerId;
1034
+ for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
1035
+ const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
1036
+ const instanceId = crypto.randomUUID();
1037
+ let occupied = false;
1038
+ const onMessage = (event) => {
1039
+ const message = ownerCoordinationMessage(event.data);
1040
+ if (!message || message.ownerId !== ownerId) return;
1041
+ if (message.type === "voice-recording-owner.probe") {
1042
+ if (message.instanceId === instanceId) return;
1043
+ channel.postMessage({
1044
+ type: "voice-recording-owner.occupied",
1045
+ ownerId,
1046
+ targetInstanceId: message.instanceId
1047
+ });
1048
+ return;
1049
+ }
1050
+ if (message.targetInstanceId === instanceId) occupied = true;
1051
+ };
1052
+ channel.addEventListener("message", onMessage);
1053
+ channel.postMessage({
1054
+ type: "voice-recording-owner.probe",
1055
+ ownerId,
1056
+ instanceId
1057
+ });
1058
+ await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
1059
+ if (!occupied) {
1060
+ writeSessionOwnerId(ownerId);
1061
+ return {
1062
+ ownerId,
1063
+ release: () => {
1064
+ channel.removeEventListener("message", onMessage);
1065
+ channel.close();
1066
+ }
1067
+ };
1068
+ }
1069
+ channel.removeEventListener("message", onMessage);
1070
+ channel.close();
1071
+ if (attempt < retainCandidateAttempts) continue;
1072
+ ownerId = crypto.randomUUID();
1073
+ writeSessionOwnerId(ownerId);
1074
+ }
1075
+ return null;
1076
+ }
1077
+ function isReloadNavigation() {
1078
+ if (typeof performance === "undefined") return false;
1079
+ return performance.getEntriesByType("navigation").some((entry) => {
1080
+ return "type" in entry && entry.type === "reload";
1081
+ });
1082
+ }
1083
+ function ownerCoordinationMessage(value) {
1084
+ if (!value || typeof value !== "object") return null;
1085
+ const candidate = value;
1086
+ if (candidate.type === "voice-recording-owner.probe" && typeof candidate.ownerId === "string" && typeof candidate.instanceId === "string") {
1087
+ return candidate;
1088
+ }
1089
+ if (candidate.type === "voice-recording-owner.occupied" && typeof candidate.ownerId === "string" && typeof candidate.targetInstanceId === "string") {
1090
+ return candidate;
1091
+ }
1092
+ return null;
1093
+ }
1094
+ function readSessionOwnerId() {
1095
+ try {
1096
+ return typeof window === "undefined" ? null : window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
1097
+ } catch {
1098
+ return null;
1099
+ }
1100
+ }
1101
+ function writeSessionOwnerId(ownerId) {
1102
+ try {
1103
+ window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
1104
+ } catch {
1105
+ }
1106
+ }
1107
+ function delay(milliseconds) {
1108
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
1109
+ }
1110
+
1111
+ // src/hooks/use-voice-input.ts
1112
+ var VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5e3;
1113
+ var VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5e3;
1114
+ var VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 3e4;
1115
+ var VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
593
1116
  var MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
1117
+ var createDefaultVoiceRecordingId = () => crypto.randomUUID();
1118
+ var currentDate = () => /* @__PURE__ */ new Date();
594
1119
  function useVoiceInput({
595
1120
  client,
596
1121
  workspaceId,
@@ -599,120 +1124,738 @@ function useVoiceInput({
599
1124
  value,
600
1125
  setValue,
601
1126
  focusInput,
602
- disabled = false
1127
+ disabled = false,
1128
+ createRecordingStore,
1129
+ createRecordingId = createDefaultVoiceRecordingId,
1130
+ createOwnerId,
1131
+ now = currentDate
603
1132
  }) {
604
1133
  const [status, setStatus] = useState2("idle");
605
1134
  const [error, setError] = useState2(null);
606
1135
  const [stream, setStream] = useState2(null);
1136
+ const [recordingId, setRecordingId] = useState2(null);
1137
+ const [durationSeconds, setDurationSeconds] = useState2(0);
1138
+ const [locallySaved, setLocallySaved] = useState2(false);
1139
+ const [storageAvailable, setStorageAvailable] = useState2(
1140
+ () => Boolean(createRecordingStore) || typeof globalThis.indexedDB !== "undefined"
1141
+ );
1142
+ const createRecordingStoreRef = useRef2(createRecordingStore);
1143
+ createRecordingStoreRef.current = createRecordingStore;
1144
+ const nowRef = useRef2(now);
1145
+ nowRef.current = now;
1146
+ const readNow = useCallback2(() => nowRef.current(), []);
607
1147
  const generationRef = useRef2(0);
1148
+ const workspaceIdRef = useRef2(workspaceId);
1149
+ const ownerIdRef = useRef2(null);
1150
+ const ownerLeaseRef = useRef2(null);
1151
+ const ownerIdPromiseRef = useRef2(null);
1152
+ const createOwnerIdRef = useRef2(createOwnerId);
608
1153
  const recorderRef = useRef2(null);
609
1154
  const streamRef = useRef2(null);
610
1155
  const timerRef = useRef2(null);
1156
+ const ownerHeartbeatTimerRef = useRef2(null);
611
1157
  const controllerRef = useRef2(null);
612
1158
  const valueRef = useRef2(value);
1159
+ const storeRef = useRef2(null);
1160
+ const storePromiseRef = useRef2(null);
1161
+ const ownsStoreRef = useRef2(false);
1162
+ const manifestRef = useRef2(null);
1163
+ const persistenceQueueRef = useRef2(Promise.resolve());
1164
+ const persistenceErrorRef = useRef2(null);
1165
+ const captureLimitErrorRef = useRef2(null);
1166
+ const captureSettledRef = useRef2(Promise.resolve());
1167
+ const resolveCaptureSettledRef = useRef2(null);
1168
+ const statusRef = useRef2(status);
613
1169
  valueRef.current = value;
614
- const clearRuntime = useCallback2(() => {
1170
+ statusRef.current = status;
1171
+ workspaceIdRef.current = workspaceId;
1172
+ const ensureOwnerId = useCallback2(async () => {
1173
+ if (ownerIdRef.current) return ownerIdRef.current;
1174
+ ownerIdPromiseRef.current ??= (async () => {
1175
+ const injectedOwnerId = createOwnerIdRef.current?.();
1176
+ if (injectedOwnerId) {
1177
+ ownerIdRef.current = injectedOwnerId;
1178
+ return injectedOwnerId;
1179
+ }
1180
+ const lease = await acquireDefaultVoiceRecordingOwnerLease();
1181
+ ownerLeaseRef.current = lease;
1182
+ ownerIdRef.current = lease.ownerId;
1183
+ return lease.ownerId;
1184
+ })();
1185
+ return await ownerIdPromiseRef.current;
1186
+ }, []);
1187
+ const stopOwnerHeartbeat = useCallback2(() => {
1188
+ if (ownerHeartbeatTimerRef.current) clearInterval(ownerHeartbeatTimerRef.current);
1189
+ ownerHeartbeatTimerRef.current = null;
1190
+ }, []);
1191
+ const clearCaptureRuntime = useCallback2((expectedStream) => {
1192
+ const captureStream = expectedStream ?? streamRef.current;
1193
+ captureStream?.getTracks().forEach((track) => track.stop());
1194
+ if (expectedStream && streamRef.current !== expectedStream) return;
615
1195
  if (timerRef.current) clearTimeout(timerRef.current);
616
1196
  timerRef.current = null;
617
- streamRef.current?.getTracks().forEach((track) => track.stop());
618
1197
  streamRef.current = null;
619
1198
  recorderRef.current = null;
620
1199
  setStream(null);
621
1200
  }, []);
622
- const cancel = useCallback2(() => {
623
- generationRef.current += 1;
624
- controllerRef.current?.abort();
625
- controllerRef.current = null;
626
- const recorder = recorderRef.current;
627
- clearRuntime();
628
- if (recorder && recorder.state !== "inactive") recorder.stop();
629
- setStatus("idle");
630
- setError(null);
631
- focusInput();
632
- }, [clearRuntime, focusInput]);
633
- const stop = () => {
1201
+ const clearVisibleRecording = useCallback2(() => {
1202
+ stopOwnerHeartbeat();
1203
+ manifestRef.current = null;
1204
+ setRecordingId(null);
1205
+ setDurationSeconds(0);
1206
+ setLocallySaved(false);
1207
+ }, [stopOwnerHeartbeat]);
1208
+ const ensureStore = useCallback2(async () => {
1209
+ if (storeRef.current) return storeRef.current;
1210
+ if (!storePromiseRef.current) {
1211
+ storePromiseRef.current = Promise.resolve().then(() => {
1212
+ const factory = createRecordingStoreRef.current;
1213
+ const store = factory?.() ?? new IndexedDbVoiceRecordingStore();
1214
+ ownsStoreRef.current = factory === void 0;
1215
+ storeRef.current = store;
1216
+ setStorageAvailable(true);
1217
+ return store;
1218
+ });
1219
+ }
1220
+ try {
1221
+ return await storePromiseRef.current;
1222
+ } catch (reason) {
1223
+ storePromiseRef.current = null;
1224
+ if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
1225
+ throw reason;
1226
+ }
1227
+ }, []);
1228
+ const rememberManifest = useCallback2((manifest) => {
1229
+ manifestRef.current = manifest;
1230
+ setRecordingId(manifest.recordingId);
1231
+ setDurationSeconds(manifest.totalDurationMilliseconds / 1e3);
1232
+ setLocallySaved(manifest.chunkCount > 0);
1233
+ }, []);
1234
+ const beginOwnerHeartbeat = useCallback2(
1235
+ (manifest) => {
1236
+ stopOwnerHeartbeat();
1237
+ ownerHeartbeatTimerRef.current = setInterval(() => {
1238
+ if (manifestRef.current?.recordingId !== manifest.recordingId || manifestRef.current.ownerId !== ownerIdRef.current) {
1239
+ stopOwnerHeartbeat();
1240
+ return;
1241
+ }
1242
+ const updatedAt = readNow().toISOString();
1243
+ void ensureStore().then(
1244
+ (store) => store.updateManifest(
1245
+ manifest.recordingId,
1246
+ { ownerHeartbeatAt: updatedAt },
1247
+ updatedAt,
1248
+ ownerIdRef.current ?? void 0
1249
+ )
1250
+ ).then((updated) => {
1251
+ if (manifestRef.current?.recordingId === updated.recordingId) {
1252
+ manifestRef.current = updated;
1253
+ }
1254
+ }).catch(() => void 0);
1255
+ }, VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS);
1256
+ },
1257
+ [ensureStore, readNow, stopOwnerHeartbeat]
1258
+ );
1259
+ const preserveForRetry = useCallback2(
1260
+ async (manifest, code, generation) => {
1261
+ if (generation !== generationRef.current) return;
1262
+ rememberManifest(manifest);
1263
+ const transcriptReady = manifest.finalizationState === "transcript-ready" && manifest.transcriptText !== null;
1264
+ setStatus(transcriptReady ? "transcript-ready" : code ? "error" : "recovered");
1265
+ setError(transcriptReady ? "handoff_uncertain" : code);
1266
+ focusInput();
1267
+ },
1268
+ [focusInput, rememberManifest]
1269
+ );
1270
+ const updateManifestBestEffort = useCallback2(
1271
+ async (manifest, update) => {
1272
+ try {
1273
+ return await (await ensureStore()).updateManifest(
1274
+ manifest.recordingId,
1275
+ update,
1276
+ readNow().toISOString(),
1277
+ ownerIdRef.current ?? void 0
1278
+ );
1279
+ } catch {
1280
+ return manifest;
1281
+ }
1282
+ },
1283
+ [ensureStore, readNow]
1284
+ );
1285
+ const loadNextRecoverable = useCallback2(
1286
+ async (generation) => {
1287
+ const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId;
1288
+ const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
1289
+ if (!active()) return;
1290
+ const staleBefore = new Date(
1291
+ readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS
1292
+ ).toISOString();
1293
+ await store.cleanupHandedOffManifests({ ownerId, staleBefore }).catch(() => void 0);
1294
+ if (!active()) return;
1295
+ const manifests = await store.listRecoverableManifests(workspaceId, {
1296
+ ownerId,
1297
+ staleBefore
1298
+ });
1299
+ if (!active()) return;
1300
+ for (const candidate of manifests) {
1301
+ try {
1302
+ const claimedAt = readNow().toISOString();
1303
+ const claimed = await store.claimManifest(
1304
+ candidate.recordingId,
1305
+ ownerId,
1306
+ claimedAt,
1307
+ staleBefore
1308
+ );
1309
+ if (!active()) {
1310
+ await store.updateManifest(
1311
+ claimed.recordingId,
1312
+ { ownerId: null, ownerHeartbeatAt: null },
1313
+ readNow().toISOString(),
1314
+ ownerId
1315
+ ).catch(() => void 0);
1316
+ return;
1317
+ }
1318
+ rememberManifest(claimed);
1319
+ beginOwnerHeartbeat(claimed);
1320
+ setStatus(
1321
+ claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null ? "transcript-ready" : "recovered"
1322
+ );
1323
+ setError(null);
1324
+ return;
1325
+ } catch (reason) {
1326
+ if (reason instanceof VoiceRecordingOwnedError) continue;
1327
+ throw reason;
1328
+ }
1329
+ }
1330
+ if (active() && !manifestRef.current) {
1331
+ setStatus("idle");
1332
+ setError(null);
1333
+ }
1334
+ },
1335
+ [beginOwnerHeartbeat, ensureOwnerId, ensureStore, readNow, rememberManifest, workspaceId]
1336
+ );
1337
+ const finalizePersistedRecording = useCallback2(
1338
+ async (generation) => {
1339
+ const manifest = manifestRef.current;
1340
+ const maxSizeBytes = capability?.maxSizeBytes;
1341
+ if (!manifest || !client || !maxSizeBytes || manifest.workspaceId !== workspaceId) return;
1342
+ const controller = new AbortController();
1343
+ controllerRef.current?.abort();
1344
+ controllerRef.current = controller;
1345
+ const active = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && !controller.signal.aborted && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId;
1346
+ let transcribing = manifest;
1347
+ try {
1348
+ const store = await ensureStore();
1349
+ if (!active()) return;
1350
+ transcribing = await store.updateManifest(
1351
+ manifest.recordingId,
1352
+ {
1353
+ captureState: "stopped",
1354
+ uploadState: "syncing",
1355
+ transcriptionState: "transcribing",
1356
+ ownerHeartbeatAt: readNow().toISOString()
1357
+ },
1358
+ readNow().toISOString(),
1359
+ ownerIdRef.current ?? void 0
1360
+ );
1361
+ if (!active()) return;
1362
+ rememberManifest(transcribing);
1363
+ setStatus("transcribing");
1364
+ setError(null);
1365
+ if (transcribing.totalBytes > maxSizeBytes) {
1366
+ throw { code: "too_large" };
1367
+ }
1368
+ const chunks = await store.listChunks(transcribing.recordingId);
1369
+ if (!active()) return;
1370
+ if (chunks.length === 0) {
1371
+ const retained = await store.updateManifest(
1372
+ transcribing.recordingId,
1373
+ { uploadState: "retrying", transcriptionState: "retrying" },
1374
+ readNow().toISOString(),
1375
+ ownerIdRef.current ?? void 0
1376
+ );
1377
+ if (!active()) return;
1378
+ await preserveForRetry(retained, "invalid_audio", generation);
1379
+ return;
1380
+ }
1381
+ const audio = new Blob(
1382
+ chunks.sort((left, right) => left.chunkNumber - right.chunkNumber).map((chunk) => chunk.audio),
1383
+ { type: transcribing.mimeType }
1384
+ );
1385
+ if (!active()) return;
1386
+ if (audio.size > maxSizeBytes) throw { code: "too_large" };
1387
+ const response = await client.transcribeAudio(workspaceId, {
1388
+ audio,
1389
+ mimeType: audio.type,
1390
+ durationSeconds: transcribing.totalDurationMilliseconds / 1e3,
1391
+ signal: controller.signal
1392
+ });
1393
+ if (!active()) return;
1394
+ const ready = await store.updateManifest(
1395
+ transcribing.recordingId,
1396
+ {
1397
+ uploadState: "complete",
1398
+ transcriptionState: "complete",
1399
+ finalizationState: "transcript-ready",
1400
+ transcriptText: response.text,
1401
+ ownerHeartbeatAt: readNow().toISOString()
1402
+ },
1403
+ readNow().toISOString(),
1404
+ ownerIdRef.current ?? void 0
1405
+ );
1406
+ if (!active()) return;
1407
+ rememberManifest(ready);
1408
+ const next = appendFinalTranscript(valueRef.current, response.text);
1409
+ if (next !== valueRef.current) {
1410
+ valueRef.current = next;
1411
+ setValue(next);
1412
+ }
1413
+ let handedOff;
1414
+ try {
1415
+ handedOff = await store.updateManifest(
1416
+ ready.recordingId,
1417
+ { finalizationState: "handed-off" },
1418
+ readNow().toISOString(),
1419
+ ownerIdRef.current ?? void 0
1420
+ );
1421
+ } catch {
1422
+ if (!active()) return;
1423
+ rememberManifest(ready);
1424
+ setStatus("transcript-ready");
1425
+ setError("handoff_uncertain");
1426
+ focusInput();
1427
+ return;
1428
+ }
1429
+ if (!active()) return;
1430
+ rememberManifest(handedOff);
1431
+ await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
1432
+ if (!active()) return;
1433
+ clearVisibleRecording();
1434
+ setStatus("idle");
1435
+ setError(null);
1436
+ focusInput();
1437
+ await loadNextRecoverable(generation);
1438
+ } catch (reason) {
1439
+ if (!active()) return;
1440
+ const retained = await updateManifestBestEffort(transcribing, {
1441
+ uploadState: "retrying",
1442
+ transcriptionState: "retrying"
1443
+ });
1444
+ if (!active()) return;
1445
+ await preserveForRetry(
1446
+ retained,
1447
+ controller.signal.aborted ? null : errorCode(reason),
1448
+ generation
1449
+ );
1450
+ } finally {
1451
+ if (controllerRef.current === controller) controllerRef.current = null;
1452
+ }
1453
+ },
1454
+ [
1455
+ capability?.maxSizeBytes,
1456
+ clearVisibleRecording,
1457
+ client,
1458
+ ensureStore,
1459
+ focusInput,
1460
+ loadNextRecoverable,
1461
+ readNow,
1462
+ preserveForRetry,
1463
+ rememberManifest,
1464
+ setValue,
1465
+ updateManifestBestEffort,
1466
+ workspaceId
1467
+ ]
1468
+ );
1469
+ const stop = useCallback2(() => {
634
1470
  const recorder = recorderRef.current;
635
1471
  if (!recorder || recorder.state === "inactive") return;
636
1472
  if (timerRef.current) clearTimeout(timerRef.current);
637
1473
  timerRef.current = null;
638
- setStatus("transcribing");
1474
+ setStatus("saving");
639
1475
  recorder.stop();
640
- };
641
- const start = async () => {
642
- if (disabled || !client || !enabled || !capability?.available || status === "requesting-permission" || status === "recording" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
1476
+ }, []);
1477
+ const start = useCallback2(async () => {
1478
+ if (disabled || !client || !enabled || !capability?.available || manifestRef.current !== null || status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing" || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === "undefined") {
1479
+ return false;
1480
+ }
1481
+ const prerequisiteGeneration = generationRef.current;
1482
+ let store;
1483
+ let ownerId;
1484
+ try {
1485
+ [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
1486
+ } catch (reason) {
1487
+ setStatus("error");
1488
+ setError(
1489
+ reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
1490
+ );
1491
+ return false;
1492
+ }
1493
+ if (prerequisiteGeneration !== generationRef.current || workspaceIdRef.current !== workspaceId || ownerIdRef.current !== ownerId || manifestRef.current !== null || statusRef.current === "requesting-permission" || statusRef.current === "recording" || statusRef.current === "saving" || statusRef.current === "transcribing") {
643
1494
  return false;
644
1495
  }
645
- const voiceClient = client;
646
1496
  const generation = ++generationRef.current;
1497
+ const startAttemptIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId;
1498
+ let attemptStream = null;
1499
+ let attemptRecorder = null;
1500
+ let attemptPersistenceQueue = Promise.resolve();
1501
+ let attemptPersistenceError = null;
1502
+ let attemptCaptureLimitError = null;
1503
+ const attemptCaptureSettlement = { resolve: null };
1504
+ const attemptManifest = { current: null };
1505
+ let attemptNextChunkNumber = 0;
1506
+ let attemptLastChunkEndMilliseconds = 0;
1507
+ let attemptRecordingStartedAt = 0;
1508
+ const attemptOwnsSharedCapture = () => startAttemptIsCurrent() && attemptStream !== null && streamRef.current === attemptStream && attemptRecorder !== null && recorderRef.current === attemptRecorder && attemptManifest.current !== null && manifestRef.current?.recordingId === attemptManifest.current.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
647
1509
  setStatus("requesting-permission");
648
1510
  setError(null);
1511
+ let acquiredStream = null;
1512
+ let recorderStarted = false;
649
1513
  try {
650
1514
  const mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
651
- if (generation !== generationRef.current) {
1515
+ acquiredStream = mediaStream;
1516
+ attemptStream = mediaStream;
1517
+ if (!startAttemptIsCurrent()) {
652
1518
  mediaStream.getTracks().forEach((track) => track.stop());
653
1519
  return false;
654
1520
  }
1521
+ streamRef.current = mediaStream;
1522
+ setStream(mediaStream);
655
1523
  const mimeType = chooseMimeType(capability.acceptedMimeTypes);
656
1524
  const recorder = new MediaRecorder(mediaStream, mimeType ? { mimeType } : void 0);
657
- const chunks = [];
658
- const startedAt = Date.now();
659
- streamRef.current = mediaStream;
1525
+ attemptRecorder = recorder;
1526
+ const createdAt = readNow();
1527
+ const manifest = createVoiceRecordingManifest({
1528
+ recordingId: createRecordingId(),
1529
+ workspaceId,
1530
+ mimeType: recorder.mimeType || mimeType || "audio/webm",
1531
+ createdAt: createdAt.toISOString(),
1532
+ ownerId
1533
+ });
1534
+ attemptManifest.current = manifest;
1535
+ await store.createManifest(manifest);
1536
+ if (!startAttemptIsCurrent()) {
1537
+ await store.discard(manifest.recordingId, ownerId);
1538
+ clearCaptureRuntime(mediaStream);
1539
+ return false;
1540
+ }
1541
+ rememberManifest(manifest);
1542
+ beginOwnerHeartbeat(manifest);
1543
+ persistenceQueueRef.current = attemptPersistenceQueue;
1544
+ persistenceErrorRef.current = null;
1545
+ captureLimitErrorRef.current = null;
660
1546
  recorderRef.current = recorder;
661
- setStream(mediaStream);
662
1547
  recorder.ondataavailable = (event) => {
663
- if (event.data.size > 0) chunks.push(event.data);
664
- };
665
- recorder.onstop = () => {
666
- clearRuntime();
667
- if (generation !== generationRef.current) return;
668
- const audio = new Blob(chunks, { type: recorder.mimeType || mimeType || "audio/webm" });
669
- if (audio.size === 0) {
670
- setStatus("error");
671
- setError("invalid_audio");
1548
+ if (event.data.size === 0 || attemptPersistenceError || attemptCaptureLimitError) {
672
1549
  return;
673
1550
  }
674
- const controller = new AbortController();
675
- controllerRef.current = controller;
676
- void voiceClient.transcribeAudio(workspaceId, {
677
- audio,
678
- mimeType: audio.type,
679
- durationSeconds: Math.max(0, (Date.now() - startedAt) / 1e3),
680
- signal: controller.signal
681
- }).then((response) => {
682
- if (generation !== generationRef.current || controller.signal.aborted) return;
683
- const next = appendFinalTranscript(valueRef.current, response.text);
684
- if (next !== valueRef.current) {
685
- valueRef.current = next;
686
- setValue(next);
1551
+ const chunkNumber = attemptNextChunkNumber++;
1552
+ const elapsed = Math.max(0, readNow().getTime() - attemptRecordingStartedAt);
1553
+ const eventTimecode = Number.isFinite(event.timecode) ? Math.max(0, event.timecode) : 0;
1554
+ const endMilliseconds = Math.max(attemptLastChunkEndMilliseconds, eventTimecode, elapsed);
1555
+ const startMilliseconds = attemptLastChunkEndMilliseconds;
1556
+ const durationMilliseconds = Math.max(0, endMilliseconds - startMilliseconds);
1557
+ attemptLastChunkEndMilliseconds = endMilliseconds;
1558
+ attemptPersistenceQueue = attemptPersistenceQueue.then(async () => {
1559
+ const result = await store.persistChunk({
1560
+ recordingId: manifest.recordingId,
1561
+ ownerId,
1562
+ chunkNumber,
1563
+ capturedAt: readNow().toISOString(),
1564
+ startMilliseconds,
1565
+ durationMilliseconds,
1566
+ mimeType: manifest.mimeType,
1567
+ audio: event.data
1568
+ });
1569
+ if (!startAttemptIsCurrent()) return;
1570
+ rememberManifest(result.manifest);
1571
+ if (result.manifest.totalBytes > capability.maxSizeBytes) {
1572
+ attemptCaptureLimitError = "too_large";
1573
+ if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
1574
+ if (recorder.state !== "inactive") recorder.stop();
687
1575
  }
688
- setStatus("idle");
689
- focusInput();
690
1576
  }).catch((reason) => {
691
- if (generation !== generationRef.current || controller.signal.aborted) return;
692
- setStatus("error");
693
- setError(errorCode(reason));
694
- }).finally(() => {
695
- if (controllerRef.current === controller) controllerRef.current = null;
1577
+ attemptPersistenceError = reason;
1578
+ if (attemptOwnsSharedCapture()) persistenceErrorRef.current = reason;
1579
+ if (recorder.state !== "inactive") recorder.stop();
1580
+ });
1581
+ if (attemptOwnsSharedCapture()) {
1582
+ persistenceQueueRef.current = attemptPersistenceQueue;
1583
+ }
1584
+ };
1585
+ recorder.onstop = () => {
1586
+ if (attemptOwnsSharedCapture()) {
1587
+ clearCaptureRuntime(mediaStream);
1588
+ } else {
1589
+ mediaStream.getTracks().forEach((track) => track.stop());
1590
+ }
1591
+ void attemptPersistenceQueue.catch((reason) => {
1592
+ attemptPersistenceError = reason;
1593
+ }).then(async () => {
1594
+ const resolveSettled = attemptCaptureSettlement.resolve;
1595
+ attemptCaptureSettlement.resolve = null;
1596
+ resolveSettled?.();
1597
+ if (resolveCaptureSettledRef.current === resolveSettled) {
1598
+ resolveCaptureSettledRef.current = null;
1599
+ }
1600
+ const stoppedCaptureIsCurrent = () => generation === generationRef.current && workspaceIdRef.current === workspaceId && ownerIdRef.current === ownerId && manifestRef.current?.recordingId === manifest.recordingId && manifestRef.current.workspaceId === workspaceId && manifestRef.current.ownerId === ownerId;
1601
+ if (!stoppedCaptureIsCurrent()) return;
1602
+ const current = manifestRef.current;
1603
+ if (!current) return;
1604
+ const stopped = await updateManifestBestEffort(current, { captureState: "stopped" });
1605
+ if (!stoppedCaptureIsCurrent() || stopped.recordingId !== manifest.recordingId || stopped.workspaceId !== workspaceId || stopped.ownerId !== ownerId) {
1606
+ return;
1607
+ }
1608
+ rememberManifest(stopped);
1609
+ if (attemptPersistenceError) {
1610
+ await preserveForRetry(stopped, "storage_unavailable", generation);
1611
+ return;
1612
+ }
1613
+ if (attemptCaptureLimitError) {
1614
+ await preserveForRetry(stopped, attemptCaptureLimitError, generation);
1615
+ return;
1616
+ }
1617
+ await finalizePersistedRecording(generation);
696
1618
  });
697
1619
  };
698
- recorder.start();
1620
+ captureSettledRef.current = new Promise((resolve) => {
1621
+ attemptCaptureSettlement.resolve = resolve;
1622
+ resolveCaptureSettledRef.current = resolve;
1623
+ });
1624
+ attemptRecordingStartedAt = readNow().getTime();
1625
+ recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
1626
+ recorderStarted = true;
699
1627
  setStatus("recording");
700
1628
  timerRef.current = setTimeout(
701
- () => stop(),
702
- Math.min(capability.maxDurationSeconds, 60) * 1e3
1629
+ stop,
1630
+ Math.min(capability.maxDurationSeconds, VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS) * 1e3
703
1631
  );
704
1632
  return true;
705
1633
  } catch (reason) {
1634
+ const resolveSettled = attemptCaptureSettlement.resolve;
1635
+ attemptCaptureSettlement.resolve = null;
1636
+ resolveSettled?.();
1637
+ if (resolveCaptureSettledRef.current === resolveSettled) {
1638
+ resolveCaptureSettledRef.current = null;
1639
+ }
1640
+ if (acquiredStream) {
1641
+ if (startAttemptIsCurrent() && streamRef.current === acquiredStream) {
1642
+ clearCaptureRuntime(acquiredStream);
1643
+ } else {
1644
+ acquiredStream.getTracks().forEach((track) => track.stop());
1645
+ }
1646
+ }
1647
+ const failedManifest = attemptManifest.current;
1648
+ if (failedManifest && !recorderStarted) {
1649
+ const discarded = await store.discard(failedManifest.recordingId, ownerId).then(() => true).catch(() => false);
1650
+ if (!discarded) {
1651
+ await store.updateManifest(
1652
+ failedManifest.recordingId,
1653
+ { captureState: "stopped", ownerId: null, ownerHeartbeatAt: null },
1654
+ readNow().toISOString(),
1655
+ ownerId
1656
+ ).catch(() => void 0);
1657
+ }
1658
+ }
706
1659
  if (generation !== generationRef.current) return false;
707
- clearRuntime();
1660
+ const visibleManifest = manifestRef.current;
1661
+ if (failedManifest && visibleManifest?.recordingId === failedManifest.recordingId) {
1662
+ clearVisibleRecording();
1663
+ }
708
1664
  setStatus("error");
709
- setError(errorCode(reason));
1665
+ setError(
1666
+ reason instanceof VoiceRecordingStorageUnavailableError ? "storage_unavailable" : errorCode(reason)
1667
+ );
710
1668
  return false;
711
1669
  }
712
- };
1670
+ }, [
1671
+ capability,
1672
+ beginOwnerHeartbeat,
1673
+ clearCaptureRuntime,
1674
+ clearVisibleRecording,
1675
+ client,
1676
+ createRecordingId,
1677
+ disabled,
1678
+ enabled,
1679
+ ensureOwnerId,
1680
+ ensureStore,
1681
+ finalizePersistedRecording,
1682
+ readNow,
1683
+ preserveForRetry,
1684
+ rememberManifest,
1685
+ status,
1686
+ stop,
1687
+ updateManifestBestEffort,
1688
+ workspaceId
1689
+ ]);
1690
+ const retry = useCallback2(() => {
1691
+ if (!manifestRef.current || manifestRef.current.finalizationState === "transcript-ready" || manifestRef.current.workspaceId !== workspaceId || !client || disabled || !enabled || status === "saving" || status === "transcribing") {
1692
+ return;
1693
+ }
1694
+ const generation = ++generationRef.current;
1695
+ controllerRef.current?.abort();
1696
+ setError(null);
1697
+ void finalizePersistedRecording(generation);
1698
+ }, [client, disabled, enabled, finalizePersistedRecording, status, workspaceId]);
1699
+ const insertSavedTranscript = useCallback2(async () => {
1700
+ const manifest = manifestRef.current;
1701
+ if (!manifest || manifest.workspaceId !== workspaceId || manifest.finalizationState !== "transcript-ready" || manifest.transcriptText === null) {
1702
+ return;
1703
+ }
1704
+ const generation = ++generationRef.current;
1705
+ controllerRef.current?.abort();
1706
+ controllerRef.current = null;
1707
+ const store = await ensureStore();
1708
+ if (generation !== generationRef.current) return;
1709
+ const next = appendFinalTranscript(valueRef.current, manifest.transcriptText);
1710
+ if (next !== valueRef.current) {
1711
+ valueRef.current = next;
1712
+ setValue(next);
1713
+ }
1714
+ let handedOff;
1715
+ try {
1716
+ handedOff = await store.updateManifest(
1717
+ manifest.recordingId,
1718
+ { finalizationState: "handed-off" },
1719
+ readNow().toISOString(),
1720
+ ownerIdRef.current ?? void 0
1721
+ );
1722
+ } catch {
1723
+ if (generation !== generationRef.current) return;
1724
+ rememberManifest(manifest);
1725
+ setStatus("transcript-ready");
1726
+ setError("handoff_uncertain");
1727
+ focusInput();
1728
+ return;
1729
+ }
1730
+ if (generation !== generationRef.current) return;
1731
+ await store.discard(handedOff.recordingId, ownerIdRef.current ?? void 0).catch(() => void 0);
1732
+ if (generation !== generationRef.current) return;
1733
+ clearVisibleRecording();
1734
+ setStatus("idle");
1735
+ setError(null);
1736
+ focusInput();
1737
+ await loadNextRecoverable(generation);
1738
+ }, [
1739
+ clearVisibleRecording,
1740
+ ensureStore,
1741
+ focusInput,
1742
+ loadNextRecoverable,
1743
+ readNow,
1744
+ rememberManifest,
1745
+ setValue,
1746
+ workspaceId
1747
+ ]);
1748
+ const discard = useCallback2(async () => {
1749
+ const manifest = manifestRef.current;
1750
+ const generation = ++generationRef.current;
1751
+ controllerRef.current?.abort();
1752
+ controllerRef.current = null;
1753
+ const recorder = recorderRef.current;
1754
+ let captureSettled = persistenceQueueRef.current;
1755
+ if (recorder && recorder.state !== "inactive") {
1756
+ captureSettled = captureSettledRef.current;
1757
+ recorder.stop();
1758
+ }
1759
+ clearCaptureRuntime();
1760
+ await captureSettled.catch(() => void 0);
1761
+ if (generation !== generationRef.current) return;
1762
+ if (manifest) {
1763
+ try {
1764
+ await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? void 0);
1765
+ } catch {
1766
+ if (generation !== generationRef.current) return;
1767
+ rememberManifest(manifest);
1768
+ setStatus("error");
1769
+ setError("storage_unavailable");
1770
+ focusInput();
1771
+ return;
1772
+ }
1773
+ }
1774
+ clearVisibleRecording();
1775
+ persistenceErrorRef.current = null;
1776
+ captureLimitErrorRef.current = null;
1777
+ setStatus("idle");
1778
+ setError(null);
1779
+ focusInput();
1780
+ await loadNextRecoverable(generation);
1781
+ }, [
1782
+ clearCaptureRuntime,
1783
+ clearVisibleRecording,
1784
+ ensureStore,
1785
+ focusInput,
1786
+ loadNextRecoverable,
1787
+ rememberManifest
1788
+ ]);
1789
+ const cancel = useCallback2(() => {
1790
+ if (status === "recording" || status === "requesting-permission") {
1791
+ void discard();
1792
+ return;
1793
+ }
1794
+ if (status === "saving" || status === "transcribing") {
1795
+ const generation = ++generationRef.current;
1796
+ controllerRef.current?.abort();
1797
+ controllerRef.current = null;
1798
+ const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
1799
+ void captureSettled.then(async () => {
1800
+ const manifest = manifestRef.current;
1801
+ if (!manifest) return;
1802
+ const retained = await updateManifestBestEffort(manifest, {
1803
+ captureState: "stopped",
1804
+ uploadState: "retrying",
1805
+ transcriptionState: "retrying"
1806
+ });
1807
+ await preserveForRetry(retained, null, generation);
1808
+ });
1809
+ }
1810
+ }, [discard, preserveForRetry, status, updateManifestBestEffort]);
1811
+ useEffect2(() => {
1812
+ const current = manifestRef.current;
1813
+ const generation = ++generationRef.current;
1814
+ if (current && current.workspaceId !== workspaceId) {
1815
+ controllerRef.current?.abort();
1816
+ controllerRef.current = null;
1817
+ const recorder = recorderRef.current;
1818
+ let captureSettled = persistenceQueueRef.current;
1819
+ if (recorder && recorder.state !== "inactive") {
1820
+ captureSettled = captureSettledRef.current;
1821
+ recorder.stop();
1822
+ }
1823
+ clearCaptureRuntime();
1824
+ clearVisibleRecording();
1825
+ setStatus("idle");
1826
+ setError(null);
1827
+ void captureSettled.catch(() => void 0).then(async () => {
1828
+ const store = await ensureStore();
1829
+ const wasProcessing = current.uploadState === "syncing" || current.transcriptionState === "transcribing";
1830
+ await store.updateManifest(
1831
+ current.recordingId,
1832
+ {
1833
+ ...current.captureState === "capturing" ? { captureState: "stopped" } : {},
1834
+ ...wasProcessing ? { uploadState: "retrying", transcriptionState: "retrying" } : {},
1835
+ ownerId: null,
1836
+ ownerHeartbeatAt: null
1837
+ },
1838
+ readNow().toISOString(),
1839
+ ownerIdRef.current ?? void 0
1840
+ );
1841
+ }).catch(() => void 0);
1842
+ }
1843
+ if (!manifestRef.current) {
1844
+ void loadNextRecoverable(generation).catch((reason) => {
1845
+ if (reason instanceof VoiceRecordingStorageUnavailableError) setStorageAvailable(false);
1846
+ });
1847
+ }
1848
+ }, [
1849
+ clearCaptureRuntime,
1850
+ clearVisibleRecording,
1851
+ ensureStore,
1852
+ loadNextRecoverable,
1853
+ readNow,
1854
+ workspaceId
1855
+ ]);
713
1856
  useEffect2(() => {
714
1857
  const onKeyDown = (event) => {
715
- if (event.key === "Escape" && status !== "idle") {
1858
+ if (event.key === "Escape" && (status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing")) {
716
1859
  event.preventDefault();
717
1860
  cancel();
718
1861
  }
@@ -724,20 +1867,61 @@ function useVoiceInput({
724
1867
  () => () => {
725
1868
  generationRef.current += 1;
726
1869
  controllerRef.current?.abort();
1870
+ controllerRef.current = null;
1871
+ const manifest = manifestRef.current;
1872
+ const ownerReady = ownerIdPromiseRef.current;
1873
+ stopOwnerHeartbeat();
727
1874
  const recorder = recorderRef.current;
728
- clearRuntime();
729
- if (recorder && recorder.state !== "inactive") recorder.stop();
1875
+ let captureSettled = persistenceQueueRef.current;
1876
+ if (recorder && recorder.state !== "inactive") {
1877
+ captureSettled = captureSettledRef.current;
1878
+ recorder.stop();
1879
+ }
1880
+ clearCaptureRuntime();
1881
+ void captureSettled.catch(() => void 0).then(async () => {
1882
+ const ownerId = ownerIdRef.current ?? (ownerReady ? await ownerReady.catch(() => null) : null);
1883
+ const store = storeRef.current;
1884
+ if (store && manifest && ownerId) {
1885
+ const wasProcessing = statusRef.current === "saving" || statusRef.current === "transcribing";
1886
+ await store.updateManifest(
1887
+ manifest.recordingId,
1888
+ {
1889
+ ...manifest.captureState === "capturing" ? { captureState: "stopped" } : {},
1890
+ ...wasProcessing ? {
1891
+ uploadState: "retrying",
1892
+ transcriptionState: "retrying"
1893
+ } : {},
1894
+ ownerId: null,
1895
+ ownerHeartbeatAt: null
1896
+ },
1897
+ readNow().toISOString(),
1898
+ ownerId
1899
+ ).catch(() => void 0);
1900
+ }
1901
+ if (ownsStoreRef.current) await store?.close();
1902
+ }).catch(() => void 0).finally(() => {
1903
+ ownerLeaseRef.current?.release();
1904
+ ownerLeaseRef.current = null;
1905
+ });
730
1906
  },
731
- [clearRuntime]
1907
+ [clearCaptureRuntime, readNow, stopOwnerHeartbeat]
732
1908
  );
733
1909
  return {
734
1910
  status,
735
1911
  error,
736
- available: Boolean(capability?.available && enabled && !disabled),
1912
+ available: Boolean(capability?.available && enabled && !disabled && storageAvailable),
737
1913
  stream,
1914
+ recordingId,
1915
+ durationSeconds,
1916
+ locallySaved,
1917
+ hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
1918
+ savedTranscript: manifestRef.current?.finalizationState === "transcript-ready" ? manifestRef.current.transcriptText : null,
738
1919
  start,
739
1920
  stop,
740
- cancel
1921
+ retry,
1922
+ insertSavedTranscript,
1923
+ cancel,
1924
+ discard
741
1925
  };
742
1926
  }
743
1927
  function chooseMimeType(accepted) {
@@ -2503,7 +3687,15 @@ function HelpPanel({
2503
3687
  }
2504
3688
 
2505
3689
  // src/components/composer-transcription-control.tsx
2506
- import { LoaderCircleIcon as LoaderCircleIcon2, MicIcon, SquareIcon, XIcon as XIcon2 } from "lucide-react";
3690
+ import {
3691
+ ClipboardPasteIcon,
3692
+ LoaderCircleIcon as LoaderCircleIcon2,
3693
+ MicIcon,
3694
+ RefreshCwIcon,
3695
+ SquareIcon,
3696
+ Trash2Icon,
3697
+ XIcon as XIcon2
3698
+ } from "lucide-react";
2507
3699
  import { AnimatePresence as AnimatePresence3, motion as motion3 } from "motion/react";
2508
3700
  import { useEffect as useEffect4, useState as useState5 } from "react";
2509
3701
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
@@ -2520,7 +3712,12 @@ var defaultMessages = {
2520
3712
  retry: "Retry voice input",
2521
3713
  requestingPermission: "Requesting microphone\u2026",
2522
3714
  recording: "Recording. Press Escape to cancel.",
3715
+ saving: "Saving audio locally\u2026",
2523
3716
  transcribing: "Transcribing\u2026",
3717
+ recovered: "Recording recovered and saved locally.",
3718
+ recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
3719
+ insertRecoveredTranscript: "Insert saved transcript",
3720
+ discardRecovered: "Discard saved recording",
2524
3721
  unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
2525
3722
  unavailable: "Voice input is unavailable for this workspace.",
2526
3723
  errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
@@ -2528,6 +3725,9 @@ var defaultMessages = {
2528
3725
  errorUnavailable: "Voice input is not configured.",
2529
3726
  errorTooLarge: "Recording is too large. Try a shorter message.",
2530
3727
  errorInvalidAudio: "The recording could not be read. Try again.",
3728
+ errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
3729
+ errorRetryable: "Recording is saved locally. Retry transcription when ready.",
3730
+ errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
2531
3731
  errorUnknown: "Voice input could not start. Try again."
2532
3732
  };
2533
3733
  var WAVEFORM_BARS = 18;
@@ -2541,7 +3741,9 @@ function ComposerTranscriptionControl({
2541
3741
  capability = null,
2542
3742
  workspaceEnabled = false,
2543
3743
  messages: overrides,
2544
- className
3744
+ className,
3745
+ createRecordingStore,
3746
+ createOwnerId
2545
3747
  }) {
2546
3748
  const composer = useChatComposer();
2547
3749
  const messages = { ...defaultMessages, ...overrides };
@@ -2553,14 +3755,18 @@ function ComposerTranscriptionControl({
2553
3755
  value: composer.value,
2554
3756
  setValue: composer.setValue,
2555
3757
  focusInput: composer.focusInput,
2556
- disabled: composer.disabled
3758
+ disabled: composer.disabled,
3759
+ createRecordingStore,
3760
+ createOwnerId
2557
3761
  });
2558
3762
  const { status } = transcription;
2559
- const active = status === "requesting-permission" || status === "recording" || status === "transcribing";
2560
- const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : null;
3763
+ const active = status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing";
3764
+ const recoverable = transcription.hasRecoverableRecording && (status === "recovered" || status === "transcript-ready" || status === "error");
3765
+ const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
3766
+ const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : !transcription.available ? messages.errorStorageUnavailable : null;
2561
3767
  const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
2562
3768
  const errorMessage3 = transcription.error ? transcriptionErrorMessage(transcription.error, messages) : null;
2563
- const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "transcribing" ? messages.transcribing : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
3769
+ const announcement = status === "requesting-permission" ? messages.requestingPermission : status === "recording" ? messages.recording : status === "saving" ? messages.saving : status === "transcribing" ? messages.transcribing : status === "recovered" ? messages.recovered : status === "transcript-ready" ? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorUnknown : unavailableMessage;
2564
3770
  function start(event) {
2565
3771
  if (unavailableMessage) {
2566
3772
  event.preventDefault();
@@ -2574,7 +3780,51 @@ function ComposerTranscriptionControl({
2574
3780
  className: cn("inline-flex min-w-0 items-center gap-1.5", className),
2575
3781
  "data-transcription-status": status,
2576
3782
  children: [
2577
- /* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: active ? /* @__PURE__ */ jsxs4(
3783
+ /* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: recoverable ? /* @__PURE__ */ jsxs4(
3784
+ motion3.span,
3785
+ {
3786
+ initial: { opacity: 0, scale: 0.96 },
3787
+ animate: { opacity: 1, scale: 1 },
3788
+ exit: { opacity: 0, scale: 0.96 },
3789
+ transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
3790
+ className: cn(
3791
+ "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
3792
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
3793
+ ),
3794
+ children: [
3795
+ /* @__PURE__ */ jsx4("span", { className: "max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28", children: savedTranscript ? errorMessage3 ?? messages.recoveredTranscript : status === "error" ? errorMessage3 ?? messages.errorRetryable : messages.recovered }),
3796
+ /* @__PURE__ */ jsx4(Tip, { tip: savedTranscript ? messages.insertRecoveredTranscript : messages.retry, children: /* @__PURE__ */ jsx4(
3797
+ "button",
3798
+ {
3799
+ type: "button",
3800
+ onClick: () => savedTranscript ? void transcription.insertSavedTranscript() : transcription.retry(),
3801
+ "aria-label": savedTranscript ? messages.insertRecoveredTranscript : messages.retry,
3802
+ className: cn(
3803
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
3804
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
3805
+ "hover:bg-og-fg-muted pointer-coarse:size-11"
3806
+ ),
3807
+ children: savedTranscript ? /* @__PURE__ */ jsx4(ClipboardPasteIcon, { className: "size-3.5" }) : /* @__PURE__ */ jsx4(RefreshCwIcon, { className: "size-3.5" })
3808
+ }
3809
+ ) }),
3810
+ /* @__PURE__ */ jsx4(Tip, { tip: messages.discardRecovered, children: /* @__PURE__ */ jsx4(
3811
+ "button",
3812
+ {
3813
+ type: "button",
3814
+ onClick: () => void transcription.discard(),
3815
+ "aria-label": messages.discardRecovered,
3816
+ className: cn(
3817
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
3818
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
3819
+ "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11"
3820
+ ),
3821
+ children: /* @__PURE__ */ jsx4(Trash2Icon, { className: "size-3.5" })
3822
+ }
3823
+ ) })
3824
+ ]
3825
+ },
3826
+ "recovered"
3827
+ ) : active ? /* @__PURE__ */ jsxs4(
2578
3828
  motion3.span,
2579
3829
  {
2580
3830
  initial: { opacity: 0, scale: 0.96 },
@@ -2583,7 +3833,7 @@ function ComposerTranscriptionControl({
2583
3833
  transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
2584
3834
  className: cn(
2585
3835
  "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
2586
- "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-9"
3836
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
2587
3837
  ),
2588
3838
  children: [
2589
3839
  status === "requesting-permission" ? /* @__PURE__ */ jsx4(
@@ -2604,7 +3854,7 @@ function ComposerTranscriptionControl({
2604
3854
  VoiceWaveform,
2605
3855
  {
2606
3856
  stream: status === "recording" ? transcription.stream : null,
2607
- mode: status === "transcribing" ? "transcribing" : "recording"
3857
+ mode: status === "recording" ? "recording" : "transcribing"
2608
3858
  }
2609
3859
  )
2610
3860
  ] }),
@@ -2619,7 +3869,7 @@ function ComposerTranscriptionControl({
2619
3869
  className: cn(
2620
3870
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
2621
3871
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
2622
- "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-9"
3872
+ "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11"
2623
3873
  ),
2624
3874
  children: /* @__PURE__ */ jsx4(XIcon2, { className: "size-3.5" })
2625
3875
  }
@@ -2633,15 +3883,15 @@ function ComposerTranscriptionControl({
2633
3883
  className: cn(
2634
3884
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
2635
3885
  "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
2636
- "hover:bg-og-fg-muted pointer-coarse:size-9"
3886
+ "hover:bg-og-fg-muted pointer-coarse:size-11"
2637
3887
  ),
2638
3888
  children: /* @__PURE__ */ jsx4(SquareIcon, { className: "size-2.5 fill-current" })
2639
3889
  }
2640
3890
  ) })
2641
- ] }) : status === "transcribing" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
3891
+ ] }) : status === "transcribing" || status === "saving" ? /* @__PURE__ */ jsx4("span", { className: "og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap", children: status === "saving" ? messages.saving : messages.transcribing }) : /* @__PURE__ */ jsx4("span", { className: "px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap", children: messages.requestingPermission })
2642
3892
  ]
2643
3893
  },
2644
- status === "transcribing" ? "transcribing" : "capture"
3894
+ status === "transcribing" || status === "saving" ? "processing" : "capture"
2645
3895
  ) : /* @__PURE__ */ jsx4(Tip, { tip: idleLabel, children: /* @__PURE__ */ jsx4(
2646
3896
  motion3.button,
2647
3897
  {
@@ -2655,14 +3905,14 @@ function ComposerTranscriptionControl({
2655
3905
  "aria-pressed": false,
2656
3906
  "aria-disabled": unavailableMessage !== null,
2657
3907
  className: cn(
2658
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-9",
3908
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
2659
3909
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
2660
3910
  unavailableMessage ? "cursor-not-allowed opacity-45" : "hover:bg-og-surface-2 hover:text-og-fg"
2661
3911
  ),
2662
3912
  children: /* @__PURE__ */ jsx4(MicIcon, { className: "size-4" })
2663
3913
  }
2664
3914
  ) }, "idle") }),
2665
- status === "error" && errorMessage3 ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
3915
+ status === "error" && errorMessage3 && !recoverable ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
2666
3916
  "span",
2667
3917
  {
2668
3918
  "aria-hidden": "true",
@@ -2772,6 +4022,14 @@ function transcriptionErrorMessage(code, messages) {
2772
4022
  return messages.errorTooLarge;
2773
4023
  case "invalid_audio":
2774
4024
  return messages.errorInvalidAudio;
4025
+ case "storage_unavailable":
4026
+ return messages.errorStorageUnavailable;
4027
+ case "network":
4028
+ case "provider":
4029
+ case "timeout":
4030
+ return messages.errorRetryable;
4031
+ case "handoff_uncertain":
4032
+ return messages.errorHandoffUncertain;
2775
4033
  case "unknown":
2776
4034
  return messages.errorUnknown;
2777
4035
  default:
@@ -2780,10 +4038,23 @@ function transcriptionErrorMessage(code, messages) {
2780
4038
  }
2781
4039
 
2782
4040
  export {
4041
+ VoiceRecordingStorageUnavailableError,
4042
+ VoiceRecordingNotFoundError,
4043
+ VoiceRecordingChunkConflictError,
4044
+ VoiceRecordingChunkSequenceError,
4045
+ VoiceRecordingOwnedError,
4046
+ createVoiceRecordingManifest,
4047
+ prepareVoiceRecordingChunk,
4048
+ planVoiceRecordingChunkCommit,
4049
+ IndexedDbVoiceRecordingStore,
2783
4050
  INITIAL_TRANSCRIPTION_CONTROL_STATE,
2784
4051
  transitionTranscriptionControl,
2785
4052
  appendFinalTranscript,
2786
4053
  useTranscription,
4054
+ VOICE_RECORDING_TIMESLICE_MILLISECONDS,
4055
+ VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
4056
+ VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
4057
+ VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
2787
4058
  useVoiceInput,
2788
4059
  OPEN_WORKSTREAM_CONTROL_EVENT,
2789
4060
  parseCommandLine,
@@ -2820,4 +4091,4 @@ export {
2820
4091
  Status,
2821
4092
  ComposerTranscriptionControl
2822
4093
  };
2823
- //# sourceMappingURL=chunk-OMCFRWHL.js.map
4094
+ //# sourceMappingURL=chunk-UWYTCQWW.js.map