@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
@@ -210,40 +210,222 @@ export function v4aToGitFileDiff(op: ApplyPatchOperation): GitFileDiff {
210
210
  };
211
211
  }
212
212
 
213
+ const BEGIN_PATCH = "*** Begin Patch";
214
+ const END_PATCH = "*** End Patch";
215
+ const ADD_FILE = "*** Add File: ";
216
+ const DELETE_FILE = "*** Delete File: ";
217
+ const UPDATE_FILE = "*** Update File: ";
218
+ const MOVE_TO = "*** Move to: ";
219
+
220
+ const APPLY_PATCH_OP_TYPES = new Set(["create_file", "update_file", "delete_file"]);
221
+
222
+ function isRecord(value: unknown): value is Record<string, unknown> {
223
+ return value !== null && typeof value === "object" && !Array.isArray(value);
224
+ }
225
+
226
+ /** Freeform / `{ patch }` / command payloads — tolerate leading whitespace. */
227
+ function freeformApplyPatchOps(rawPatch: string): ApplyPatchOperation[] {
228
+ return parseFreeformApplyPatch(rawPatch.trimStart());
229
+ }
230
+
231
+ function asApplyPatchOperation(value: unknown): ApplyPatchOperation | null {
232
+ if (!isRecord(value)) return null;
233
+ if (typeof value.type !== "string" || !APPLY_PATCH_OP_TYPES.has(value.type)) {
234
+ return null;
235
+ }
236
+ if (typeof value.path !== "string" || !value.path) {
237
+ return null;
238
+ }
239
+ const op: ApplyPatchOperation = {
240
+ type: value.type as ApplyPatchOperation["type"],
241
+ path: value.path,
242
+ };
243
+ if (typeof value.diff === "string") op.diff = value.diff;
244
+ if (typeof value.moveTo === "string" && value.moveTo.length > 0) op.moveTo = value.moveTo;
245
+ return op;
246
+ }
247
+
248
+ function parseStructuredOperations(payloads: unknown[]): ApplyPatchOperation[] {
249
+ if (payloads.length === 0) return [];
250
+ const operations: ApplyPatchOperation[] = [];
251
+ for (const payload of payloads) {
252
+ const op = asApplyPatchOperation(payload);
253
+ if (!op) return [];
254
+ operations.push(op);
255
+ }
256
+ return operations;
257
+ }
258
+
213
259
  /**
214
- * Extract the `apply_patch` operations from a provider-native tool item's `raw`
215
- * payload, normalizing the two wire shapes (`raw.operations[]` for a multi-file
216
- * patch, `raw.operation` for a single op). The single owner of this shape so the
217
- * renderer and the turn-summary facet counter never drift.
260
+ * Mirror of `@openai/agents-core` freeform `*** Begin Patch` ops. Kept here so
261
+ * the timeline can render Codex function-tool apply_patch without importing the
262
+ * server SDK package.
218
263
  */
219
- export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
220
- const r = (raw ?? {}) as {
221
- operation?: ApplyPatchOperation;
222
- operations?: ApplyPatchOperation[];
264
+ export function parseFreeformApplyPatch(rawPatch: string): ApplyPatchOperation[] {
265
+ const lines = rawPatch.split(/\r?\n/);
266
+ if (lines.at(-1) === "") lines.pop();
267
+ if (lines[0] !== BEGIN_PATCH) return [];
268
+ if (lines.length < 2 || lines.at(-1) !== END_PATCH) return [];
269
+
270
+ const operations: ApplyPatchOperation[] = [];
271
+ let index = 1;
272
+ while (index < lines.length - 1) {
273
+ const line = lines[index]!;
274
+ let parsed: { operation: ApplyPatchOperation; nextIndex: number } | { error: true } | null =
275
+ null;
276
+ if (line.startsWith(ADD_FILE)) parsed = parseAddFilePatch(lines, index);
277
+ else if (line.startsWith(DELETE_FILE)) parsed = parseDeleteFilePatch(lines, index);
278
+ else if (line.startsWith(UPDATE_FILE)) parsed = parseUpdateFilePatch(lines, index);
279
+ else return [];
280
+ if (!parsed || "error" in parsed) return [];
281
+ operations.push(parsed.operation);
282
+ index = parsed.nextIndex;
283
+ }
284
+ // Match the SDK: Begin/End with no file ops is not a valid patch.
285
+ return operations.length > 0 ? operations : [];
286
+ }
287
+
288
+ function parsePatchHeader(line: string, prefix: string): string | null {
289
+ const path = line.slice(prefix.length).trim();
290
+ return path || null;
291
+ }
292
+
293
+ function isFileOperationHeader(line: string): boolean {
294
+ return line.startsWith(ADD_FILE) || line.startsWith(DELETE_FILE) || line.startsWith(UPDATE_FILE);
295
+ }
296
+
297
+ function joinDiff(lines: string[]): string {
298
+ return `${lines.join("\n")}\n`;
299
+ }
300
+
301
+ function parseAddFilePatch(
302
+ lines: string[],
303
+ index: number,
304
+ ): { operation: ApplyPatchOperation; nextIndex: number } | { error: true } {
305
+ const path = parsePatchHeader(lines[index]!, ADD_FILE);
306
+ if (!path) return { error: true };
307
+ index += 1;
308
+ const diffLines: string[] = [];
309
+ while (index < lines.length - 1 && !isFileOperationHeader(lines[index]!)) {
310
+ const line = lines[index]!;
311
+ if (!line.startsWith("+")) return { error: true };
312
+ diffLines.push(line);
313
+ index += 1;
314
+ }
315
+ if (diffLines.length === 0) return { error: true };
316
+ return {
317
+ operation: { type: "create_file", path, diff: joinDiff(diffLines) },
318
+ nextIndex: index,
223
319
  };
224
- if (Array.isArray(r.operations)) {
225
- return r.operations;
320
+ }
321
+
322
+ function parseDeleteFilePatch(
323
+ lines: string[],
324
+ index: number,
325
+ ): { operation: ApplyPatchOperation; nextIndex: number } | { error: true } {
326
+ const path = parsePatchHeader(lines[index]!, DELETE_FILE);
327
+ if (!path) return { error: true };
328
+ index += 1;
329
+ if (index < lines.length - 1 && !isFileOperationHeader(lines[index]!)) {
330
+ return { error: true };
331
+ }
332
+ return { operation: { type: "delete_file", path }, nextIndex: index };
333
+ }
334
+
335
+ function parseUpdateFilePatch(
336
+ lines: string[],
337
+ index: number,
338
+ ): { operation: ApplyPatchOperation; nextIndex: number } | { error: true } {
339
+ const path = parsePatchHeader(lines[index]!, UPDATE_FILE);
340
+ if (!path) return { error: true };
341
+ index += 1;
342
+ let moveTo: string | undefined;
343
+ if (index < lines.length - 1 && lines[index]!.startsWith(MOVE_TO)) {
344
+ const parsedMoveTo = parsePatchHeader(lines[index]!, MOVE_TO);
345
+ if (!parsedMoveTo) return { error: true };
346
+ moveTo = parsedMoveTo;
347
+ index += 1;
348
+ }
349
+ const diffLines: string[] = [];
350
+ while (index < lines.length - 1 && !isFileOperationHeader(lines[index]!)) {
351
+ diffLines.push(lines[index]!);
352
+ index += 1;
353
+ }
354
+ if (diffLines.length === 0 && !moveTo) return { error: true };
355
+ return {
356
+ operation: {
357
+ type: "update_file",
358
+ path,
359
+ diff: diffLines.length > 0 ? joinDiff(diffLines) : "",
360
+ ...(moveTo ? { moveTo } : {}),
361
+ },
362
+ nextIndex: index,
363
+ };
364
+ }
365
+
366
+ /**
367
+ * Normalize every apply_patch payload the Agents SDK accepts into structured
368
+ * ops — hosted `{ operation }` / `{ operations }`, function-tool `{ patch }`,
369
+ * `command` tuple, flat op, freeform string, or op array.
370
+ */
371
+ export function applyPatchOps(raw: unknown): ApplyPatchOperation[] {
372
+ if (raw == null) return [];
373
+ if (typeof raw === "string") {
374
+ const trimmed = raw.trimStart();
375
+ if (trimmed.startsWith(BEGIN_PATCH)) return freeformApplyPatchOps(trimmed);
376
+ const parsed = tryParseJson(trimmed);
377
+ return parsed === undefined ? [] : applyPatchOps(parsed);
378
+ }
379
+ if (Array.isArray(raw)) return parseStructuredOperations(raw);
380
+ if (!isRecord(raw)) return [];
381
+
382
+ if (typeof raw.patch === "string") return freeformApplyPatchOps(raw.patch);
383
+ if (Array.isArray(raw.command)) {
384
+ const [commandName, patch] = raw.command;
385
+ if (commandName === "apply_patch" && typeof patch === "string") {
386
+ return freeformApplyPatchOps(patch);
387
+ }
226
388
  }
227
- return r.operation ? [r.operation] : [];
389
+ // Empty `operations: []` is not authoritative — fall through to operation/flat.
390
+ if (Array.isArray(raw.operations) && raw.operations.length > 0) {
391
+ return parseStructuredOperations(raw.operations);
392
+ }
393
+ if (raw.operation !== undefined) {
394
+ const op = asApplyPatchOperation(raw.operation);
395
+ return op ? [op] : [];
396
+ }
397
+ // Flat single op: `{ type, path, diff?, moveTo? }`.
398
+ const flat = asApplyPatchOperation(raw);
399
+ return flat ? [flat] : [];
228
400
  }
229
401
 
230
- /** Ops from provider `raw`, or from function-tool arguments when raw is empty. */
402
+ /** Ops from provider `raw` and/or function-tool arguments (Codex path). */
231
403
  export function applyPatchOpsFromToolItem(item: {
232
404
  raw: unknown;
233
405
  arguments: unknown;
234
406
  }): ApplyPatchOperation[] {
235
407
  const fromRaw = applyPatchOps(item.raw);
236
- if (fromRaw.length > 0) {
237
- return fromRaw;
408
+ if (fromRaw.length > 0) return fromRaw;
409
+
410
+ // function_call envelopes sometimes keep the payload only under raw.arguments.
411
+ if (isRecord(item.raw)) {
412
+ const nested = item.raw.arguments ?? item.raw.input;
413
+ if (nested !== undefined && nested !== item.arguments) {
414
+ const fromNested = applyPatchOps(nested);
415
+ if (fromNested.length > 0) return fromNested;
416
+ }
417
+ }
418
+
419
+ if (item.arguments !== undefined && item.arguments !== null) {
420
+ return applyPatchOps(item.arguments);
238
421
  }
239
- const args = parseToolArgs(item.arguments);
240
- return applyPatchOps(args);
422
+ return [];
241
423
  }
242
424
 
243
425
  /**
244
- * True when a tool item is an `apply_patch_call` by its provider-native
245
- * `raw.type` (the live-wire source of truth) or by tool `name` (first-party
246
- * replays that omit `raw`). Centralizes the rawType-or-name check.
426
+ * True when a tool item is apply_patchhosted `raw.type === "apply_patch_call"`,
427
+ * function-tool `name` `apply_patch` / `apply_patch_call`, or an MCP-prefixed
428
+ * `…__apply_patch` leaf. Centralizes the rawType-or-name check.
247
429
  */
248
430
  export function isApplyPatch(item: { name: string; raw: unknown }): boolean {
249
431
  const type =
@@ -54,6 +54,11 @@ const WORKER_MESSAGE_TOOL = "session_send_message";
54
54
  * `"agent"`. That keeps mid-turn goal tools from splitting the step rail with
55
55
  * a breakaway GoalRow pill. Non-agent goal events (API, create-session,
56
56
  * system auto-pause, continuations) still render as landmarks.
57
+ *
58
+ * Solo `goal_continuation` machine-input batches are also suppressed: the
59
+ * paired `goal.continuation` GoalRow already marks the tick; rendering both
60
+ * restates the goal text. Mixed batches (continuation + other kinds) still
61
+ * render as machine-input rows.
57
62
  */
58
63
  const LANDMARK_ONLY_TOOL_LEAVES = new Set(["memory_save", "memory_correct"]);
59
64
 
@@ -140,6 +145,12 @@ export function buildTimeline(events: SessionEvent[]): TimelineItem[] {
140
145
  case "system.update.delivered": {
141
146
  const inputs = machineInputMembers(payload.members);
142
147
  if (inputs.length === 0) break;
148
+ // Goal continuations already land as `goal.continuation` GoalRows.
149
+ // A solo continuation batch would duplicate that landmark + dump the
150
+ // model-facing prompt — skip chrome for that case only.
151
+ if (inputs.every((member) => member.kind === "goal_continuation")) {
152
+ break;
153
+ }
143
154
  closeStreamingTail();
144
155
  items.push({
145
156
  kind: "machine-input-batch",
@@ -364,12 +364,13 @@ function ApplyPatchRenderer({ item }: ToolRendererProps) {
364
364
  </RunningPreview>
365
365
  }
366
366
  >
367
- {ops.map((op) => {
367
+ {ops.map((op, index) => {
368
368
  const file = safeParseOp(op);
369
+ const key = `${op.type}:${op.path}:${index}`;
369
370
  return file ? (
370
- <ToolDiff key={op.path} files={[file]} />
371
+ <ToolDiff key={key} files={[file]} />
371
372
  ) : (
372
- <div key={op.path}>
373
+ <div key={key}>
373
374
  <p className="mb-1 font-og-mono text-og-xs text-og-fg-muted">{op.path}</p>
374
375
  <RawPatch diff={op.diff ?? ""} />
375
376
  </div>
@@ -421,10 +422,11 @@ function ApplyPatchRenderer({ item }: ToolRendererProps) {
421
422
  >
422
423
  {ops.map((op, index) => {
423
424
  const file = parsed[index];
425
+ const key = `${op.type}:${op.path}:${index}`;
424
426
  return file ? (
425
- <ToolDiff key={op.path} files={[file]} />
427
+ <ToolDiff key={key} files={[file]} />
426
428
  ) : (
427
- <div key={op.path}>
429
+ <div key={key}>
428
430
  <p className="mb-1 font-og-mono text-og-xs text-og-fg-muted">{op.path}</p>
429
431
  <RawPatch diff={op.diff ?? ""} />
430
432
  </div>
@@ -16,7 +16,12 @@ import { MOTION_INSPECT_SCALE } from "../lib/motion-inspect";
16
16
  import { useForcedDefaultOpen } from "./disclosure-context";
17
17
  import { useEntranceAnimation } from "./entrance";
18
18
  import { useFoldMemory, type FoldRestingState } from "./fold-memory";
19
- import { applyPatchOps, isApplyPatch, mediaPreviewFact, screenshotDataUrl } from "./parsers";
19
+ import {
20
+ applyPatchOpsFromToolItem,
21
+ isApplyPatch,
22
+ mediaPreviewFact,
23
+ screenshotDataUrl,
24
+ } from "./parsers";
20
25
  import { rawTypeOf } from "./registry";
21
26
  import type { ActivityItem, ToolCallItem, TurnOutcome } from "./types";
22
27
  export type { TurnOutcome } from "./types";
@@ -571,7 +576,7 @@ const BUILT_IN_TURN_SUMMARY_FACETS: readonly TurnSummaryFacet[] = Object.freeze(
571
576
  let files = 0;
572
577
  for (const item of toolCalls) {
573
578
  if (isApplyPatch(item)) {
574
- files += applyPatchOps(item.raw).length;
579
+ files += applyPatchOpsFromToolItem(item).length;
575
580
  }
576
581
  }
577
582
  return files ? { content: `${files} ${files === 1 ? "file" : "files"} edited` } : null;
@@ -0,0 +1,251 @@
1
+ export const VOICE_RECORDING_OWNER_SESSION_KEY = "opengeni.voice-recording-owner.v1";
2
+
3
+ const VOICE_RECORDING_OWNER_LOCK_PREFIX = "opengeni.voice-recording-owner:";
4
+ const VOICE_RECORDING_OWNER_CHANNEL = "opengeni.voice-recording-owner.v1";
5
+ const OWNER_LOCK_RELOAD_GRACE_MILLISECONDS = 250;
6
+ const OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS = 5_000;
7
+ const OWNER_BROADCAST_PROBE_MILLISECONDS = 100;
8
+ const OWNER_BROADCAST_RELOAD_ATTEMPTS = 10;
9
+
10
+ export type VoiceRecordingOwnerLease = {
11
+ ownerId: string;
12
+ release: () => void;
13
+ };
14
+
15
+ type UnderlyingOwnerLease = VoiceRecordingOwnerLease;
16
+
17
+ type OwnerProbeMessage = {
18
+ type: "voice-recording-owner.probe";
19
+ ownerId: string;
20
+ instanceId: string;
21
+ };
22
+
23
+ type OwnerOccupiedMessage = {
24
+ type: "voice-recording-owner.occupied";
25
+ ownerId: string;
26
+ targetInstanceId: string;
27
+ };
28
+
29
+ let sharedLeasePromise: Promise<UnderlyingOwnerLease> | null = null;
30
+ let sharedLeaseConsumers = 0;
31
+
32
+ /**
33
+ * Acquire one document-scoped owner identity shared by every voice hook in the
34
+ * current document. The session-stored candidate survives reload, while the
35
+ * held lock/handshake prevents opener-created or duplicated tabs from reusing
36
+ * that identity concurrently.
37
+ */
38
+ export async function acquireDefaultVoiceRecordingOwnerLease(): Promise<VoiceRecordingOwnerLease> {
39
+ sharedLeaseConsumers += 1;
40
+ sharedLeasePromise ??= createUnderlyingOwnerLease();
41
+ let underlying: UnderlyingOwnerLease;
42
+ try {
43
+ underlying = await sharedLeasePromise;
44
+ } catch (error) {
45
+ sharedLeaseConsumers -= 1;
46
+ if (sharedLeaseConsumers === 0) sharedLeasePromise = null;
47
+ throw error;
48
+ }
49
+
50
+ let released = false;
51
+ return {
52
+ ownerId: underlying.ownerId,
53
+ release: () => {
54
+ if (released) return;
55
+ released = true;
56
+ sharedLeaseConsumers = Math.max(0, sharedLeaseConsumers - 1);
57
+ if (sharedLeaseConsumers === 0) {
58
+ sharedLeasePromise = null;
59
+ underlying.release();
60
+ }
61
+ },
62
+ };
63
+ }
64
+
65
+ async function createUnderlyingOwnerLease(): Promise<UnderlyingOwnerLease> {
66
+ const candidate = readSessionOwnerId() ?? crypto.randomUUID();
67
+ const reloadNavigation = isReloadNavigation();
68
+
69
+ if (hasWebLocks()) {
70
+ const retained = await tryAcquireWebLock(
71
+ candidate,
72
+ reloadNavigation
73
+ ? OWNER_LOCK_RELOAD_NAVIGATION_MILLISECONDS
74
+ : OWNER_LOCK_RELOAD_GRACE_MILLISECONDS,
75
+ );
76
+ if (retained) {
77
+ writeSessionOwnerId(candidate);
78
+ return retained;
79
+ }
80
+
81
+ const rotated = crypto.randomUUID();
82
+ writeSessionOwnerId(rotated);
83
+ const acquired = await tryAcquireWebLock(rotated, OWNER_LOCK_RELOAD_GRACE_MILLISECONDS);
84
+ if (acquired) return acquired;
85
+ }
86
+
87
+ const broadcastLease = await tryAcquireBroadcastLease(
88
+ readSessionOwnerId() ?? candidate,
89
+ reloadNavigation ? OWNER_BROADCAST_RELOAD_ATTEMPTS : 0,
90
+ );
91
+ if (broadcastLease) return broadcastLease;
92
+
93
+ // Without a cross-document coordination primitive, prefer a fresh
94
+ // per-document identity over copied session state. Recovery then waits only
95
+ // for the ordinary stale-owner timeout instead of risking cross-tab access.
96
+ const ownerId = crypto.randomUUID();
97
+ writeSessionOwnerId(ownerId);
98
+ return { ownerId, release: () => undefined };
99
+ }
100
+
101
+ function hasWebLocks(): boolean {
102
+ return (
103
+ typeof navigator !== "undefined" &&
104
+ navigator.locks !== null &&
105
+ navigator.locks !== undefined &&
106
+ typeof navigator.locks.request === "function"
107
+ );
108
+ }
109
+
110
+ async function tryAcquireWebLock(
111
+ ownerId: string,
112
+ waitMilliseconds: number,
113
+ ): Promise<UnderlyingOwnerLease | null> {
114
+ if (!hasWebLocks()) return null;
115
+
116
+ return await new Promise<UnderlyingOwnerLease | null>((resolve) => {
117
+ const controller = new AbortController();
118
+ let settled = false;
119
+ const settle = (lease: UnderlyingOwnerLease | null) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ resolve(lease);
123
+ };
124
+ const timeout = setTimeout(() => controller.abort(), waitMilliseconds);
125
+
126
+ void navigator.locks
127
+ .request(
128
+ `${VOICE_RECORDING_OWNER_LOCK_PREFIX}${ownerId}`,
129
+ { mode: "exclusive", signal: controller.signal },
130
+ async () => {
131
+ clearTimeout(timeout);
132
+ let releaseLock: (() => void) | null = null;
133
+ const held = new Promise<void>((release) => {
134
+ releaseLock = release;
135
+ });
136
+ settle({
137
+ ownerId,
138
+ release: () => releaseLock?.(),
139
+ });
140
+ await held;
141
+ },
142
+ )
143
+ .catch(() => {
144
+ clearTimeout(timeout);
145
+ settle(null);
146
+ });
147
+ });
148
+ }
149
+
150
+ async function tryAcquireBroadcastLease(
151
+ initialOwnerId: string,
152
+ retainCandidateAttempts: number,
153
+ ): Promise<UnderlyingOwnerLease | null> {
154
+ const BroadcastChannelConstructor =
155
+ typeof window !== "undefined" ? window.BroadcastChannel : undefined;
156
+ if (!BroadcastChannelConstructor) return null;
157
+
158
+ let ownerId = initialOwnerId;
159
+ for (let attempt = 0; attempt < retainCandidateAttempts + 3; attempt += 1) {
160
+ const channel = new BroadcastChannelConstructor(VOICE_RECORDING_OWNER_CHANNEL);
161
+ const instanceId = crypto.randomUUID();
162
+ let occupied = false;
163
+ const onMessage = (event: MessageEvent<unknown>) => {
164
+ const message = ownerCoordinationMessage(event.data);
165
+ if (!message || message.ownerId !== ownerId) return;
166
+ if (message.type === "voice-recording-owner.probe") {
167
+ if (message.instanceId === instanceId) return;
168
+ channel.postMessage({
169
+ type: "voice-recording-owner.occupied",
170
+ ownerId,
171
+ targetInstanceId: message.instanceId,
172
+ } satisfies OwnerOccupiedMessage);
173
+ return;
174
+ }
175
+ if (message.targetInstanceId === instanceId) occupied = true;
176
+ };
177
+ channel.addEventListener("message", onMessage);
178
+ channel.postMessage({
179
+ type: "voice-recording-owner.probe",
180
+ ownerId,
181
+ instanceId,
182
+ } satisfies OwnerProbeMessage);
183
+ await delay(OWNER_BROADCAST_PROBE_MILLISECONDS);
184
+ if (!occupied) {
185
+ writeSessionOwnerId(ownerId);
186
+ return {
187
+ ownerId,
188
+ release: () => {
189
+ channel.removeEventListener("message", onMessage);
190
+ channel.close();
191
+ },
192
+ };
193
+ }
194
+ channel.removeEventListener("message", onMessage);
195
+ channel.close();
196
+ if (attempt < retainCandidateAttempts) continue;
197
+ ownerId = crypto.randomUUID();
198
+ writeSessionOwnerId(ownerId);
199
+ }
200
+ return null;
201
+ }
202
+
203
+ function isReloadNavigation(): boolean {
204
+ if (typeof performance === "undefined") return false;
205
+ return performance.getEntriesByType("navigation").some((entry) => {
206
+ return "type" in entry && entry.type === "reload";
207
+ });
208
+ }
209
+
210
+ function ownerCoordinationMessage(value: unknown): OwnerProbeMessage | OwnerOccupiedMessage | null {
211
+ if (!value || typeof value !== "object") return null;
212
+ const candidate = value as Record<string, unknown>;
213
+ if (
214
+ candidate.type === "voice-recording-owner.probe" &&
215
+ typeof candidate.ownerId === "string" &&
216
+ typeof candidate.instanceId === "string"
217
+ ) {
218
+ return candidate as OwnerProbeMessage;
219
+ }
220
+ if (
221
+ candidate.type === "voice-recording-owner.occupied" &&
222
+ typeof candidate.ownerId === "string" &&
223
+ typeof candidate.targetInstanceId === "string"
224
+ ) {
225
+ return candidate as OwnerOccupiedMessage;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function readSessionOwnerId(): string | null {
231
+ try {
232
+ return typeof window === "undefined"
233
+ ? null
234
+ : window.sessionStorage.getItem(VOICE_RECORDING_OWNER_SESSION_KEY);
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ function writeSessionOwnerId(ownerId: string): void {
241
+ try {
242
+ window.sessionStorage.setItem(VOICE_RECORDING_OWNER_SESSION_KEY, ownerId);
243
+ } catch {
244
+ // Private/embedded contexts may deny session storage. The held lock or
245
+ // broadcast lease still keeps the in-memory identity document-scoped.
246
+ }
247
+ }
248
+
249
+ function delay(milliseconds: number): Promise<void> {
250
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
251
+ }