@opengeni/react 0.13.0 → 0.20.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 (113) hide show
  1. package/README.md +129 -23
  2. package/dist/chunk-6BXBGJ3B.js +1956 -0
  3. package/dist/chunk-6BXBGJ3B.js.map +1 -0
  4. package/dist/chunk-6UT5OC3P.js +2133 -0
  5. package/dist/chunk-6UT5OC3P.js.map +1 -0
  6. package/dist/chunk-EBTT3MYN.js +92 -0
  7. package/dist/chunk-EBTT3MYN.js.map +1 -0
  8. package/dist/chunk-HHFA4AFX.js +425 -0
  9. package/dist/chunk-HHFA4AFX.js.map +1 -0
  10. package/dist/chunk-LAZ2A743.js +2393 -0
  11. package/dist/chunk-LAZ2A743.js.map +1 -0
  12. package/dist/chunk-MRSECXRP.js +563 -0
  13. package/dist/chunk-MRSECXRP.js.map +1 -0
  14. package/dist/chunk-TK7G6XLT.js +18 -0
  15. package/dist/chunk-TK7G6XLT.js.map +1 -0
  16. package/dist/composer-DvUaa5ki.d.ts +585 -0
  17. package/dist/composer.d.ts +6 -0
  18. package/dist/composer.js +56 -0
  19. package/dist/composer.js.map +1 -0
  20. package/dist/index.d.ts +642 -1080
  21. package/dist/index.js +7672 -8005
  22. package/dist/index.js.map +1 -1
  23. package/dist/machines.d.ts +412 -3
  24. package/dist/machines.js +25 -1
  25. package/dist/noto-sans-arabic-loader-IA2T4X2A.js +21 -0
  26. package/dist/noto-sans-arabic-loader-IA2T4X2A.js.map +1 -0
  27. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js +292 -0
  28. package/dist/noto-sans-jp-loader.generated-HFGLYBR3.js.map +1 -0
  29. package/dist/queue-surface-implementation-NQMV2ML3.js +890 -0
  30. package/dist/queue-surface-implementation-NQMV2ML3.js.map +1 -0
  31. package/dist/session-Gd4iVPYw.d.ts +425 -0
  32. package/dist/session-context-DIFFlXKc.d.ts +52 -0
  33. package/dist/session.d.ts +4 -0
  34. package/dist/session.js +50 -0
  35. package/dist/session.js.map +1 -0
  36. package/dist/use-composer-BCfm4mzX.d.ts +112 -0
  37. package/package.json +23 -4
  38. package/src/approvals.ts +5 -3
  39. package/src/client.ts +50 -1
  40. package/src/components/approval-surface.tsx +198 -0
  41. package/src/components/chat-composer.tsx +104 -751
  42. package/src/components/code-editor.tsx +104 -26
  43. package/src/components/command-palette.tsx +8 -2
  44. package/src/components/composer-transcription-control.tsx +211 -0
  45. package/src/components/composer.tsx +1536 -0
  46. package/src/components/desktop-viewer.tsx +3 -3
  47. package/src/components/enrollment-consent.tsx +2 -2
  48. package/src/components/file-browser.tsx +367 -29
  49. package/src/components/human-input-form.tsx +417 -0
  50. package/src/components/machine-card.tsx +82 -16
  51. package/src/components/machine-health-pill.tsx +68 -0
  52. package/src/components/machine-metrics.tsx +10 -24
  53. package/src/components/machines/health.ts +146 -0
  54. package/src/components/machines/machine-detail.tsx +220 -0
  55. package/src/components/machines/metric-history-chart.tsx +298 -0
  56. package/src/components/machines/metric-sparkline.tsx +76 -0
  57. package/src/components/machines/series.ts +113 -0
  58. package/src/components/machines-dashboard.tsx +16 -4
  59. package/src/components/message-timeline.tsx +15 -7
  60. package/src/components/model-picker.tsx +12 -3
  61. package/src/components/pierre-diff.tsx +32 -6
  62. package/src/components/queue-surface-implementation.tsx +1041 -0
  63. package/src/components/queue-surface-state.tsx +94 -0
  64. package/src/components/queue-surface.tsx +60 -0
  65. package/src/components/sandbox-files.tsx +230 -21
  66. package/src/components/sandbox-terminal.tsx +8 -2
  67. package/src/components/sandbox-workspace.tsx +489 -135
  68. package/src/components/session-status.tsx +0 -6
  69. package/src/components/workbench-changes.tsx +145 -50
  70. package/src/components/workspace-dock.tsx +329 -68
  71. package/src/composer.ts +60 -0
  72. package/src/hooks/internal.ts +115 -34
  73. package/src/hooks/use-composer.ts +635 -74
  74. package/src/hooks/use-human-input.ts +131 -0
  75. package/src/hooks/use-machine-chip.ts +2 -2
  76. package/src/hooks/use-machines.ts +27 -11
  77. package/src/hooks/use-sandbox-files.ts +310 -62
  78. package/src/hooks/use-sandbox-git.ts +154 -40
  79. package/src/hooks/use-sandbox-terminal.ts +28 -6
  80. package/src/hooks/use-session-capabilities.ts +38 -9
  81. package/src/hooks/use-session-control.ts +43 -15
  82. package/src/hooks/use-session-events.ts +523 -48
  83. package/src/hooks/use-session-lineage.ts +15 -6
  84. package/src/hooks/use-session.ts +10 -2
  85. package/src/hooks/use-slash-commands.ts +38 -38
  86. package/src/hooks/use-transcription.ts +757 -0
  87. package/src/hooks/use-turn-queue.ts +273 -77
  88. package/src/hooks/use-windowed-sections.ts +2 -2
  89. package/src/hooks/use-workspace-capture.ts +146 -40
  90. package/src/hooks/use-workspace-edit.ts +50 -14
  91. package/src/hooks/use-workspace-sessions.ts +3 -0
  92. package/src/human-input.ts +72 -0
  93. package/src/index.ts +88 -12
  94. package/src/lib/noto-sans-arabic-loader.ts +21 -0
  95. package/src/lib/noto-sans-jp-loader.generated.ts +295 -0
  96. package/src/lib/use-portal-token-style.ts +51 -0
  97. package/src/lib/use-unicode-fonts.ts +57 -0
  98. package/src/machines.ts +16 -0
  99. package/src/provider.tsx +163 -41
  100. package/src/session-context.ts +135 -0
  101. package/src/session.ts +79 -0
  102. package/src/timeline/parsers.ts +78 -6
  103. package/src/timeline/projection.ts +36 -6
  104. package/src/timeline/tool-renderers.tsx +41 -0
  105. package/src/timeline/turn-summary.tsx +2 -2
  106. package/src/timeline/types.ts +16 -8
  107. package/styles/index.css +78 -0
  108. package/styles/index.d.ts +2 -0
  109. package/styles/tokens.css +11 -6
  110. package/styles/tokens.d.ts +2 -0
  111. package/dist/chunk-NFYVQWIB.js +0 -1377
  112. package/dist/chunk-NFYVQWIB.js.map +0 -1
  113. package/dist/machines-CnlMb7E-.d.ts +0 -329
@@ -0,0 +1,757 @@
1
+ import {
2
+ authorizeTranscriptionAdapter,
3
+ createTranscriptionSessionRequest,
4
+ type TranscriptionAdapter,
5
+ type TranscriptionDiagnostic,
6
+ type TranscriptionErrorCode,
7
+ type TranscriptionEvent,
8
+ type TranscriptionLifecycleStatus,
9
+ type TranscriptionPolicyBlockReason,
10
+ type TranscriptionSession,
11
+ type TranscriptionTargetSelection,
12
+ type WorkspaceTranscriptionPolicy,
13
+ } from "@opengeni/sdk";
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
15
+
16
+ export type TranscriptionControlState = {
17
+ status: TranscriptionLifecycleStatus;
18
+ generation: number;
19
+ localSessionId: string | null;
20
+ providerSessionId: string | null;
21
+ lastSequence: number;
22
+ partial: string;
23
+ error: { code: TranscriptionErrorCode; recoverable: boolean } | null;
24
+ acceptedFinalIds: string[];
25
+ audioMilliseconds: number;
26
+ costUsd: number | null;
27
+ };
28
+
29
+ export const INITIAL_TRANSCRIPTION_CONTROL_STATE: TranscriptionControlState = {
30
+ status: "idle",
31
+ generation: 0,
32
+ localSessionId: null,
33
+ providerSessionId: null,
34
+ lastSequence: 0,
35
+ partial: "",
36
+ error: null,
37
+ acceptedFinalIds: [],
38
+ audioMilliseconds: 0,
39
+ costUsd: null,
40
+ };
41
+
42
+ export type TranscriptionControlAction =
43
+ | { type: "start"; generation: number; localSessionId: string }
44
+ | { type: "event"; generation: number; event: TranscriptionEvent }
45
+ | {
46
+ type: "start.failed";
47
+ generation: number;
48
+ code: TranscriptionErrorCode;
49
+ }
50
+ | { type: "cancel"; generation: number }
51
+ | { type: "cancel.settled"; generation: number }
52
+ | { type: "reset" };
53
+
54
+ export type TranscriptionControlTransition = {
55
+ state: TranscriptionControlState;
56
+ /** A newly accepted final to append to the current editable draft once. */
57
+ commit: string | null;
58
+ };
59
+
60
+ export function transitionTranscriptionControl(
61
+ state: TranscriptionControlState,
62
+ action: TranscriptionControlAction,
63
+ ): TranscriptionControlTransition {
64
+ if (action.type === "reset") {
65
+ return {
66
+ state: { ...INITIAL_TRANSCRIPTION_CONTROL_STATE, generation: state.generation },
67
+ commit: null,
68
+ };
69
+ }
70
+ if (action.type === "start") {
71
+ if (action.generation <= state.generation) return { state, commit: null };
72
+ return {
73
+ state: {
74
+ ...INITIAL_TRANSCRIPTION_CONTROL_STATE,
75
+ status: "requesting-permission",
76
+ generation: action.generation,
77
+ localSessionId: action.localSessionId,
78
+ },
79
+ commit: null,
80
+ };
81
+ }
82
+ if (action.generation !== state.generation) return { state, commit: null };
83
+ if (action.type === "start.failed") {
84
+ if (state.status === "closed" || state.status === "error") return { state, commit: null };
85
+ return {
86
+ state: {
87
+ ...state,
88
+ status: "error",
89
+ partial: "",
90
+ error: { code: normalizeTranscriptionErrorCode(action.code), recoverable: false },
91
+ },
92
+ commit: null,
93
+ };
94
+ }
95
+ if (action.type === "cancel") {
96
+ if (!isActiveTranscriptionStatus(state.status)) return { state, commit: null };
97
+ return {
98
+ state: { ...state, status: "cancelling", partial: "", error: null },
99
+ commit: null,
100
+ };
101
+ }
102
+ if (action.type === "cancel.settled") {
103
+ if (state.status !== "cancelling") return { state, commit: null };
104
+ return { state: { ...state, status: "closed", partial: "" }, commit: null };
105
+ }
106
+
107
+ const event = action.event;
108
+ if (event.localSessionId !== state.localSessionId) return { state, commit: null };
109
+ if (!Number.isSafeInteger(event.sequence) || event.sequence <= state.lastSequence) {
110
+ return { state, commit: null };
111
+ }
112
+ // Closed and failed generations are terminal local privacy fences. Adapters
113
+ // may still deliver callbacks while remote cleanup settles; none can revive
114
+ // the session or mutate the draft.
115
+ if (state.status === "closed" || state.status === "error") return { state, commit: null };
116
+ // Once cancellation begins, late partial/final/usage callbacks are fenced.
117
+ if (state.status === "cancelling" && event.type !== "session.closed") {
118
+ return { state: { ...state, lastSequence: event.sequence }, commit: null };
119
+ }
120
+
121
+ const sequenced = { ...state, lastSequence: event.sequence };
122
+ switch (event.type) {
123
+ case "permission.requested":
124
+ return {
125
+ state: { ...sequenced, status: "requesting-permission", partial: "", error: null },
126
+ commit: null,
127
+ };
128
+ case "session.opened":
129
+ return {
130
+ state: {
131
+ ...sequenced,
132
+ status: "listening",
133
+ providerSessionId: event.providerSessionId,
134
+ error: null,
135
+ },
136
+ commit: null,
137
+ };
138
+ case "transcript.partial":
139
+ return {
140
+ state: { ...sequenced, status: "listening", partial: event.text, error: null },
141
+ commit: null,
142
+ };
143
+ case "transcript.final": {
144
+ const text = event.text.trim();
145
+ const duplicate = state.acceptedFinalIds.includes(event.providerAcceptanceId);
146
+ const acceptedFinalIds =
147
+ duplicate || text.length === 0
148
+ ? state.acceptedFinalIds
149
+ : [...state.acceptedFinalIds, event.providerAcceptanceId];
150
+ return {
151
+ state: {
152
+ ...sequenced,
153
+ status: "listening",
154
+ partial: "",
155
+ error: null,
156
+ acceptedFinalIds,
157
+ },
158
+ commit: duplicate || text.length === 0 ? null : text,
159
+ };
160
+ }
161
+ case "usage":
162
+ return {
163
+ state: {
164
+ ...sequenced,
165
+ audioMilliseconds: Math.max(state.audioMilliseconds, event.audioMilliseconds),
166
+ costUsd: event.costUsd,
167
+ },
168
+ commit: null,
169
+ };
170
+ case "session.reconnecting":
171
+ return {
172
+ state: { ...sequenced, status: "reconnecting", partial: "", error: null },
173
+ commit: null,
174
+ };
175
+ case "session.error":
176
+ return {
177
+ state: {
178
+ ...sequenced,
179
+ status: event.recoverable ? "reconnecting" : "error",
180
+ partial: "",
181
+ error: {
182
+ code: normalizeTranscriptionErrorCode(event.code),
183
+ recoverable: event.recoverable,
184
+ },
185
+ },
186
+ commit: null,
187
+ };
188
+ case "session.closed":
189
+ return {
190
+ state: {
191
+ ...sequenced,
192
+ status: event.reason === "error" ? "error" : "closed",
193
+ partial: "",
194
+ },
195
+ commit: null,
196
+ };
197
+ }
198
+ }
199
+
200
+ export function appendFinalTranscript(draft: string, transcript: string): string {
201
+ const final = transcript.trim();
202
+ if (!final) return draft;
203
+ if (!draft) return final;
204
+ return /\s$/u.test(draft) ? `${draft}${final}` : `${draft} ${final}`;
205
+ }
206
+
207
+ export type UseTranscriptionOptions = {
208
+ adapter: TranscriptionAdapter | null;
209
+ policy: WorkspaceTranscriptionPolicy;
210
+ selection?: TranscriptionTargetSelection | undefined;
211
+ value: string;
212
+ setValue: (value: string) => void;
213
+ focusInput: () => void;
214
+ disabled?: boolean | undefined;
215
+ createLocalSessionId?: (() => string) | undefined;
216
+ lifecycleTimeouts?: Partial<TranscriptionLifecycleTimeouts> | undefined;
217
+ /** Receives bounded, redacted diagnostics; this detail is never rendered by the hook. */
218
+ onDiagnostic?: ((diagnostic: TranscriptionDiagnostic) => void) | undefined;
219
+ };
220
+
221
+ export type TranscriptionLifecycleTimeouts = {
222
+ startMs: number;
223
+ cleanupMs: number;
224
+ };
225
+
226
+ const DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS: TranscriptionLifecycleTimeouts = {
227
+ startMs: 15_000,
228
+ cleanupMs: 2_000,
229
+ };
230
+
231
+ export type UseTranscriptionResult = {
232
+ state: TranscriptionControlState;
233
+ available: boolean;
234
+ unavailableReason:
235
+ | TranscriptionPolicyBlockReason
236
+ | "adapter_missing"
237
+ | "composer_disabled"
238
+ | null;
239
+ start: () => Promise<boolean>;
240
+ cancel: () => Promise<boolean>;
241
+ reset: () => void;
242
+ };
243
+
244
+ export function useTranscription({
245
+ adapter,
246
+ policy,
247
+ selection = { kind: "primary" },
248
+ value,
249
+ setValue,
250
+ focusInput,
251
+ disabled = false,
252
+ createLocalSessionId = defaultLocalSessionId,
253
+ lifecycleTimeouts,
254
+ onDiagnostic,
255
+ }: UseTranscriptionOptions): UseTranscriptionResult {
256
+ const [state, setState] = useState(INITIAL_TRANSCRIPTION_CONTROL_STATE);
257
+ const stateRef = useRef(state);
258
+ const valueRef = useRef(value);
259
+ const sessionRef = useRef<{ generation: number; session: TranscriptionSession } | null>(null);
260
+ const pendingStartRef = useRef<{
261
+ generation: number;
262
+ controller: AbortController;
263
+ } | null>(null);
264
+ const activePolicyRef = useRef<{ generation: number; revision: string } | null>(null);
265
+ valueRef.current = value;
266
+
267
+ const startTimeoutMs = normalizeTimeout(
268
+ lifecycleTimeouts?.startMs,
269
+ DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS.startMs,
270
+ );
271
+ const cleanupTimeoutMs = normalizeTimeout(
272
+ lifecycleTimeouts?.cleanupMs,
273
+ DEFAULT_TRANSCRIPTION_LIFECYCLE_TIMEOUTS.cleanupMs,
274
+ );
275
+
276
+ const reportDiagnostic = useCallback(
277
+ (diagnostic: unknown) => {
278
+ if (!onDiagnostic) return;
279
+ try {
280
+ onDiagnostic(sanitizeTranscriptionDiagnostic(diagnostic));
281
+ } catch {
282
+ // Observability must never break the local privacy or UI lifecycle.
283
+ }
284
+ },
285
+ [onDiagnostic],
286
+ );
287
+ const cleanupRuntimeRef = useRef({ cleanupTimeoutMs, reportDiagnostic });
288
+
289
+ useEffect(() => {
290
+ cleanupRuntimeRef.current = { cleanupTimeoutMs, reportDiagnostic };
291
+ }, [cleanupTimeoutMs, reportDiagnostic]);
292
+
293
+ const policyRevision = useMemo(
294
+ () => transcriptionPolicyRevision(policy, selection),
295
+ [policy, selection],
296
+ );
297
+ const authorization = useMemo(
298
+ () => (adapter ? authorizeTranscriptionAdapter(policy, adapter.descriptor, selection) : null),
299
+ [adapter, policy, selection],
300
+ );
301
+ const unavailableReason = disabled
302
+ ? ("composer_disabled" as const)
303
+ : !policy.enabled
304
+ ? ("disabled" as const)
305
+ : !policy.acceptanceId
306
+ ? ("unaccepted" as const)
307
+ : !policy.primary
308
+ ? ("target_missing" as const)
309
+ : !adapter
310
+ ? ("adapter_missing" as const)
311
+ : authorization?.authorized
312
+ ? null
313
+ : (authorization?.reason ?? "unaccepted");
314
+
315
+ const apply = useCallback(
316
+ (action: TranscriptionControlAction) => {
317
+ const transition = transitionTranscriptionControl(stateRef.current, action);
318
+ if (transition.state !== stateRef.current) {
319
+ stateRef.current = transition.state;
320
+ setState(transition.state);
321
+ }
322
+ if (transition.commit !== null) {
323
+ const next = appendFinalTranscript(valueRef.current, transition.commit);
324
+ valueRef.current = next;
325
+ setValue(next);
326
+ focusInput();
327
+ }
328
+ return transition.state;
329
+ },
330
+ [focusInput, setValue],
331
+ );
332
+
333
+ const releaseTerminalSession = useCallback(
334
+ (generation: number) => {
335
+ const active = sessionRef.current;
336
+ if (!active || active.generation !== generation) return;
337
+ sessionRef.current = null;
338
+ if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
339
+ detachSessionCleanup(active.session, {
340
+ cleanupTimeoutMs,
341
+ reportDiagnostic,
342
+ });
343
+ },
344
+ [cleanupTimeoutMs, reportDiagnostic],
345
+ );
346
+
347
+ const start = useCallback(async (): Promise<boolean> => {
348
+ if (
349
+ !adapter ||
350
+ unavailableReason !== null ||
351
+ isActiveTranscriptionStatus(stateRef.current.status)
352
+ ) {
353
+ return false;
354
+ }
355
+ const generation = stateRef.current.generation + 1;
356
+ const localSessionId = createLocalSessionId();
357
+ apply({ type: "start", generation, localSessionId });
358
+ const request = createTranscriptionSessionRequest({
359
+ policy,
360
+ adapter,
361
+ localSessionId,
362
+ selection,
363
+ sequenceFloor: 0,
364
+ });
365
+ if (!request) {
366
+ apply({
367
+ type: "start.failed",
368
+ generation,
369
+ code: "policy_blocked",
370
+ });
371
+ return false;
372
+ }
373
+ activePolicyRef.current = { generation, revision: policyRevision };
374
+ const controller = new AbortController();
375
+ pendingStartRef.current = { generation, controller };
376
+ let boundarySettled = false;
377
+ let accepted = false;
378
+ const observedStart = Promise.resolve()
379
+ .then(() =>
380
+ adapter.start(
381
+ request,
382
+ (event) => {
383
+ apply({ type: "event", generation, event });
384
+ if (event.type === "session.error" && !event.recoverable) {
385
+ releaseTerminalSession(generation);
386
+ } else if (event.type === "session.closed") {
387
+ releaseTerminalSession(generation);
388
+ }
389
+ },
390
+ {
391
+ signal: controller.signal,
392
+ reportDiagnostic,
393
+ },
394
+ ),
395
+ )
396
+ .then(
397
+ (session) => {
398
+ if (boundarySettled && !accepted) {
399
+ detachSessionCleanup(session, {
400
+ cancelReason: "stale-generation",
401
+ cleanupTimeoutMs,
402
+ reportDiagnostic,
403
+ });
404
+ }
405
+ return { kind: "session" as const, session };
406
+ },
407
+ (error: unknown) => {
408
+ if (boundarySettled) {
409
+ reportDiagnostic({
410
+ operation: "start",
411
+ code: classifyStartError(error),
412
+ detail: diagnosticDetail(error),
413
+ });
414
+ }
415
+ return { kind: "error" as const, error };
416
+ },
417
+ );
418
+
419
+ let abortListener: (() => void) | null = null;
420
+ const aborted = new Promise<{ kind: "aborted" }>((resolve) => {
421
+ if (controller.signal.aborted) {
422
+ resolve({ kind: "aborted" });
423
+ return;
424
+ }
425
+ abortListener = () => resolve({ kind: "aborted" });
426
+ controller.signal.addEventListener("abort", abortListener, { once: true });
427
+ });
428
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
429
+ const timedOut = new Promise<{ kind: "timeout" }>((resolve) => {
430
+ timeoutId = setTimeout(() => resolve({ kind: "timeout" }), startTimeoutMs);
431
+ });
432
+ const outcome = await Promise.race([observedStart, aborted, timedOut]);
433
+ boundarySettled = true;
434
+ if (timeoutId !== null) clearTimeout(timeoutId);
435
+ if (abortListener) controller.signal.removeEventListener("abort", abortListener);
436
+ if (pendingStartRef.current?.generation === generation) pendingStartRef.current = null;
437
+
438
+ if (outcome.kind === "aborted") return false;
439
+
440
+ if (outcome.kind === "timeout") {
441
+ controller.abort("transcription-start-timeout");
442
+ if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
443
+ reportDiagnostic({
444
+ operation: "start",
445
+ code: "timeout",
446
+ detail: `Transcription adapter start exceeded ${startTimeoutMs}ms.`,
447
+ });
448
+ apply({ type: "start.failed", generation, code: "timeout" });
449
+ return false;
450
+ }
451
+
452
+ if (outcome.kind === "error") {
453
+ if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
454
+ const code = classifyStartError(outcome.error);
455
+ reportDiagnostic({
456
+ operation: "start",
457
+ code,
458
+ detail: diagnosticDetail(outcome.error),
459
+ });
460
+ if (!controller.signal.aborted) {
461
+ apply({ type: "start.failed", generation, code });
462
+ }
463
+ return false;
464
+ }
465
+
466
+ const current = stateRef.current;
467
+ if (
468
+ current.generation !== generation ||
469
+ current.localSessionId !== localSessionId ||
470
+ current.status === "cancelling" ||
471
+ !isActiveTranscriptionStatus(current.status)
472
+ ) {
473
+ if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
474
+ detachSessionCleanup(outcome.session, {
475
+ ...(current.status === "error" ? {} : { cancelReason: "stale-generation" }),
476
+ cleanupTimeoutMs,
477
+ reportDiagnostic,
478
+ });
479
+ return false;
480
+ }
481
+ accepted = true;
482
+ sessionRef.current = { generation, session: outcome.session };
483
+ return true;
484
+ }, [
485
+ adapter,
486
+ apply,
487
+ createLocalSessionId,
488
+ cleanupTimeoutMs,
489
+ policy,
490
+ policyRevision,
491
+ releaseTerminalSession,
492
+ reportDiagnostic,
493
+ selection,
494
+ startTimeoutMs,
495
+ unavailableReason,
496
+ ]);
497
+
498
+ const cancel = useCallback(async (): Promise<boolean> => {
499
+ const current = stateRef.current;
500
+ if (!isActiveTranscriptionStatus(current.status)) return false;
501
+ const generation = current.generation;
502
+ apply({ type: "cancel", generation });
503
+ const pending = pendingStartRef.current;
504
+ if (pending?.generation === generation) {
505
+ pendingStartRef.current = null;
506
+ pending.controller.abort("transcription-locally-cancelled");
507
+ }
508
+ const active = sessionRef.current;
509
+ sessionRef.current = null;
510
+ if (activePolicyRef.current?.generation === generation) activePolicyRef.current = null;
511
+ apply({ type: "cancel.settled", generation });
512
+ focusInput();
513
+ if (active?.generation === generation) {
514
+ detachSessionCleanup(active.session, {
515
+ cancelReason: "user-cancelled",
516
+ cleanupTimeoutMs,
517
+ reportDiagnostic,
518
+ });
519
+ }
520
+ return true;
521
+ }, [apply, cleanupTimeoutMs, focusInput, reportDiagnostic]);
522
+
523
+ const reset = useCallback(() => apply({ type: "reset" }), [apply]);
524
+
525
+ useEffect(() => {
526
+ if (!isActiveTranscriptionStatus(state.status)) return;
527
+ const onKeyDown = (event: KeyboardEvent) => {
528
+ if (event.key !== "Escape") return;
529
+ event.preventDefault();
530
+ void cancel();
531
+ };
532
+ document.addEventListener("keydown", onKeyDown);
533
+ return () => document.removeEventListener("keydown", onKeyDown);
534
+ }, [cancel, state.status]);
535
+
536
+ useEffect(() => {
537
+ if (!isActiveTranscriptionStatus(state.status)) return;
538
+ const activePolicy = activePolicyRef.current;
539
+ if (
540
+ unavailableReason === null &&
541
+ activePolicy?.generation === state.generation &&
542
+ activePolicy.revision === policyRevision
543
+ ) {
544
+ return;
545
+ }
546
+ void cancel();
547
+ }, [cancel, policyRevision, state.generation, state.status, unavailableReason]);
548
+
549
+ useEffect(
550
+ () => () => {
551
+ const pending = pendingStartRef.current;
552
+ pendingStartRef.current = null;
553
+ pending?.controller.abort("transcription-component-unmounted");
554
+ const active = sessionRef.current;
555
+ sessionRef.current = null;
556
+ activePolicyRef.current = null;
557
+ stateRef.current = {
558
+ ...stateRef.current,
559
+ generation: stateRef.current.generation + 1,
560
+ status: "closed",
561
+ partial: "",
562
+ };
563
+ if (active) {
564
+ const runtime = cleanupRuntimeRef.current;
565
+ detachSessionCleanup(active.session, {
566
+ cancelReason: "component-unmounted",
567
+ cleanupTimeoutMs: runtime.cleanupTimeoutMs,
568
+ reportDiagnostic: runtime.reportDiagnostic,
569
+ });
570
+ }
571
+ },
572
+ [],
573
+ );
574
+
575
+ return {
576
+ state,
577
+ available: unavailableReason === null,
578
+ unavailableReason,
579
+ start,
580
+ cancel,
581
+ reset,
582
+ };
583
+ }
584
+
585
+ function isActiveTranscriptionStatus(status: TranscriptionLifecycleStatus): boolean {
586
+ return (
587
+ status === "requesting-permission" ||
588
+ status === "listening" ||
589
+ status === "reconnecting" ||
590
+ status === "cancelling"
591
+ );
592
+ }
593
+
594
+ function defaultLocalSessionId(): string {
595
+ return globalThis.crypto.randomUUID();
596
+ }
597
+
598
+ function classifyStartError(error: unknown): TranscriptionErrorCode {
599
+ if (isRecord(error)) {
600
+ const normalized = normalizeTranscriptionErrorCode(error.code);
601
+ if (normalized !== "unknown") return normalized;
602
+ }
603
+ if (typeof DOMException !== "undefined" && error instanceof DOMException) {
604
+ if (error.name === "NotAllowedError") return "permission_denied";
605
+ if (error.name === "NotSupportedError") return "not_supported";
606
+ if (error.name === "TimeoutError") return "timeout";
607
+ if (error.name === "AbortError") return "cancelled";
608
+ }
609
+ return "unknown";
610
+ }
611
+
612
+ export function normalizeTranscriptionErrorCode(code: unknown): TranscriptionErrorCode {
613
+ switch (code) {
614
+ case "permission_denied":
615
+ case "not_supported":
616
+ case "network":
617
+ case "provider":
618
+ case "policy_blocked":
619
+ case "timeout":
620
+ case "cancelled":
621
+ case "unknown":
622
+ return code;
623
+ default:
624
+ return "unknown";
625
+ }
626
+ }
627
+
628
+ export function sanitizeTranscriptionDiagnostic(diagnostic: unknown): TranscriptionDiagnostic {
629
+ const source = isRecord(diagnostic) ? diagnostic : {};
630
+ const operation =
631
+ source.operation === "start" ||
632
+ source.operation === "session" ||
633
+ source.operation === "cancel" ||
634
+ source.operation === "close"
635
+ ? source.operation
636
+ : "session";
637
+ const rawDetail = typeof source.detail === "string" ? source.detail : "No diagnostic detail.";
638
+ const detail = rawDetail
639
+ .replace(/[\u0000-\u001f\u007f]+/gu, " ")
640
+ .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/giu, "Bearer [REDACTED]")
641
+ .replace(/\bsk-[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED]")
642
+ .replace(
643
+ /\b(api[-_ ]?key|authorization|access[-_ ]?token|refresh[-_ ]?token|token|secret)\b\s*[:=]\s*[^\s,;]+/giu,
644
+ "$1=[REDACTED]",
645
+ )
646
+ .trim()
647
+ .slice(0, 512);
648
+ return {
649
+ operation,
650
+ code: normalizeTranscriptionErrorCode(source.code),
651
+ detail: detail || "No diagnostic detail.",
652
+ };
653
+ }
654
+
655
+ function detachSessionCleanup(
656
+ session: TranscriptionSession,
657
+ options: {
658
+ cancelReason?: string | undefined;
659
+ cleanupTimeoutMs: number;
660
+ reportDiagnostic: (diagnostic: unknown) => void;
661
+ },
662
+ ): void {
663
+ if (options.cancelReason) {
664
+ void runBoundedCleanup(
665
+ "cancel",
666
+ () => session.cancel(options.cancelReason),
667
+ options.cleanupTimeoutMs,
668
+ options.reportDiagnostic,
669
+ );
670
+ }
671
+ void runBoundedCleanup(
672
+ "close",
673
+ () => session.close(),
674
+ options.cleanupTimeoutMs,
675
+ options.reportDiagnostic,
676
+ );
677
+ }
678
+
679
+ async function runBoundedCleanup(
680
+ operation: "cancel" | "close",
681
+ invoke: () => Promise<void>,
682
+ timeoutMs: number,
683
+ reportDiagnostic: (diagnostic: unknown) => void,
684
+ ): Promise<void> {
685
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
686
+ const result = Promise.resolve()
687
+ .then(invoke)
688
+ .then(
689
+ () => ({ kind: "settled" as const }),
690
+ (error: unknown) => ({ kind: "rejected" as const, error }),
691
+ );
692
+ const timeout = new Promise<{ kind: "timeout" }>((resolve) => {
693
+ timeoutId = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
694
+ });
695
+ const outcome = await Promise.race([result, timeout]);
696
+ if (timeoutId !== null) clearTimeout(timeoutId);
697
+ if (outcome.kind === "settled") return;
698
+ reportDiagnostic({
699
+ operation,
700
+ code: outcome.kind === "timeout" ? "timeout" : "provider",
701
+ detail:
702
+ outcome.kind === "timeout"
703
+ ? `Transcription ${operation} exceeded ${timeoutMs}ms.`
704
+ : diagnosticDetail(outcome.error),
705
+ });
706
+ }
707
+
708
+ function diagnosticDetail(error: unknown): string {
709
+ if (error instanceof Error) return error.message;
710
+ return typeof error === "string" ? error : "Unknown transcription adapter failure.";
711
+ }
712
+
713
+ function normalizeTimeout(value: number | undefined, fallback: number): number {
714
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
715
+ ? Math.min(Math.floor(value), 60_000)
716
+ : fallback;
717
+ }
718
+
719
+ function isRecord(value: unknown): value is Record<string, unknown> {
720
+ return typeof value === "object" && value !== null;
721
+ }
722
+
723
+ function transcriptionPolicyRevision(
724
+ policy: WorkspaceTranscriptionPolicy,
725
+ selection: TranscriptionTargetSelection,
726
+ ): string {
727
+ const targetKey = (target: WorkspaceTranscriptionPolicy["primary"]): string =>
728
+ target
729
+ ? [
730
+ target.provider,
731
+ target.model ?? "",
732
+ target.credentialMode,
733
+ target.credentialConnectionId ?? "",
734
+ target.region ?? "",
735
+ ].join("\u0000")
736
+ : "";
737
+ return [
738
+ policy.enabled ? "1" : "0",
739
+ policy.acceptanceId ?? "",
740
+ targetKey(policy.primary),
741
+ policy.language ?? "",
742
+ policy.autoDetectLanguage ? "1" : "0",
743
+ policy.diarization.enabled ? "1" : "0",
744
+ policy.diarization.maxSpeakers?.toString() ?? "",
745
+ policy.retention.mode,
746
+ policy.retention.maxDays?.toString() ?? "",
747
+ policy.privacy.allowProviderLogging ? "1" : "0",
748
+ policy.privacy.allowProviderTraining ? "1" : "0",
749
+ policy.fallback.mode,
750
+ policy.fallback.targets.map(targetKey).join("\u0001"),
751
+ policy.cost.currency,
752
+ policy.cost.maxPerHour?.toString() ?? "",
753
+ policy.cost.maxPerMonth?.toString() ?? "",
754
+ selection.kind,
755
+ selection.kind === "fallback" ? selection.index.toString() : "",
756
+ ].join("\u0002");
757
+ }