@opengeni/react 0.45.0 → 0.48.0

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 (141) hide show
  1. package/README.md +68 -12
  2. package/dist/{chunk-U3CSCMWA.js → chunk-FK6VG4LL.js} +9 -13
  3. package/dist/chunk-FK6VG4LL.js.map +1 -0
  4. package/dist/{chunk-HFO4ERGQ.js → chunk-FWXGSNXT.js} +2 -2
  5. package/dist/{chunk-SRFUT2ZU.js → chunk-I4MPFDRP.js} +119 -63
  6. package/dist/chunk-I4MPFDRP.js.map +1 -0
  7. package/dist/{chunk-EB67J347.js → chunk-KZ73RL76.js} +111 -31
  8. package/dist/chunk-KZ73RL76.js.map +1 -0
  9. package/dist/{chunk-LWR4MXSS.js → chunk-LHLYWG5R.js} +10 -1
  10. package/dist/chunk-LHLYWG5R.js.map +1 -0
  11. package/dist/{chunk-L3EWO3UK.js → chunk-NJ7M2B6Z.js} +445 -84
  12. package/dist/chunk-NJ7M2B6Z.js.map +1 -0
  13. package/dist/{chunk-VCBFUSUZ.js → chunk-O4VXOBGF.js} +3 -3
  14. package/dist/{chunk-VCBFUSUZ.js.map → chunk-O4VXOBGF.js.map} +1 -1
  15. package/dist/{chunk-6EDV6YFE.js → chunk-OL6QVXGF.js} +121 -29
  16. package/dist/chunk-OL6QVXGF.js.map +1 -0
  17. package/dist/chunk-VZTQ73XT.js +86 -0
  18. package/dist/chunk-VZTQ73XT.js.map +1 -0
  19. package/dist/{chunk-EHSYZ4KP.js → chunk-WYA65Y3F.js} +16 -16
  20. package/dist/chunk-WYA65Y3F.js.map +1 -0
  21. package/dist/{chunk-FD72GJMK.js → chunk-XCNTXWBB.js} +274 -125
  22. package/dist/chunk-XCNTXWBB.js.map +1 -0
  23. package/dist/client.d.ts +1 -1
  24. package/dist/components/chat-composer.d.ts +7 -2
  25. package/dist/components/composer-transcription-control.d.ts +2 -0
  26. package/dist/components/composer.d.ts +14 -0
  27. package/dist/components/machines/machine-detail.d.ts +3 -1
  28. package/dist/components/machines-dashboard.d.ts +3 -3
  29. package/dist/components/message-timeline.d.ts +12 -2
  30. package/dist/components/sandbox-files.d.ts +2 -2
  31. package/dist/components/sandbox-terminal.d.ts +29 -0
  32. package/dist/components/sandbox-workspace.d.ts +5 -1
  33. package/dist/components/workspace-dock.d.ts +3 -1
  34. package/dist/composer.d.ts +2 -2
  35. package/dist/composer.js +6 -6
  36. package/dist/hooks/internal.d.ts +6 -0
  37. package/dist/hooks/use-codex-accounts.d.ts +1 -1
  38. package/dist/hooks/use-environments.d.ts +10 -4
  39. package/dist/hooks/use-machines.d.ts +7 -0
  40. package/dist/hooks/use-sandbox-files.d.ts +19 -0
  41. package/dist/hooks/use-sandbox-git.d.ts +7 -0
  42. package/dist/hooks/use-transcription.d.ts +2 -2
  43. package/dist/hooks/use-voice-input.d.ts +13 -4
  44. package/dist/index.d.ts +6 -5
  45. package/dist/index.js +904 -538
  46. package/dist/index.js.map +1 -1
  47. package/dist/lib/composer-responsive-context.d.ts +9 -0
  48. package/dist/lib/use-portal-token-style.d.ts +14 -3
  49. package/dist/machines.js +2 -2
  50. package/dist/{queue-surface-implementation-J4XJFKRW.js → queue-surface-implementation-U5JFG7HT.js} +19 -18
  51. package/dist/queue-surface-implementation-U5JFG7HT.js.map +1 -0
  52. package/dist/realtime/realtime-control.d.ts +9 -0
  53. package/dist/realtime.js +29 -32
  54. package/dist/realtime.js.map +1 -1
  55. package/dist/session-ui.js +5 -5
  56. package/dist/session.js +4 -4
  57. package/dist/timeline/activity-rail.d.ts +3 -2
  58. package/dist/timeline/index.d.ts +2 -2
  59. package/dist/timeline/parsers.d.ts +3 -3
  60. package/dist/timeline/registry.d.ts +3 -0
  61. package/dist/timeline/screenshot-lightbox.d.ts +1 -1
  62. package/dist/timeline/types.d.ts +4 -0
  63. package/dist/voice-recording-store.d.ts +8 -2
  64. package/package.json +20 -4
  65. package/src/client.ts +3 -0
  66. package/src/components/chat-composer.tsx +12 -2
  67. package/src/components/code-editor.tsx +1 -1
  68. package/src/components/command-palette.tsx +1 -1
  69. package/src/components/composer-transcription-control.tsx +41 -16
  70. package/src/components/composer.tsx +52 -26
  71. package/src/components/copy-button.tsx +2 -2
  72. package/src/components/desktop-viewer.tsx +2 -2
  73. package/src/components/file-browser.tsx +39 -17
  74. package/src/components/human-input-form.tsx +2 -2
  75. package/src/components/machine-card.tsx +1 -1
  76. package/src/components/machines/machine-detail.tsx +39 -15
  77. package/src/components/machines-dashboard.tsx +7 -6
  78. package/src/components/message-timeline.tsx +44 -11
  79. package/src/components/model-picker.tsx +2 -2
  80. package/src/components/model-policy-picker.tsx +16 -15
  81. package/src/components/queue-surface-implementation.tsx +20 -16
  82. package/src/components/queue-surface-state.tsx +2 -2
  83. package/src/components/sandbox-files.tsx +140 -60
  84. package/src/components/sandbox-terminal.tsx +24 -5
  85. package/src/components/sandbox-workspace.tsx +180 -142
  86. package/src/components/session-chrome.tsx +14 -14
  87. package/src/components/tooltip.tsx +13 -16
  88. package/src/components/user-message-body.tsx +1 -1
  89. package/src/components/workbench-changes.tsx +4 -4
  90. package/src/components/workspace-dock.tsx +13 -5
  91. package/src/composer.ts +3 -0
  92. package/src/hooks/internal.ts +132 -38
  93. package/src/hooks/use-codex-accounts.ts +4 -4
  94. package/src/hooks/use-environments.ts +24 -5
  95. package/src/hooks/use-file-attachments.ts +70 -32
  96. package/src/hooks/use-goal.ts +17 -5
  97. package/src/hooks/use-machines.ts +47 -1
  98. package/src/hooks/use-sandbox-files.ts +310 -59
  99. package/src/hooks/use-sandbox-git.ts +148 -41
  100. package/src/hooks/use-session-capabilities.ts +35 -24
  101. package/src/hooks/use-session-events.ts +18 -4
  102. package/src/hooks/use-transcription.ts +4 -14
  103. package/src/hooks/use-turn-queue.ts +17 -5
  104. package/src/hooks/use-voice-input.ts +481 -36
  105. package/src/hooks/use-windowed-sections.ts +9 -7
  106. package/src/index.ts +11 -6
  107. package/src/lib/composer-responsive-context.ts +15 -0
  108. package/src/lib/use-portal-token-style.ts +60 -15
  109. package/src/provider.tsx +52 -7
  110. package/src/realtime/dropdown-menu.tsx +15 -19
  111. package/src/realtime/realtime-control.tsx +18 -7
  112. package/src/timeline/activity-rail.tsx +15 -6
  113. package/src/timeline/index.ts +1 -1
  114. package/src/timeline/parsers.ts +33 -20
  115. package/src/timeline/projection.ts +23 -0
  116. package/src/timeline/registry.ts +7 -0
  117. package/src/timeline/screenshot-lightbox.tsx +15 -4
  118. package/src/timeline/shared.tsx +8 -8
  119. package/src/timeline/tool-renderers.tsx +166 -22
  120. package/src/timeline/turn-summary.tsx +6 -6
  121. package/src/timeline/types.ts +4 -0
  122. package/src/voice-recording-store.ts +15 -0
  123. package/styles/compiled.css +5065 -0
  124. package/styles/compiled.d.ts +2 -0
  125. package/styles/effective-tokens.css +27 -0
  126. package/styles/index.css +3 -0
  127. package/styles/responsive.css +196 -0
  128. package/styles/responsive.d.ts +2 -0
  129. package/styles/tokens.css +50 -17
  130. package/dist/chunk-6EDV6YFE.js.map +0 -1
  131. package/dist/chunk-EB67J347.js.map +0 -1
  132. package/dist/chunk-EHSYZ4KP.js.map +0 -1
  133. package/dist/chunk-FD72GJMK.js.map +0 -1
  134. package/dist/chunk-JB5725BI.js +0 -48
  135. package/dist/chunk-JB5725BI.js.map +0 -1
  136. package/dist/chunk-L3EWO3UK.js.map +0 -1
  137. package/dist/chunk-LWR4MXSS.js.map +0 -1
  138. package/dist/chunk-SRFUT2ZU.js.map +0 -1
  139. package/dist/chunk-U3CSCMWA.js.map +0 -1
  140. package/dist/queue-surface-implementation-J4XJFKRW.js.map +0 -1
  141. /package/dist/{chunk-HFO4ERGQ.js.map → chunk-FWXGSNXT.js.map} +0 -0
@@ -1,10 +1,11 @@
1
- import type { ClientVoiceInputConfig, OpenGeniClient } from "@opengeni/sdk";
1
+ import { OpenGeniApiError, type ClientVoiceInputConfig, type OpenGeniClient } from "@opengeni/sdk";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
  import {
4
4
  IndexedDbVoiceRecordingStore,
5
5
  VoiceRecordingOwnedError,
6
6
  VoiceRecordingStorageUnavailableError,
7
7
  createVoiceRecordingManifest,
8
+ type VoiceRecordingChunk,
8
9
  type VoiceRecordingManifest,
9
10
  type VoiceRecordingStore,
10
11
  } from "../voice-recording-store";
@@ -20,12 +21,13 @@ export type VoiceInputStatus =
20
21
  | "recording"
21
22
  | "saving"
22
23
  | "transcribing"
24
+ | "retrying"
23
25
  | "recovered"
24
26
  | "transcript-ready"
25
27
  | "error";
26
28
 
27
29
  export type UseVoiceInputOptions = {
28
- client: Pick<OpenGeniClient, "transcribeAudio"> | null;
30
+ client: VoiceInputClient | null;
29
31
  workspaceId: string;
30
32
  capability: ClientVoiceInputConfig | null;
31
33
  enabled: boolean;
@@ -38,8 +40,23 @@ export type UseVoiceInputOptions = {
38
40
  createRecordingId?: (() => string) | undefined;
39
41
  createOwnerId?: (() => string) | undefined;
40
42
  now?: (() => Date) | undefined;
43
+ /** Test/embed seam. Production uses bounded exponential recovery backoff. */
44
+ automaticRetryDelayMilliseconds?: number | undefined;
41
45
  };
42
46
 
47
+ type ResumableVoiceInputClient = Pick<
48
+ OpenGeniClient,
49
+ | "createTranscriptionRecording"
50
+ | "getTranscriptionRecording"
51
+ | "uploadTranscriptionRecordingChunk"
52
+ | "finalizeTranscriptionRecording"
53
+ | "processNextTranscriptionRecordingSegment"
54
+ | "discardTranscriptionRecording"
55
+ >;
56
+
57
+ type VoiceInputClient = Pick<OpenGeniClient, "transcribeAudio"> &
58
+ Partial<ResumableVoiceInputClient>;
59
+
43
60
  export type UseVoiceInputResult = {
44
61
  status: VoiceInputStatus;
45
62
  error: string | null;
@@ -68,11 +85,42 @@ export const VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5_000;
68
85
  export const VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5_000;
69
86
  export const VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 30_000;
70
87
  export const VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
71
-
88
+ export const VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS = 8 * 60 * 60;
89
+ export const VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS = 2_000;
90
+ export const VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS = 30_000;
91
+ const TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS = 5_000;
72
92
  const MIME_PREFERENCES = ["audio/webm;codecs=opus", "audio/mp4", "audio/ogg;codecs=opus"];
73
93
  const createDefaultVoiceRecordingId = () => crypto.randomUUID();
74
94
  const currentDate = () => new Date();
75
95
 
96
+ export function transcriptionRecoveryMutationDelayMilliseconds(
97
+ retryAfterMilliseconds: number | undefined,
98
+ attempt: number,
99
+ random: () => number = Math.random,
100
+ ): number {
101
+ const hint =
102
+ typeof retryAfterMilliseconds === "number" &&
103
+ Number.isInteger(retryAfterMilliseconds) &&
104
+ retryAfterMilliseconds > 0
105
+ ? retryAfterMilliseconds
106
+ : TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS;
107
+ const boundedHint = Math.max(
108
+ 500,
109
+ Math.min(hint, VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS),
110
+ );
111
+ const exponent = Math.max(0, Math.min(Math.floor(attempt), 6));
112
+ const exponential = Math.min(
113
+ VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS,
114
+ boundedHint * 2 ** exponent,
115
+ );
116
+ const sampledJitter = random();
117
+ const jitterRatio = Number.isFinite(sampledJitter) ? Math.max(0, Math.min(1, sampledJitter)) : 0;
118
+ return Math.min(
119
+ VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS,
120
+ Math.ceil(exponential * (1 + jitterRatio * 0.2)),
121
+ );
122
+ }
123
+
76
124
  export function useVoiceInput({
77
125
  client,
78
126
  workspaceId,
@@ -86,6 +134,7 @@ export function useVoiceInput({
86
134
  createRecordingId = createDefaultVoiceRecordingId,
87
135
  createOwnerId,
88
136
  now = currentDate,
137
+ automaticRetryDelayMilliseconds,
89
138
  }: UseVoiceInputOptions): UseVoiceInputResult {
90
139
  const [status, setStatus] = useState<VoiceInputStatus>("idle");
91
140
  const [error, setError] = useState<string | null>(null);
@@ -110,6 +159,8 @@ export function useVoiceInput({
110
159
  const recorderRef = useRef<MediaRecorder | null>(null);
111
160
  const streamRef = useRef<MediaStream | null>(null);
112
161
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
162
+ const automaticRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
163
+ const automaticRetryAttemptRef = useRef(0);
113
164
  const ownerHeartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
114
165
  const controllerRef = useRef<AbortController | null>(null);
115
166
  const valueRef = useRef(value);
@@ -165,6 +216,7 @@ export function useVoiceInput({
165
216
  setRecordingId(null);
166
217
  setDurationSeconds(0);
167
218
  setLocallySaved(false);
219
+ automaticRetryAttemptRef.current = 0;
168
220
  }, [stopOwnerHeartbeat]);
169
221
 
170
222
  const ensureStore = useCallback(async (): Promise<VoiceRecordingStore> => {
@@ -302,7 +354,9 @@ export function useVoiceInput({
302
354
  setStatus(
303
355
  claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null
304
356
  ? "transcript-ready"
305
- : "recovered",
357
+ : claimed.recoveryMode === "automatic"
358
+ ? "retrying"
359
+ : "recovered",
306
360
  );
307
361
  setError(null);
308
362
  return;
@@ -322,8 +376,17 @@ export function useVoiceInput({
322
376
  const finalizePersistedRecording = useCallback(
323
377
  async (generation: number): Promise<void> => {
324
378
  const manifest = manifestRef.current;
325
- const maxSizeBytes = capability?.maxSizeBytes;
326
- if (!manifest || !client || !maxSizeBytes || manifest.workspaceId !== workspaceId) return;
379
+ const resumable =
380
+ capability?.resumable && isResumableVoiceInputClient(client) ? capability.resumable : null;
381
+ const maxSizeBytes = resumable?.maxSizeBytes ?? capability?.maxSizeBytes;
382
+ if (
383
+ !manifest ||
384
+ !client ||
385
+ !capability ||
386
+ !maxSizeBytes ||
387
+ manifest.workspaceId !== workspaceId
388
+ )
389
+ return;
327
390
  const controller = new AbortController();
328
391
  controllerRef.current?.abort();
329
392
  controllerRef.current = controller;
@@ -360,7 +423,12 @@ export function useVoiceInput({
360
423
  if (chunks.length === 0) {
361
424
  const retained = await store.updateManifest(
362
425
  transcribing.recordingId,
363
- { uploadState: "retrying", transcriptionState: "retrying" },
426
+ {
427
+ uploadState: "retrying",
428
+ transcriptionState: "retrying",
429
+ recoveryMode: "manual",
430
+ handoffMode: "explicit",
431
+ },
364
432
  readNow().toISOString(),
365
433
  ownerIdRef.current ?? undefined,
366
434
  );
@@ -368,18 +436,12 @@ export function useVoiceInput({
368
436
  await preserveForRetry(retained, "invalid_audio", generation);
369
437
  return;
370
438
  }
371
- const audio = new Blob(
372
- chunks
373
- .sort((left, right) => left.chunkNumber - right.chunkNumber)
374
- .map((chunk) => chunk.audio),
375
- { type: transcribing.mimeType },
376
- );
377
- if (!active()) return;
378
- if (audio.size > maxSizeBytes) throw { code: "too_large" };
379
- const response = await client.transcribeAudio(workspaceId, {
380
- audio,
381
- mimeType: audio.type,
382
- durationSeconds: transcribing.totalDurationMilliseconds / 1_000,
439
+ const response = await transcribePersistedRecording({
440
+ client,
441
+ workspaceId,
442
+ capability,
443
+ manifest: transcribing,
444
+ chunks,
383
445
  signal: controller.signal,
384
446
  });
385
447
  if (!active()) return;
@@ -398,6 +460,14 @@ export function useVoiceInput({
398
460
  if (!active()) return;
399
461
  rememberManifest(ready);
400
462
 
463
+ automaticRetryAttemptRef.current = 0;
464
+ if (ready.handoffMode === "explicit") {
465
+ setStatus("transcript-ready");
466
+ setError("handoff_uncertain");
467
+ focusInput();
468
+ return;
469
+ }
470
+
401
471
  const next = appendFinalTranscript(valueRef.current, response.text);
402
472
  if (next !== valueRef.current) {
403
473
  valueRef.current = next;
@@ -422,6 +492,14 @@ export function useVoiceInput({
422
492
  }
423
493
  if (!active()) return;
424
494
  rememberManifest(handedOff);
495
+ if (resumable && isResumableVoiceInputClient(client)) {
496
+ await client
497
+ .discardTranscriptionRecording(workspaceId, handedOff.recordingId, {
498
+ signal: controller.signal,
499
+ })
500
+ .catch(() => undefined);
501
+ }
502
+ if (!active()) return;
425
503
  await store
426
504
  .discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
427
505
  .catch(() => undefined);
@@ -433,22 +511,28 @@ export function useVoiceInput({
433
511
  await loadNextRecoverable(generation);
434
512
  } catch (reason) {
435
513
  if (!active()) return;
514
+ const code = controller.signal.aborted ? null : errorCode(reason);
515
+ const retryable = !controller.signal.aborted && isRetryableVoiceInputError(reason);
436
516
  const retained = await updateManifestBestEffort(transcribing, {
437
517
  uploadState: "retrying",
438
518
  transcriptionState: "retrying",
519
+ recoveryMode: retryable ? "automatic" : "manual",
520
+ handoffMode: "explicit",
439
521
  });
440
522
  if (!active()) return;
441
- await preserveForRetry(
442
- retained,
443
- controller.signal.aborted ? null : errorCode(reason),
444
- generation,
445
- );
523
+ if (retryable) {
524
+ rememberManifest(retained);
525
+ setStatus("retrying");
526
+ setError(code);
527
+ } else {
528
+ await preserveForRetry(retained, code, generation);
529
+ }
446
530
  } finally {
447
531
  if (controllerRef.current === controller) controllerRef.current = null;
448
532
  }
449
533
  },
450
534
  [
451
- capability?.maxSizeBytes,
535
+ capability,
452
536
  clearVisibleRecording,
453
537
  client,
454
538
  ensureStore,
@@ -462,6 +546,61 @@ export function useVoiceInput({
462
546
  workspaceId,
463
547
  ],
464
548
  );
549
+ // Live session renders must not postpone a durable retry deadline. Execute the
550
+ // latest finalizer without making its callback identity part of the timer key.
551
+ const finalizePersistedRecordingRef = useRef(finalizePersistedRecording);
552
+ finalizePersistedRecordingRef.current = finalizePersistedRecording;
553
+ const clientAvailable = client !== null && client !== undefined;
554
+
555
+ useEffect(() => {
556
+ if (
557
+ status !== "retrying" ||
558
+ !manifestRef.current ||
559
+ manifestRef.current.recoveryMode !== "automatic" ||
560
+ manifestRef.current.workspaceId !== workspaceId ||
561
+ !clientAvailable ||
562
+ disabled ||
563
+ !enabled
564
+ ) {
565
+ return;
566
+ }
567
+ const generation = generationRef.current;
568
+ let started = false;
569
+ const run = () => {
570
+ if (
571
+ started ||
572
+ generation !== generationRef.current ||
573
+ statusRef.current !== "retrying" ||
574
+ workspaceIdRef.current !== workspaceId
575
+ ) {
576
+ return;
577
+ }
578
+ started = true;
579
+ if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
580
+ automaticRetryTimerRef.current = null;
581
+ automaticRetryAttemptRef.current += 1;
582
+ void finalizePersistedRecordingRef.current(generation);
583
+ };
584
+ const onOnline = () => run();
585
+ globalThis.addEventListener?.("online", onOnline);
586
+ if (typeof navigator !== "undefined" && navigator.onLine === false) {
587
+ return () => globalThis.removeEventListener?.("online", onOnline);
588
+ }
589
+ const delay = Math.max(
590
+ 0,
591
+ automaticRetryDelayMilliseconds ??
592
+ transcriptionRecoveryMutationDelayMilliseconds(
593
+ TRANSCRIPTION_RECORDING_RECOVERY_RETRY_AFTER_MILLISECONDS,
594
+ automaticRetryAttemptRef.current,
595
+ ),
596
+ );
597
+ automaticRetryTimerRef.current = setTimeout(run, delay);
598
+ return () => {
599
+ globalThis.removeEventListener?.("online", onOnline);
600
+ if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
601
+ automaticRetryTimerRef.current = null;
602
+ };
603
+ }, [automaticRetryDelayMilliseconds, clientAvailable, disabled, enabled, status, workspaceId]);
465
604
 
466
605
  const stop = useCallback(() => {
467
606
  const recorder = recorderRef.current;
@@ -483,6 +622,7 @@ export function useVoiceInput({
483
622
  status === "recording" ||
484
623
  status === "saving" ||
485
624
  status === "transcribing" ||
625
+ status === "retrying" ||
486
626
  !navigator.mediaDevices?.getUserMedia ||
487
627
  typeof MediaRecorder === "undefined"
488
628
  ) {
@@ -602,7 +742,11 @@ export function useVoiceInput({
602
742
  });
603
743
  if (!startAttemptIsCurrent()) return;
604
744
  rememberManifest(result.manifest);
605
- if (result.manifest.totalBytes > capability.maxSizeBytes) {
745
+ const maxSizeBytes =
746
+ capability.resumable && isResumableVoiceInputClient(client)
747
+ ? capability.resumable.maxSizeBytes
748
+ : capability.maxSizeBytes;
749
+ if (result.manifest.totalBytes > maxSizeBytes) {
606
750
  attemptCaptureLimitError = "too_large";
607
751
  if (attemptOwnsSharedCapture()) captureLimitErrorRef.current = "too_large";
608
752
  if (recorder.state !== "inactive") recorder.stop();
@@ -655,11 +799,19 @@ export function useVoiceInput({
655
799
  }
656
800
  rememberManifest(stopped);
657
801
  if (attemptPersistenceError) {
658
- await preserveForRetry(stopped, "storage_unavailable", generation);
802
+ const retained = await updateManifestBestEffort(stopped, {
803
+ recoveryMode: "manual",
804
+ handoffMode: "explicit",
805
+ });
806
+ await preserveForRetry(retained, "storage_unavailable", generation);
659
807
  return;
660
808
  }
661
809
  if (attemptCaptureLimitError) {
662
- await preserveForRetry(stopped, attemptCaptureLimitError, generation);
810
+ const retained = await updateManifestBestEffort(stopped, {
811
+ recoveryMode: "manual",
812
+ handoffMode: "explicit",
813
+ });
814
+ await preserveForRetry(retained, attemptCaptureLimitError, generation);
663
815
  return;
664
816
  }
665
817
  await finalizePersistedRecording(generation);
@@ -675,8 +827,14 @@ export function useVoiceInput({
675
827
  setStatus("recording");
676
828
  timerRef.current = setTimeout(
677
829
  stop,
678
- Math.min(capability.maxDurationSeconds, VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS) *
679
- 1_000,
830
+ Math.min(
831
+ capability.resumable && isResumableVoiceInputClient(client)
832
+ ? capability.resumable.maxDurationSeconds
833
+ : capability.maxDurationSeconds,
834
+ capability.resumable && isResumableVoiceInputClient(client)
835
+ ? VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS
836
+ : VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
837
+ ) * 1_000,
680
838
  );
681
839
  return true;
682
840
  } catch (reason) {
@@ -759,9 +917,29 @@ export function useVoiceInput({
759
917
  }
760
918
  const generation = ++generationRef.current;
761
919
  controllerRef.current?.abort();
920
+ if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
921
+ automaticRetryTimerRef.current = null;
922
+ automaticRetryAttemptRef.current = 0;
762
923
  setError(null);
763
- void finalizePersistedRecording(generation);
764
- }, [client, disabled, enabled, finalizePersistedRecording, status, workspaceId]);
924
+ const manifest = manifestRef.current;
925
+ void updateManifestBestEffort(manifest, {
926
+ recoveryMode: "automatic",
927
+ handoffMode: "append",
928
+ }).then((retained) => {
929
+ if (generation !== generationRef.current) return;
930
+ rememberManifest(retained);
931
+ void finalizePersistedRecording(generation);
932
+ });
933
+ }, [
934
+ client,
935
+ disabled,
936
+ enabled,
937
+ finalizePersistedRecording,
938
+ rememberManifest,
939
+ status,
940
+ updateManifestBestEffort,
941
+ workspaceId,
942
+ ]);
765
943
 
766
944
  const insertSavedTranscript = useCallback(async (): Promise<void> => {
767
945
  const manifest = manifestRef.current;
@@ -800,6 +978,12 @@ export function useVoiceInput({
800
978
  return;
801
979
  }
802
980
  if (generation !== generationRef.current) return;
981
+ if (capability?.resumable && isResumableVoiceInputClient(client)) {
982
+ await client
983
+ .discardTranscriptionRecording(workspaceId, handedOff.recordingId)
984
+ .catch(() => undefined);
985
+ }
986
+ if (generation !== generationRef.current) return;
803
987
  await store
804
988
  .discard(handedOff.recordingId, ownerIdRef.current ?? undefined)
805
989
  .catch(() => undefined);
@@ -811,6 +995,8 @@ export function useVoiceInput({
811
995
  await loadNextRecoverable(generation);
812
996
  }, [
813
997
  clearVisibleRecording,
998
+ capability?.resumable,
999
+ client,
814
1000
  ensureStore,
815
1001
  focusInput,
816
1002
  loadNextRecoverable,
@@ -835,6 +1021,12 @@ export function useVoiceInput({
835
1021
  await captureSettled.catch(() => undefined);
836
1022
  if (generation !== generationRef.current) return;
837
1023
  if (manifest) {
1024
+ if (capability?.resumable && isResumableVoiceInputClient(client)) {
1025
+ await client
1026
+ .discardTranscriptionRecording(workspaceId, manifest.recordingId)
1027
+ .catch(() => undefined);
1028
+ }
1029
+ if (generation !== generationRef.current) return;
838
1030
  try {
839
1031
  await (await ensureStore()).discard(manifest.recordingId, ownerIdRef.current ?? undefined);
840
1032
  } catch {
@@ -856,10 +1048,13 @@ export function useVoiceInput({
856
1048
  }, [
857
1049
  clearCaptureRuntime,
858
1050
  clearVisibleRecording,
1051
+ capability?.resumable,
1052
+ client,
859
1053
  ensureStore,
860
1054
  focusInput,
861
1055
  loadNextRecoverable,
862
1056
  rememberManifest,
1057
+ workspaceId,
863
1058
  ]);
864
1059
 
865
1060
  const cancel = useCallback(() => {
@@ -867,10 +1062,12 @@ export function useVoiceInput({
867
1062
  void discard();
868
1063
  return;
869
1064
  }
870
- if (status === "saving" || status === "transcribing") {
1065
+ if (status === "saving" || status === "transcribing" || status === "retrying") {
871
1066
  const generation = ++generationRef.current;
872
1067
  controllerRef.current?.abort();
873
1068
  controllerRef.current = null;
1069
+ if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
1070
+ automaticRetryTimerRef.current = null;
874
1071
  const captureSettled = status === "saving" ? captureSettledRef.current : Promise.resolve();
875
1072
  void captureSettled.then(async () => {
876
1073
  const manifest = manifestRef.current;
@@ -879,6 +1076,8 @@ export function useVoiceInput({
879
1076
  captureState: "stopped",
880
1077
  uploadState: "retrying",
881
1078
  transcriptionState: "retrying",
1079
+ recoveryMode: "manual",
1080
+ handoffMode: "explicit",
882
1081
  });
883
1082
  await preserveForRetry(retained, null, generation);
884
1083
  });
@@ -912,7 +1111,12 @@ export function useVoiceInput({
912
1111
  {
913
1112
  ...(current.captureState === "capturing" ? { captureState: "stopped" as const } : {}),
914
1113
  ...(wasProcessing
915
- ? { uploadState: "retrying" as const, transcriptionState: "retrying" as const }
1114
+ ? {
1115
+ uploadState: "retrying" as const,
1116
+ transcriptionState: "retrying" as const,
1117
+ recoveryMode: "automatic" as const,
1118
+ handoffMode: "explicit" as const,
1119
+ }
916
1120
  : {}),
917
1121
  ownerId: null,
918
1122
  ownerHeartbeatAt: null,
@@ -944,7 +1148,8 @@ export function useVoiceInput({
944
1148
  (status === "requesting-permission" ||
945
1149
  status === "recording" ||
946
1150
  status === "saving" ||
947
- status === "transcribing")
1151
+ status === "transcribing" ||
1152
+ status === "retrying")
948
1153
  ) {
949
1154
  event.preventDefault();
950
1155
  cancel();
@@ -959,6 +1164,8 @@ export function useVoiceInput({
959
1164
  generationRef.current += 1;
960
1165
  controllerRef.current?.abort();
961
1166
  controllerRef.current = null;
1167
+ if (automaticRetryTimerRef.current) clearTimeout(automaticRetryTimerRef.current);
1168
+ automaticRetryTimerRef.current = null;
962
1169
  const manifest = manifestRef.current;
963
1170
  const ownerReady = ownerIdPromiseRef.current;
964
1171
  stopOwnerHeartbeat();
@@ -977,7 +1184,9 @@ export function useVoiceInput({
977
1184
  const store = storeRef.current;
978
1185
  if (store && manifest && ownerId) {
979
1186
  const wasProcessing =
980
- statusRef.current === "saving" || statusRef.current === "transcribing";
1187
+ statusRef.current === "saving" ||
1188
+ statusRef.current === "transcribing" ||
1189
+ statusRef.current === "retrying";
981
1190
  await store
982
1191
  .updateManifest(
983
1192
  manifest.recordingId,
@@ -989,6 +1198,8 @@ export function useVoiceInput({
989
1198
  ? {
990
1199
  uploadState: "retrying" as const,
991
1200
  transcriptionState: "retrying" as const,
1201
+ recoveryMode: "automatic" as const,
1202
+ handoffMode: "explicit" as const,
992
1203
  }
993
1204
  : {}),
994
1205
  ownerId: null,
@@ -1032,6 +1243,226 @@ export function useVoiceInput({
1032
1243
  };
1033
1244
  }
1034
1245
 
1246
+ async function transcribePersistedRecording(input: {
1247
+ client: VoiceInputClient;
1248
+ workspaceId: string;
1249
+ capability: ClientVoiceInputConfig;
1250
+ manifest: VoiceRecordingManifest;
1251
+ chunks: VoiceRecordingChunk[];
1252
+ signal: AbortSignal;
1253
+ }): Promise<{ text: string; languages: string[] }> {
1254
+ const client = input.client;
1255
+ const resumable = input.capability.resumable;
1256
+ const ordered = [...input.chunks].sort((left, right) => left.chunkNumber - right.chunkNumber);
1257
+ if (!resumable || !isResumableVoiceInputClient(client)) {
1258
+ const audio = new Blob(
1259
+ ordered.map((chunk) => chunk.audio),
1260
+ { type: input.manifest.mimeType },
1261
+ );
1262
+ if (audio.size > input.capability.maxSizeBytes) throw { code: "too_large" };
1263
+ return await client.transcribeAudio(input.workspaceId, {
1264
+ audio,
1265
+ mimeType: audio.type,
1266
+ durationSeconds: input.manifest.totalDurationMilliseconds / 1_000,
1267
+ signal: input.signal,
1268
+ });
1269
+ }
1270
+
1271
+ if (
1272
+ input.manifest.totalBytes > resumable.maxSizeBytes ||
1273
+ input.manifest.totalDurationMilliseconds > resumable.maxDurationSeconds * 1_000
1274
+ ) {
1275
+ throw { code: "too_large" };
1276
+ }
1277
+ if (
1278
+ ordered.some((chunk, index) => chunk.chunkNumber !== index) ||
1279
+ ordered.length !== input.manifest.chunkCount
1280
+ ) {
1281
+ throw { code: "invalid_audio" };
1282
+ }
1283
+
1284
+ const finalizeInput = {
1285
+ chunkCount: input.manifest.chunkCount,
1286
+ totalBytes: input.manifest.totalBytes,
1287
+ totalDurationMilliseconds: input.manifest.totalDurationMilliseconds,
1288
+ signal: input.signal,
1289
+ };
1290
+
1291
+ let remote = await client.createTranscriptionRecording(input.workspaceId, {
1292
+ recordingId: input.manifest.recordingId,
1293
+ mimeType: input.manifest.mimeType,
1294
+ signal: input.signal,
1295
+ });
1296
+ for (const chunk of ordered) {
1297
+ if (chunk.chunkNumber < remote.recording.nextChunkNumber) continue;
1298
+ if (chunk.chunkNumber !== remote.recording.nextChunkNumber) {
1299
+ throw { code: "invalid_audio" };
1300
+ }
1301
+ if (chunk.byteLength > resumable.maxChunkSizeBytes) throw { code: "too_large" };
1302
+ const uploaded = await client.uploadTranscriptionRecordingChunk(
1303
+ input.workspaceId,
1304
+ input.manifest.recordingId,
1305
+ chunk.chunkNumber,
1306
+ {
1307
+ audio: chunk.audio,
1308
+ mimeType: input.manifest.mimeType,
1309
+ sha256: chunk.sha256,
1310
+ startMilliseconds: chunk.startMilliseconds,
1311
+ durationMilliseconds: chunk.durationMilliseconds,
1312
+ signal: input.signal,
1313
+ },
1314
+ );
1315
+ remote = { recording: uploaded.recording, segments: remote.segments };
1316
+ }
1317
+ if (remote.recording.nextChunkNumber !== ordered.length) {
1318
+ throw { code: "invalid_audio" };
1319
+ }
1320
+
1321
+ remote = await client.finalizeTranscriptionRecording(
1322
+ input.workspaceId,
1323
+ input.manifest.recordingId,
1324
+ finalizeInput,
1325
+ );
1326
+
1327
+ let recoveryMutationAttempt = 0;
1328
+ let recoveryMutationDueAt = 0;
1329
+ const scheduleRecoveryMutation = (response: typeof remote): void => {
1330
+ if (response.recording.state !== "segmenting" && response.recording.state !== "transcribing") {
1331
+ return;
1332
+ }
1333
+ recoveryMutationDueAt =
1334
+ Date.now() +
1335
+ transcriptionRecoveryMutationDelayMilliseconds(
1336
+ response.retryAfterMilliseconds,
1337
+ recoveryMutationAttempt,
1338
+ );
1339
+ recoveryMutationAttempt += 1;
1340
+ };
1341
+ scheduleRecoveryMutation(remote);
1342
+
1343
+ for (;;) {
1344
+ if (input.signal.aborted) throw new DOMException("Aborted", "AbortError");
1345
+ switch (remote.recording.state) {
1346
+ case "complete":
1347
+ if (remote.recording.transcriptText === null) throw { code: "invalid_audio" };
1348
+ return {
1349
+ text: remote.recording.transcriptText,
1350
+ languages: remote.recording.languages,
1351
+ };
1352
+ case "ready":
1353
+ remote = await client.processNextTranscriptionRecordingSegment(
1354
+ input.workspaceId,
1355
+ input.manifest.recordingId,
1356
+ { signal: input.signal },
1357
+ );
1358
+ scheduleRecoveryMutation(remote);
1359
+ break;
1360
+ case "failed": {
1361
+ if (!remote.recording.retryable) {
1362
+ throw { code: remote.recording.errorCode ?? "unknown", retryable: false };
1363
+ }
1364
+ const retried =
1365
+ remote.recording.segmentCount === 0
1366
+ ? await client.finalizeTranscriptionRecording(
1367
+ input.workspaceId,
1368
+ input.manifest.recordingId,
1369
+ finalizeInput,
1370
+ )
1371
+ : await client.processNextTranscriptionRecordingSegment(
1372
+ input.workspaceId,
1373
+ input.manifest.recordingId,
1374
+ { signal: input.signal },
1375
+ );
1376
+ if (retried.recording.state === "failed") {
1377
+ throw {
1378
+ code: retried.recording.errorCode ?? "unknown",
1379
+ retryable: retried.recording.retryable,
1380
+ };
1381
+ }
1382
+ remote = retried;
1383
+ scheduleRecoveryMutation(remote);
1384
+ break;
1385
+ }
1386
+ case "discarded":
1387
+ throw { code: "invalid_audio" };
1388
+ case "uploading":
1389
+ remote = await client.finalizeTranscriptionRecording(
1390
+ input.workspaceId,
1391
+ input.manifest.recordingId,
1392
+ finalizeInput,
1393
+ );
1394
+ scheduleRecoveryMutation(remote);
1395
+ break;
1396
+ case "segmenting":
1397
+ case "transcribing":
1398
+ if (Date.now() < recoveryMutationDueAt) {
1399
+ await abortableDelay(
1400
+ Math.min(
1401
+ recoveryMutationDueAt - Date.now(),
1402
+ VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS,
1403
+ ),
1404
+ input.signal,
1405
+ );
1406
+ remote = await client.getTranscriptionRecording(
1407
+ input.workspaceId,
1408
+ input.manifest.recordingId,
1409
+ { signal: input.signal },
1410
+ );
1411
+ break;
1412
+ }
1413
+ if (remote.recording.state === "segmenting") {
1414
+ // Re-enter the server-authoritative assembly claim only when its
1415
+ // durable recovery schedule is due.
1416
+ remote = await client.finalizeTranscriptionRecording(
1417
+ input.workspaceId,
1418
+ input.manifest.recordingId,
1419
+ finalizeInput,
1420
+ );
1421
+ } else {
1422
+ // Re-enter the server-authoritative segment claim only when its
1423
+ // durable recovery schedule is due.
1424
+ remote = await client.processNextTranscriptionRecordingSegment(
1425
+ input.workspaceId,
1426
+ input.manifest.recordingId,
1427
+ { signal: input.signal },
1428
+ );
1429
+ }
1430
+ scheduleRecoveryMutation(remote);
1431
+ break;
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ function isResumableVoiceInputClient(
1437
+ client: VoiceInputClient | null,
1438
+ ): client is VoiceInputClient & ResumableVoiceInputClient {
1439
+ return Boolean(
1440
+ client &&
1441
+ client.createTranscriptionRecording &&
1442
+ client.getTranscriptionRecording &&
1443
+ client.uploadTranscriptionRecordingChunk &&
1444
+ client.finalizeTranscriptionRecording &&
1445
+ client.processNextTranscriptionRecordingSegment &&
1446
+ client.discardTranscriptionRecording,
1447
+ );
1448
+ }
1449
+
1450
+ async function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
1451
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
1452
+ await new Promise<void>((resolve, reject) => {
1453
+ const timer = setTimeout(() => {
1454
+ signal.removeEventListener("abort", onAbort);
1455
+ resolve();
1456
+ }, milliseconds);
1457
+ const onAbort = () => {
1458
+ clearTimeout(timer);
1459
+ signal.removeEventListener("abort", onAbort);
1460
+ reject(new DOMException("Aborted", "AbortError"));
1461
+ };
1462
+ signal.addEventListener("abort", onAbort, { once: true });
1463
+ });
1464
+ }
1465
+
1035
1466
  function chooseMimeType(accepted: string[]): string | undefined {
1036
1467
  const normalized = accepted.map((value) => value.trim().toLowerCase());
1037
1468
  return MIME_PREFERENCES.find((mimeType) => {
@@ -1049,6 +1480,9 @@ function chooseMimeType(accepted: string[]): string | undefined {
1049
1480
  }
1050
1481
 
1051
1482
  function errorCode(error: unknown): string {
1483
+ if (error instanceof OpenGeniApiError && typeof error.details?.transcriptionCode === "string") {
1484
+ return error.details.transcriptionCode;
1485
+ }
1052
1486
  if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
1053
1487
  return error.code;
1054
1488
  }
@@ -1056,3 +1490,14 @@ function errorCode(error: unknown): string {
1056
1490
  if (error instanceof DOMException && error.name === "NotSupportedError") return "not_supported";
1057
1491
  return "unknown";
1058
1492
  }
1493
+
1494
+ function isRetryableVoiceInputError(error: unknown): boolean {
1495
+ if (error instanceof OpenGeniApiError) {
1496
+ return error.retryable && error.status !== 409;
1497
+ }
1498
+ if (error instanceof TypeError) return true;
1499
+ if (error && typeof error === "object" && "retryable" in error) {
1500
+ return error.retryable === true;
1501
+ }
1502
+ return false;
1503
+ }