@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
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-Q2NCKWTK.js";
5
5
  import {
6
6
  groupPickerRowsByBillingClass
7
- } from "./chunk-SJKT4TKW.js";
7
+ } from "./chunk-23EJ676W.js";
8
8
  import {
9
9
  Tooltip,
10
10
  TooltipContent,
@@ -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) {
@@ -1429,16 +2613,60 @@ var defaultChatComposerMessages = {
1429
2613
  formatBytes,
1430
2614
  formatRelativeTime
1431
2615
  };
1432
- function applyComposerTextareaHeight(textarea, maxPx = 220) {
1433
- if (textarea.scrollHeight > textarea.clientHeight + 1) {
1434
- textarea.style.height = `${Math.min(textarea.scrollHeight, maxPx)}px`;
1435
- return;
2616
+ var composerHeightMirror = null;
2617
+ function measureComposerContentHeight(textarea) {
2618
+ if (typeof document === "undefined") {
2619
+ return textarea.scrollHeight;
2620
+ }
2621
+ const width = textarea.clientWidth;
2622
+ if (width <= 0) {
2623
+ return textarea.offsetHeight;
1436
2624
  }
2625
+ let mirror = composerHeightMirror;
2626
+ if (!mirror) {
2627
+ mirror = document.createElement("textarea");
2628
+ mirror.setAttribute("aria-hidden", "true");
2629
+ mirror.tabIndex = -1;
2630
+ mirror.rows = 1;
2631
+ mirror.style.cssText = "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;height:auto;min-height:0;max-height:none;overflow:hidden;z-index:-1;";
2632
+ composerHeightMirror = mirror;
2633
+ }
2634
+ const style = getComputedStyle(textarea);
2635
+ mirror.style.width = `${width}px`;
2636
+ mirror.style.boxSizing = style.boxSizing;
2637
+ mirror.style.font = style.font;
2638
+ mirror.style.fontSize = style.fontSize;
2639
+ mirror.style.fontFamily = style.fontFamily;
2640
+ mirror.style.fontWeight = style.fontWeight;
2641
+ mirror.style.fontStyle = style.fontStyle;
2642
+ mirror.style.letterSpacing = style.letterSpacing;
2643
+ mirror.style.lineHeight = style.lineHeight;
2644
+ mirror.style.textTransform = style.textTransform;
2645
+ mirror.style.paddingTop = style.paddingTop;
2646
+ mirror.style.paddingRight = style.paddingRight;
2647
+ mirror.style.paddingBottom = style.paddingBottom;
2648
+ mirror.style.paddingLeft = style.paddingLeft;
2649
+ mirror.style.borderTopWidth = style.borderTopWidth;
2650
+ mirror.style.borderRightWidth = style.borderRightWidth;
2651
+ mirror.style.borderBottomWidth = style.borderBottomWidth;
2652
+ mirror.style.borderLeftWidth = style.borderLeftWidth;
2653
+ mirror.style.borderStyle = style.borderStyle;
2654
+ mirror.style.whiteSpace = style.whiteSpace;
2655
+ mirror.style.wordBreak = style.wordBreak;
2656
+ mirror.style.overflowWrap = style.overflowWrap;
2657
+ mirror.value = textarea.value;
2658
+ if (!mirror.isConnected) {
2659
+ document.documentElement.appendChild(mirror);
2660
+ }
2661
+ return mirror.scrollHeight;
2662
+ }
2663
+ function applyComposerTextareaHeight(textarea, maxPx = 220, measure = measureComposerContentHeight) {
1437
2664
  const before = textarea.offsetHeight;
1438
- textarea.style.height = "auto";
1439
- const nextPx = Math.min(textarea.scrollHeight, maxPx);
2665
+ const nextPx = Math.min(
2666
+ textarea.scrollHeight > textarea.clientHeight + 1 ? textarea.scrollHeight : measure(textarea),
2667
+ maxPx
2668
+ );
1440
2669
  if (Math.abs(nextPx - before) < 1) {
1441
- textarea.style.height = `${before}px`;
1442
2670
  return;
1443
2671
  }
1444
2672
  textarea.style.height = `${nextPx}px`;
@@ -1966,7 +3194,8 @@ var Footer = forwardRef(function ComposerFooter({ className, ...props }, ref) {
1966
3194
  ref,
1967
3195
  className: cn(
1968
3196
  "flex items-end gap-1.5 px-2 pb-2 pt-0.5 sm:px-2.5 sm:pb-2.5",
1969
- "max-sm:items-center max-sm:gap-1",
3197
+ // Mobile: one control row — never wrap into a second toolbar line.
3198
+ "max-sm:flex-nowrap max-sm:items-center max-sm:gap-1",
1970
3199
  className
1971
3200
  )
1972
3201
  }
@@ -2006,7 +3235,11 @@ var Actions = forwardRef(function ComposerActions({ className, ...props }, ref)
2006
3235
  {
2007
3236
  ...props,
2008
3237
  ref,
2009
- className: cn("ml-auto flex shrink-0 items-center gap-1.5", className)
3238
+ className: cn(
3239
+ "ml-auto flex shrink-0 items-center gap-1.5",
3240
+ "max-sm:flex-nowrap max-sm:gap-1",
3241
+ className
3242
+ )
2010
3243
  }
2011
3244
  );
2012
3245
  });
@@ -2503,7 +3736,15 @@ function HelpPanel({
2503
3736
  }
2504
3737
 
2505
3738
  // src/components/composer-transcription-control.tsx
2506
- import { LoaderCircleIcon as LoaderCircleIcon2, MicIcon, SquareIcon, XIcon as XIcon2 } from "lucide-react";
3739
+ import {
3740
+ ClipboardPasteIcon,
3741
+ LoaderCircleIcon as LoaderCircleIcon2,
3742
+ MicIcon,
3743
+ RefreshCwIcon,
3744
+ SquareIcon,
3745
+ Trash2Icon,
3746
+ XIcon as XIcon2
3747
+ } from "lucide-react";
2507
3748
  import { AnimatePresence as AnimatePresence3, motion as motion3 } from "motion/react";
2508
3749
  import { useEffect as useEffect4, useState as useState5 } from "react";
2509
3750
  import { Fragment as Fragment2, jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
@@ -2520,7 +3761,12 @@ var defaultMessages = {
2520
3761
  retry: "Retry voice input",
2521
3762
  requestingPermission: "Requesting microphone\u2026",
2522
3763
  recording: "Recording. Press Escape to cancel.",
3764
+ saving: "Saving audio locally\u2026",
2523
3765
  transcribing: "Transcribing\u2026",
3766
+ recovered: "Recording recovered and saved locally.",
3767
+ recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
3768
+ insertRecoveredTranscript: "Insert saved transcript",
3769
+ discardRecovered: "Discard saved recording",
2524
3770
  unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
2525
3771
  unavailable: "Voice input is unavailable for this workspace.",
2526
3772
  errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
@@ -2528,6 +3774,9 @@ var defaultMessages = {
2528
3774
  errorUnavailable: "Voice input is not configured.",
2529
3775
  errorTooLarge: "Recording is too large. Try a shorter message.",
2530
3776
  errorInvalidAudio: "The recording could not be read. Try again.",
3777
+ errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
3778
+ errorRetryable: "Recording is saved locally. Retry transcription when ready.",
3779
+ errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
2531
3780
  errorUnknown: "Voice input could not start. Try again."
2532
3781
  };
2533
3782
  var WAVEFORM_BARS = 18;
@@ -2541,7 +3790,10 @@ function ComposerTranscriptionControl({
2541
3790
  capability = null,
2542
3791
  workspaceEnabled = false,
2543
3792
  messages: overrides,
2544
- className
3793
+ className,
3794
+ createRecordingStore,
3795
+ createOwnerId,
3796
+ suppressed = false
2545
3797
  }) {
2546
3798
  const composer = useChatComposer();
2547
3799
  const messages = { ...defaultMessages, ...overrides };
@@ -2549,18 +3801,29 @@ function ComposerTranscriptionControl({
2549
3801
  client,
2550
3802
  workspaceId,
2551
3803
  capability,
2552
- enabled: workspaceEnabled,
3804
+ enabled: workspaceEnabled && !suppressed,
2553
3805
  value: composer.value,
2554
3806
  setValue: composer.setValue,
2555
3807
  focusInput: composer.focusInput,
2556
- disabled: composer.disabled
3808
+ disabled: composer.disabled,
3809
+ createRecordingStore,
3810
+ createOwnerId
2557
3811
  });
2558
3812
  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;
3813
+ const cancelTranscription = transcription.cancel;
3814
+ useEffect4(() => {
3815
+ if (!suppressed) return;
3816
+ if (status === "recording" || status === "requesting-permission") {
3817
+ cancelTranscription();
3818
+ }
3819
+ }, [cancelTranscription, status, suppressed]);
3820
+ const active = status === "requesting-permission" || status === "recording" || status === "saving" || status === "transcribing";
3821
+ const recoverable = transcription.hasRecoverableRecording && (status === "recovered" || status === "transcript-ready" || status === "error");
3822
+ const savedTranscript = status === "transcript-ready" && transcription.savedTranscript !== null;
3823
+ const unavailableMessage = composer.disabled ? messages.unavailableDisabled : !capability?.available || !workspaceEnabled ? messages.unavailable : !transcription.available ? messages.errorStorageUnavailable : null;
2561
3824
  const idleLabel = unavailableMessage ?? (status === "error" ? messages.retry : messages.start);
2562
3825
  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;
3826
+ 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
3827
  function start(event) {
2565
3828
  if (unavailableMessage) {
2566
3829
  event.preventDefault();
@@ -2568,13 +3831,61 @@ function ComposerTranscriptionControl({
2568
3831
  }
2569
3832
  void transcription.start();
2570
3833
  }
2571
- return /* @__PURE__ */ jsxs4(
2572
- "span",
3834
+ return /* @__PURE__ */ jsx4(AnimatePresence3, { initial: false, children: suppressed ? null : /* @__PURE__ */ jsx4(
3835
+ motion3.span,
2573
3836
  {
2574
- className: cn("inline-flex min-w-0 items-center gap-1.5", className),
3837
+ initial: { opacity: 0, width: 0 },
3838
+ animate: { opacity: 1, width: "auto" },
3839
+ exit: { opacity: 0, width: 0 },
3840
+ transition: { duration: 0.36, ease: [0.22, 1, 0.36, 1] },
3841
+ className: cn("inline-flex min-w-0 overflow-hidden", className),
2575
3842
  "data-transcription-status": status,
2576
- children: [
2577
- /* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: active ? /* @__PURE__ */ jsxs4(
3843
+ children: /* @__PURE__ */ jsxs4("span", { className: "inline-flex min-w-0 items-center gap-1.5", children: [
3844
+ /* @__PURE__ */ jsx4(AnimatePresence3, { mode: "popLayout", initial: false, children: recoverable ? /* @__PURE__ */ jsxs4(
3845
+ motion3.span,
3846
+ {
3847
+ initial: { opacity: 0, scale: 0.96 },
3848
+ animate: { opacity: 1, scale: 1 },
3849
+ exit: { opacity: 0, scale: 0.96 },
3850
+ transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
3851
+ className: cn(
3852
+ "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
3853
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
3854
+ ),
3855
+ children: [
3856
+ /* @__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 }),
3857
+ /* @__PURE__ */ jsx4(Tip, { tip: savedTranscript ? messages.insertRecoveredTranscript : messages.retry, children: /* @__PURE__ */ jsx4(
3858
+ "button",
3859
+ {
3860
+ type: "button",
3861
+ onClick: () => savedTranscript ? void transcription.insertSavedTranscript() : transcription.retry(),
3862
+ "aria-label": savedTranscript ? messages.insertRecoveredTranscript : messages.retry,
3863
+ className: cn(
3864
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
3865
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
3866
+ "hover:bg-og-fg-muted pointer-coarse:size-11"
3867
+ ),
3868
+ children: savedTranscript ? /* @__PURE__ */ jsx4(ClipboardPasteIcon, { className: "size-3.5" }) : /* @__PURE__ */ jsx4(RefreshCwIcon, { className: "size-3.5" })
3869
+ }
3870
+ ) }),
3871
+ /* @__PURE__ */ jsx4(Tip, { tip: messages.discardRecovered, children: /* @__PURE__ */ jsx4(
3872
+ "button",
3873
+ {
3874
+ type: "button",
3875
+ onClick: () => void transcription.discard(),
3876
+ "aria-label": messages.discardRecovered,
3877
+ className: cn(
3878
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
3879
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
3880
+ "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11"
3881
+ ),
3882
+ children: /* @__PURE__ */ jsx4(Trash2Icon, { className: "size-3.5" })
3883
+ }
3884
+ ) })
3885
+ ]
3886
+ },
3887
+ "recovered"
3888
+ ) : active ? /* @__PURE__ */ jsxs4(
2578
3889
  motion3.span,
2579
3890
  {
2580
3891
  initial: { opacity: 0, scale: 0.96 },
@@ -2583,7 +3894,7 @@ function ComposerTranscriptionControl({
2583
3894
  transition: { duration: 0.16, ease: [0.22, 1, 0.36, 1] },
2584
3895
  className: cn(
2585
3896
  "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"
3897
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11"
2587
3898
  ),
2588
3899
  children: [
2589
3900
  status === "requesting-permission" ? /* @__PURE__ */ jsx4(
@@ -2604,7 +3915,7 @@ function ComposerTranscriptionControl({
2604
3915
  VoiceWaveform,
2605
3916
  {
2606
3917
  stream: status === "recording" ? transcription.stream : null,
2607
- mode: status === "transcribing" ? "transcribing" : "recording"
3918
+ mode: status === "recording" ? "recording" : "transcribing"
2608
3919
  }
2609
3920
  )
2610
3921
  ] }),
@@ -2619,7 +3930,7 @@ function ComposerTranscriptionControl({
2619
3930
  className: cn(
2620
3931
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
2621
3932
  "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"
3933
+ "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11"
2623
3934
  ),
2624
3935
  children: /* @__PURE__ */ jsx4(XIcon2, { className: "size-3.5" })
2625
3936
  }
@@ -2633,19 +3944,20 @@ function ComposerTranscriptionControl({
2633
3944
  className: cn(
2634
3945
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
2635
3946
  "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
2636
- "hover:bg-og-fg-muted pointer-coarse:size-9"
3947
+ "hover:bg-og-fg-muted pointer-coarse:size-11"
2637
3948
  ),
2638
3949
  children: /* @__PURE__ */ jsx4(SquareIcon, { className: "size-2.5 fill-current" })
2639
3950
  }
2640
3951
  ) })
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 })
3952
+ ] }) : 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
3953
  ]
2643
3954
  },
2644
- status === "transcribing" ? "transcribing" : "capture"
3955
+ status === "transcribing" || status === "saving" ? "processing" : "capture"
2645
3956
  ) : /* @__PURE__ */ jsx4(Tip, { tip: idleLabel, children: /* @__PURE__ */ jsx4(
2646
3957
  motion3.button,
2647
3958
  {
2648
3959
  type: "button",
3960
+ "data-og-composer-dictate": true,
2649
3961
  initial: { opacity: 0, scale: 0.96 },
2650
3962
  animate: { opacity: 1, scale: 1 },
2651
3963
  exit: { opacity: 0, scale: 0.96 },
@@ -2655,14 +3967,14 @@ function ComposerTranscriptionControl({
2655
3967
  "aria-pressed": false,
2656
3968
  "aria-disabled": unavailableMessage !== null,
2657
3969
  className: cn(
2658
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-9",
3970
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
2659
3971
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
2660
3972
  unavailableMessage ? "cursor-not-allowed opacity-45" : "hover:bg-og-surface-2 hover:text-og-fg"
2661
3973
  ),
2662
3974
  children: /* @__PURE__ */ jsx4(MicIcon, { className: "size-4" })
2663
3975
  }
2664
3976
  ) }, "idle") }),
2665
- status === "error" && errorMessage3 ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
3977
+ status === "error" && errorMessage3 && !recoverable ? /* @__PURE__ */ jsx4(Tip, { tip: errorMessage3, children: /* @__PURE__ */ jsx4(
2666
3978
  "span",
2667
3979
  {
2668
3980
  "aria-hidden": "true",
@@ -2670,10 +3982,19 @@ function ComposerTranscriptionControl({
2670
3982
  children: errorMessage3
2671
3983
  }
2672
3984
  ) }) : null,
2673
- /* @__PURE__ */ jsx4("span", { className: "sr-only", role: status === "error" ? "alert" : "status", "aria-live": "polite", children: announcement })
2674
- ]
2675
- }
2676
- );
3985
+ /* @__PURE__ */ jsx4(
3986
+ "span",
3987
+ {
3988
+ className: "sr-only",
3989
+ role: status === "error" ? "alert" : "status",
3990
+ "aria-live": "polite",
3991
+ children: announcement
3992
+ }
3993
+ )
3994
+ ] })
3995
+ },
3996
+ "composer-dictate"
3997
+ ) });
2677
3998
  }
2678
3999
  function VoiceWaveform({
2679
4000
  stream,
@@ -2772,6 +4093,14 @@ function transcriptionErrorMessage(code, messages) {
2772
4093
  return messages.errorTooLarge;
2773
4094
  case "invalid_audio":
2774
4095
  return messages.errorInvalidAudio;
4096
+ case "storage_unavailable":
4097
+ return messages.errorStorageUnavailable;
4098
+ case "network":
4099
+ case "provider":
4100
+ case "timeout":
4101
+ return messages.errorRetryable;
4102
+ case "handoff_uncertain":
4103
+ return messages.errorHandoffUncertain;
2775
4104
  case "unknown":
2776
4105
  return messages.errorUnknown;
2777
4106
  default:
@@ -2780,10 +4109,23 @@ function transcriptionErrorMessage(code, messages) {
2780
4109
  }
2781
4110
 
2782
4111
  export {
4112
+ VoiceRecordingStorageUnavailableError,
4113
+ VoiceRecordingNotFoundError,
4114
+ VoiceRecordingChunkConflictError,
4115
+ VoiceRecordingChunkSequenceError,
4116
+ VoiceRecordingOwnedError,
4117
+ createVoiceRecordingManifest,
4118
+ prepareVoiceRecordingChunk,
4119
+ planVoiceRecordingChunkCommit,
4120
+ IndexedDbVoiceRecordingStore,
2783
4121
  INITIAL_TRANSCRIPTION_CONTROL_STATE,
2784
4122
  transitionTranscriptionControl,
2785
4123
  appendFinalTranscript,
2786
4124
  useTranscription,
4125
+ VOICE_RECORDING_TIMESLICE_MILLISECONDS,
4126
+ VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS,
4127
+ VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
4128
+ VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
2787
4129
  useVoiceInput,
2788
4130
  OPEN_WORKSTREAM_CONTROL_EVENT,
2789
4131
  parseCommandLine,
@@ -2820,4 +4162,4 @@ export {
2820
4162
  Status,
2821
4163
  ComposerTranscriptionControl
2822
4164
  };
2823
- //# sourceMappingURL=chunk-OMCFRWHL.js.map
4165
+ //# sourceMappingURL=chunk-KC27K42G.js.map