@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.
- package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
- package/dist/chunk-23EJ676W.js.map +1 -0
- package/dist/{chunk-OMCFRWHL.js → chunk-KC27K42G.js} +1443 -101
- package/dist/chunk-KC27K42G.js.map +1 -0
- package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
- package/dist/chunk-KG5F2OKE.js.map +1 -0
- package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
- package/dist/chunk-LWR4MXSS.js.map +1 -0
- package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
- package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
- package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
- package/dist/components/chat-composer.d.ts +5 -1
- package/dist/components/composer-transcription-control.d.ts +16 -1
- package/dist/components/composer.d.ts +6 -4
- package/dist/components/session-chrome.d.ts +1 -1
- package/dist/composer.d.ts +3 -1
- package/dist/composer.js +29 -3
- package/dist/hooks/use-voice-input.d.ts +25 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +95 -20
- package/dist/index.js.map +1 -1
- package/dist/model-policy.d.ts +2 -0
- package/dist/model-policy.js +1 -1
- package/dist/realtime/realtime-control.d.ts +29 -1
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +269 -151
- package/dist/realtime.js.map +1 -1
- package/dist/session-ui.js +2 -2
- package/dist/session.js +2 -2
- package/dist/timeline/index.d.ts +1 -1
- package/dist/timeline/parsers.d.ts +13 -8
- package/dist/voice-recording-owner.d.ts +12 -0
- package/dist/voice-recording-store.d.ts +124 -0
- package/package.json +2 -2
- package/src/components/chat-composer.tsx +58 -19
- package/src/components/composer-transcription-control.tsx +258 -117
- package/src/components/composer.tsx +78 -14
- package/src/components/model-policy-picker.tsx +7 -2
- package/src/components/session-chrome.tsx +89 -78
- package/src/composer.ts +30 -1
- package/src/hooks/use-voice-input.ts +893 -67
- package/src/index.ts +32 -1
- package/src/model-policy.ts +4 -0
- package/src/realtime/realtime-control.tsx +307 -137
- package/src/realtime.ts +1 -0
- package/src/timeline/index.ts +2 -0
- package/src/timeline/parsers.ts +201 -19
- package/src/timeline/projection.ts +11 -0
- package/src/timeline/tool-renderers.tsx +7 -5
- package/src/timeline/turn-summary.tsx +7 -2
- package/src/voice-recording-owner.ts +251 -0
- package/src/voice-recording-store.ts +528 -0
- package/styles/tokens.css +8 -5
- package/dist/chunk-4IJCL7YO.js.map +0 -1
- package/dist/chunk-KR2SK5GJ.js.map +0 -1
- package/dist/chunk-OMCFRWHL.js.map +0 -1
- package/dist/chunk-SJKT4TKW.js.map +0 -1
- /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
package/src/timeline/parsers.ts
CHANGED
|
@@ -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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
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
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
return applyPatchOps(args);
|
|
422
|
+
return [];
|
|
241
423
|
}
|
|
242
424
|
|
|
243
425
|
/**
|
|
244
|
-
* True when a tool item is
|
|
245
|
-
* `
|
|
246
|
-
*
|
|
426
|
+
* True when a tool item is apply_patch — hosted `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={
|
|
371
|
+
<ToolDiff key={key} files={[file]} />
|
|
371
372
|
) : (
|
|
372
|
-
<div key={
|
|
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={
|
|
427
|
+
<ToolDiff key={key} files={[file]} />
|
|
426
428
|
) : (
|
|
427
|
-
<div key={
|
|
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 {
|
|
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 +=
|
|
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
|
+
}
|