@opengeni/react 1.4.0 → 1.4.2
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/README.md +8 -0
- package/dist/accounts.js +2 -0
- package/dist/accounts.js.map +1 -1
- package/dist/artifacts-document.js +5 -4
- package/dist/artifacts-presentation.js +5 -4
- package/dist/artifacts-spreadsheet.js +4 -3
- package/dist/artifacts.js +14 -13
- package/dist/artifacts.js.map +1 -1
- package/dist/browser-viewer-WGJ43YGI.js +17 -0
- package/dist/chat-interactive-block-ZFHOKTMS.js +12 -0
- package/dist/{chunk-WEHMPOXK.js → chunk-2DDJ6YH6.js} +3 -1
- package/dist/{chunk-WEHMPOXK.js.map → chunk-2DDJ6YH6.js.map} +1 -1
- package/dist/{chunk-C5X5AJXK.js → chunk-2HVLQ26H.js} +5 -4
- package/dist/{chunk-C5X5AJXK.js.map → chunk-2HVLQ26H.js.map} +1 -1
- package/dist/{chunk-A4UJB2MM.js → chunk-32CGLXW2.js} +4 -3
- package/dist/{chunk-A4UJB2MM.js.map → chunk-32CGLXW2.js.map} +1 -1
- package/dist/{chunk-5CVXZA3Y.js → chunk-45NUOSXQ.js} +3 -2
- package/dist/{chunk-5CVXZA3Y.js.map → chunk-45NUOSXQ.js.map} +1 -1
- package/dist/{chunk-WUISZ3WU.js → chunk-53KJBGPZ.js} +5 -4
- package/dist/{chunk-WUISZ3WU.js.map → chunk-53KJBGPZ.js.map} +1 -1
- package/dist/{chunk-MBMPHOMA.js → chunk-55DXD22O.js} +3 -2
- package/dist/{chunk-MBMPHOMA.js.map → chunk-55DXD22O.js.map} +1 -1
- package/dist/{chunk-JT4JW7BC.js → chunk-5I4VRWBB.js} +3 -1
- package/dist/{chunk-JT4JW7BC.js.map → chunk-5I4VRWBB.js.map} +1 -1
- package/dist/{chunk-UK4TINKT.js → chunk-B27E3V37.js} +3 -1
- package/dist/{chunk-UK4TINKT.js.map → chunk-B27E3V37.js.map} +1 -1
- package/dist/{chunk-J6DBS4T3.js → chunk-BNKV5ZLF.js} +5 -4
- package/dist/{chunk-J6DBS4T3.js.map → chunk-BNKV5ZLF.js.map} +1 -1
- package/dist/{chunk-YM2DE7BQ.js → chunk-C5BF4L26.js} +13 -12
- package/dist/{chunk-YM2DE7BQ.js.map → chunk-C5BF4L26.js.map} +1 -1
- package/dist/{chunk-YBXQDQFX.js → chunk-C7CMD56T.js} +6 -5
- package/dist/{chunk-YBXQDQFX.js.map → chunk-C7CMD56T.js.map} +1 -1
- package/dist/{chunk-UGLEYEMP.js → chunk-CM7LVUKC.js} +3 -1
- package/dist/{chunk-UGLEYEMP.js.map → chunk-CM7LVUKC.js.map} +1 -1
- package/dist/{chunk-LJ2UTJJE.js → chunk-CREMJMVN.js} +181 -96
- package/dist/chunk-CREMJMVN.js.map +1 -0
- package/dist/{chunk-JVYQRRC4.js → chunk-CVYCLFMH.js} +3 -1
- package/dist/{chunk-JVYQRRC4.js.map → chunk-CVYCLFMH.js.map} +1 -1
- package/dist/{chunk-Y2PRMYLC.js → chunk-G3U2A6GS.js} +3 -2
- package/dist/{chunk-Y2PRMYLC.js.map → chunk-G3U2A6GS.js.map} +1 -1
- package/dist/{chunk-WZMYFJD3.js → chunk-G6YRCIYJ.js} +16 -7
- package/dist/{chunk-WZMYFJD3.js.map → chunk-G6YRCIYJ.js.map} +1 -1
- package/dist/{chunk-B3V6TG77.js → chunk-GC3PH6N7.js} +3 -1
- package/dist/{chunk-B3V6TG77.js.map → chunk-GC3PH6N7.js.map} +1 -1
- package/dist/{chunk-CJUO7J5Z.js → chunk-HXYOSSQO.js} +3 -2
- package/dist/{chunk-CJUO7J5Z.js.map → chunk-HXYOSSQO.js.map} +1 -1
- package/dist/{chunk-BRM6FVZA.js → chunk-IEE7WCLN.js} +4 -3
- package/dist/{chunk-BRM6FVZA.js.map → chunk-IEE7WCLN.js.map} +1 -1
- package/dist/{chunk-DYWBUX5Q.js → chunk-IMSUVYQB.js} +6 -5
- package/dist/{chunk-DYWBUX5Q.js.map → chunk-IMSUVYQB.js.map} +1 -1
- package/dist/{chunk-LN6TCXD3.js → chunk-IUHHPMAX.js} +184 -48
- package/dist/chunk-IUHHPMAX.js.map +1 -0
- package/dist/{chunk-SGKD2OCN.js → chunk-IYQCWGU5.js} +6 -5
- package/dist/{chunk-SGKD2OCN.js.map → chunk-IYQCWGU5.js.map} +1 -1
- package/dist/{chunk-J2QWVSQY.js → chunk-JH7V627O.js} +8 -7
- package/dist/{chunk-J2QWVSQY.js.map → chunk-JH7V627O.js.map} +1 -1
- package/dist/{chunk-EESD3PY3.js → chunk-JHTIMBOL.js} +6 -5
- package/dist/{chunk-EESD3PY3.js.map → chunk-JHTIMBOL.js.map} +1 -1
- package/dist/{chunk-LNIRAOA3.js → chunk-L2J3PU7A.js} +7 -6
- package/dist/{chunk-LNIRAOA3.js.map → chunk-L2J3PU7A.js.map} +1 -1
- package/dist/{chunk-ZYRGEKWS.js → chunk-LJUMYUBO.js} +3 -1
- package/dist/{chunk-ZYRGEKWS.js.map → chunk-LJUMYUBO.js.map} +1 -1
- package/dist/{chunk-4RVYOY4O.js → chunk-LSW2OJVV.js} +4 -3
- package/dist/{chunk-4RVYOY4O.js.map → chunk-LSW2OJVV.js.map} +1 -1
- package/dist/{chunk-UTRTG6LH.js → chunk-LVS3MH5T.js} +3 -1
- package/dist/{chunk-UTRTG6LH.js.map → chunk-LVS3MH5T.js.map} +1 -1
- package/dist/chunk-M357MHH3.js +2 -0
- package/dist/{chunk-GYVRFZDY.js → chunk-MDIXFKA3.js} +9 -8
- package/dist/{chunk-GYVRFZDY.js.map → chunk-MDIXFKA3.js.map} +1 -1
- package/dist/{chunk-WRHIZYZF.js → chunk-MEQM4LI3.js} +6 -5
- package/dist/{chunk-WRHIZYZF.js.map → chunk-MEQM4LI3.js.map} +1 -1
- package/dist/chunk-NX4ADH73.js +14 -0
- package/dist/chunk-NX4ADH73.js.map +1 -0
- package/dist/{chunk-XCHWDJ22.js → chunk-OGTTUYNT.js} +3 -1
- package/dist/{chunk-XCHWDJ22.js.map → chunk-OGTTUYNT.js.map} +1 -1
- package/dist/{chunk-GIMRELBW.js → chunk-OYQQJL3J.js} +3 -1
- package/dist/{chunk-GIMRELBW.js.map → chunk-OYQQJL3J.js.map} +1 -1
- package/dist/{chunk-RSI7XYXL.js → chunk-PBCZL4JP.js} +6 -5
- package/dist/{chunk-RSI7XYXL.js.map → chunk-PBCZL4JP.js.map} +1 -1
- package/dist/{chunk-I7IWRQLN.js → chunk-PHBFJCEU.js} +17 -11
- package/dist/chunk-PHBFJCEU.js.map +1 -0
- package/dist/{chunk-3CTRFMAK.js → chunk-PKCAYHDC.js} +5 -4
- package/dist/{chunk-3CTRFMAK.js.map → chunk-PKCAYHDC.js.map} +1 -1
- package/dist/{chunk-K3HQAKQX.js → chunk-PMV53IMM.js} +3 -1
- package/dist/{chunk-K3HQAKQX.js.map → chunk-PMV53IMM.js.map} +1 -1
- package/dist/{chunk-YQEAA6LT.js → chunk-QHUE7E4N.js} +3 -2
- package/dist/{chunk-YQEAA6LT.js.map → chunk-QHUE7E4N.js.map} +1 -1
- package/dist/{chunk-OA6HFRQP.js → chunk-QMMCJGFI.js} +4 -3
- package/dist/{chunk-OA6HFRQP.js.map → chunk-QMMCJGFI.js.map} +1 -1
- package/dist/{chunk-YV4L3ILF.js → chunk-QV3DJVRZ.js} +3 -1
- package/dist/{chunk-YV4L3ILF.js.map → chunk-QV3DJVRZ.js.map} +1 -1
- package/dist/{chunk-447JLCBU.js → chunk-S2QH6EZ5.js} +3 -1
- package/dist/{chunk-447JLCBU.js.map → chunk-S2QH6EZ5.js.map} +1 -1
- package/dist/{chunk-PNY3JKBJ.js → chunk-SFH43JT2.js} +5 -4
- package/dist/{chunk-PNY3JKBJ.js.map → chunk-SFH43JT2.js.map} +1 -1
- package/dist/{chunk-AWZLIMKX.js → chunk-T46QZH3E.js} +3 -1
- package/dist/{chunk-AWZLIMKX.js.map → chunk-T46QZH3E.js.map} +1 -1
- package/dist/{chunk-5J4FOW6Q.js → chunk-TNEZNMSN.js} +3 -1
- package/dist/{chunk-5J4FOW6Q.js.map → chunk-TNEZNMSN.js.map} +1 -1
- package/dist/{chunk-22KP6LR5.js → chunk-TXS65BB2.js} +3 -1
- package/dist/{chunk-22KP6LR5.js.map → chunk-TXS65BB2.js.map} +1 -1
- package/dist/{chunk-H5X55XH7.js → chunk-UDPH5SLV.js} +4 -3
- package/dist/{chunk-H5X55XH7.js.map → chunk-UDPH5SLV.js.map} +1 -1
- package/dist/{chunk-HDJWDK7W.js → chunk-VCSERYXH.js} +10 -9
- package/dist/{chunk-HDJWDK7W.js.map → chunk-VCSERYXH.js.map} +1 -1
- package/dist/{chunk-VZTQ73XT.js → chunk-XEA5BCMA.js} +3 -1
- package/dist/{chunk-VZTQ73XT.js.map → chunk-XEA5BCMA.js.map} +1 -1
- package/dist/{chunk-5F4WR7X7.js → chunk-XS67YG5R.js} +3 -1
- package/dist/{chunk-5F4WR7X7.js.map → chunk-XS67YG5R.js.map} +1 -1
- package/dist/{chunk-TKOTKRWY.js → chunk-XTD7TZHW.js} +3 -1
- package/dist/{chunk-TKOTKRWY.js.map → chunk-XTD7TZHW.js.map} +1 -1
- package/dist/{chunk-A2PI6UMY.js → chunk-YSRBFJMQ.js} +3 -1
- package/dist/{chunk-A2PI6UMY.js.map → chunk-YSRBFJMQ.js.map} +1 -1
- package/dist/chunk-YVFRBXST.js +1420 -0
- package/dist/chunk-YVFRBXST.js.map +1 -0
- package/dist/{chunk-SCOTPE2I.js → chunk-YYLBXXTE.js} +3 -1
- package/dist/{chunk-SCOTPE2I.js.map → chunk-YYLBXXTE.js.map} +1 -1
- package/dist/{chunk-RHREZ7BO.js → chunk-ZUWF4NF7.js} +3 -1
- package/dist/{chunk-RHREZ7BO.js.map → chunk-ZUWF4NF7.js.map} +1 -1
- package/dist/clipboard.js +3 -2
- package/dist/components/composer-transcription-control.d.ts +4 -0
- package/dist/components/session-conversation.d.ts +6 -0
- package/dist/composer-voice-events.d.ts +8 -0
- package/dist/composer.js +15 -13
- package/dist/computer-viewer-46UIN7F3.js +20 -0
- package/dist/connect.js +15 -12
- package/dist/connect.js.map +1 -1
- package/dist/desktop.js +6 -5
- package/dist/desktop.js.map +1 -1
- package/dist/diffs.js +2 -1
- package/dist/diffs.js.map +1 -1
- package/dist/editor.js +4 -3
- package/dist/editor.js.map +1 -1
- package/dist/embedded-voice-NIL4RDX3.js +69 -0
- package/dist/embedded-voice-NIL4RDX3.js.map +1 -0
- package/dist/{fleet-decision-row-YZ3ZKWMM.js → fleet-decision-row-SI63O5V4.js} +3 -1
- package/dist/{fleet-decision-row-YZ3ZKWMM.js.map → fleet-decision-row-SI63O5V4.js.map} +1 -1
- package/dist/hooks/use-client-config-flags.d.ts +6 -0
- package/dist/hooks/use-realtime-voice-models.d.ts +9 -0
- package/dist/hooks/use-voice-input.d.ts +8 -1
- package/dist/index.js +47 -45
- package/dist/index.js.map +1 -1
- package/dist/interaction.js +14 -13
- package/dist/interaction.js.map +1 -1
- package/dist/{latest-question-GPYM2JNU.js → latest-question-2POFPYNB.js} +5 -4
- package/dist/{latest-question-GPYM2JNU.js.map → latest-question-2POFPYNB.js.map} +1 -1
- package/dist/machines.js +8 -7
- package/dist/{markdown-table-layout-QXSLK4BY.js → markdown-table-layout-DQ4DRNVT.js} +3 -1
- package/dist/{markdown-table-layout-QXSLK4BY.js.map → markdown-table-layout-DQ4DRNVT.js.map} +1 -1
- package/dist/model-policy-picker-menu-WRNSRFQC.js +15 -0
- package/dist/model-policy.js +2 -1
- package/dist/{noto-sans-arabic-loader-IA2T4X2A.js → noto-sans-arabic-loader-XSH2KYWJ.js} +3 -1
- package/dist/{noto-sans-arabic-loader-IA2T4X2A.js.map → noto-sans-arabic-loader-XSH2KYWJ.js.map} +1 -1
- package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js → noto-sans-jp-loader.generated-MR5L6V7K.js} +3 -1
- package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js.map → noto-sans-jp-loader.generated-MR5L6V7K.js.map} +1 -1
- package/dist/{platform-activity-row-627BFZ46.js → platform-activity-row-PJJVATTZ.js} +3 -1
- package/dist/{platform-activity-row-627BFZ46.js.map → platform-activity-row-PJJVATTZ.js.map} +1 -1
- package/dist/{queue-surface-implementation-ZHFI5JBP.js → queue-surface-implementation-67FS7FUI.js} +10 -9
- package/dist/{queue-surface-implementation-ZHFI5JBP.js.map → queue-surface-implementation-67FS7FUI.js.map} +1 -1
- package/dist/realtime/embedded-voice.d.ts +20 -0
- package/dist/realtime.js +22 -1353
- package/dist/realtime.js.map +1 -1
- package/dist/sandbox-files-34CJJNOY.js +21 -0
- package/dist/session-ui.js +32 -30
- package/dist/session-ui.js.map +1 -1
- package/dist/session.js +10 -9
- package/dist/sites.js +2 -1
- package/dist/terminal.js +6 -5
- package/dist/terminal.js.map +1 -1
- package/dist/{timeline-annotation-markers-GNDHTGBD.js → timeline-annotation-markers-U3G66KUD.js} +5 -4
- package/dist/{timeline-annotation-markers-GNDHTGBD.js.map → timeline-annotation-markers-U3G66KUD.js.map} +1 -1
- package/dist/{timeline-annotation-selection-DMLMM2TX.js → timeline-annotation-selection-BI7QTPTZ.js} +4 -3
- package/dist/{timeline-annotation-selection-DMLMM2TX.js.map → timeline-annotation-selection-BI7QTPTZ.js.map} +1 -1
- package/dist/{timeline-annotations-dialog-ELRDBHTS.js → timeline-annotations-dialog-FOMHSDTE.js} +7 -6
- package/dist/{timeline-annotations-dialog-ELRDBHTS.js.map → timeline-annotations-dialog-FOMHSDTE.js.map} +1 -1
- package/dist/usage.js +6 -5
- package/dist/usage.js.map +1 -1
- package/dist/voice-recording-owner.d.ts +7 -0
- package/package.json +3 -3
- package/src/components/composer-transcription-control.tsx +109 -27
- package/src/components/markdown.tsx +7 -2
- package/src/components/message-timeline.tsx +10 -1
- package/src/components/session-conversation.tsx +44 -0
- package/src/components/session-proxy-scope.tsx +26 -5
- package/src/composer-voice-events.ts +12 -0
- package/src/connection-discovery.tsx +4 -1
- package/src/hooks/use-client-config-flags.ts +8 -0
- package/src/hooks/use-goal.ts +12 -1
- package/src/hooks/use-realtime-voice-models.ts +67 -0
- package/src/hooks/use-voice-input.ts +109 -18
- package/src/realtime/embedded-voice.tsx +81 -0
- package/src/realtime/realtime-control.tsx +82 -17
- package/src/voice-recording-owner.ts +22 -0
- package/styles/compiled.css +72 -18
- package/styles/index.css +49 -0
- package/styles/responsive.css +61 -4
- package/styles/session-capability.css +1 -1
- package/dist/browser-viewer-SHYKQRNX.js +0 -16
- package/dist/chat-interactive-block-UGBTNB3L.js +0 -11
- package/dist/chunk-I7IWRQLN.js.map +0 -1
- package/dist/chunk-IWIDV7SB.js +0 -1
- package/dist/chunk-LJ2UTJJE.js.map +0 -1
- package/dist/chunk-LN6TCXD3.js.map +0 -1
- package/dist/computer-viewer-6SRGWRUX.js +0 -19
- package/dist/model-policy-picker-menu-DXHUL73H.js +0 -14
- package/dist/sandbox-files-GORZOUUJ.js +0 -20
- /package/dist/{browser-viewer-SHYKQRNX.js.map → browser-viewer-WGJ43YGI.js.map} +0 -0
- /package/dist/{chat-interactive-block-UGBTNB3L.js.map → chat-interactive-block-ZFHOKTMS.js.map} +0 -0
- /package/dist/{chunk-IWIDV7SB.js.map → chunk-M357MHH3.js.map} +0 -0
- /package/dist/{computer-viewer-6SRGWRUX.js.map → computer-viewer-46UIN7F3.js.map} +0 -0
- /package/dist/{model-policy-picker-menu-DXHUL73H.js.map → model-policy-picker-menu-WRNSRFQC.js.map} +0 -0
- /package/dist/{sandbox-files-GORZOUUJ.js.map → sandbox-files-34CJJNOY.js.map} +0 -0
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "../voice-recording-store";
|
|
12
12
|
import {
|
|
13
13
|
acquireDefaultVoiceRecordingOwnerLease,
|
|
14
|
+
liveVoiceRecordingOwnerIds,
|
|
14
15
|
type VoiceRecordingOwnerLease,
|
|
15
16
|
} from "../voice-recording-owner";
|
|
16
17
|
import { appendFinalTranscript } from "./use-transcription";
|
|
@@ -65,6 +66,10 @@ export type UseVoiceInputResult = {
|
|
|
65
66
|
stream: MediaStream | null;
|
|
66
67
|
recordingId: string | null;
|
|
67
68
|
durationSeconds: number;
|
|
69
|
+
/** Wall-clock start of the current capture (epoch ms), or null when not recording. */
|
|
70
|
+
recordingStartedAt: number | null;
|
|
71
|
+
/** Capture stops and transcribes automatically after this many seconds. */
|
|
72
|
+
maxRecordingSeconds: number | null;
|
|
68
73
|
locallySaved: boolean;
|
|
69
74
|
hasRecoverableRecording: boolean;
|
|
70
75
|
savedTranscript: string | null;
|
|
@@ -75,7 +80,10 @@ export type UseVoiceInputResult = {
|
|
|
75
80
|
retry: () => void;
|
|
76
81
|
/** Explicitly insert a durably saved transcript whose prior handoff may be uncertain. */
|
|
77
82
|
insertSavedTranscript: () => Promise<void>;
|
|
78
|
-
/**
|
|
83
|
+
/**
|
|
84
|
+
* Cancel active work without losing speech: a recording longer than a few
|
|
85
|
+
* seconds and any stopped/transcribing recording stay locally recoverable.
|
|
86
|
+
*/
|
|
79
87
|
cancel: () => void;
|
|
80
88
|
/** Intentionally delete the current durable recording. */
|
|
81
89
|
discard: () => Promise<void>;
|
|
@@ -85,6 +93,8 @@ export const VOICE_RECORDING_TIMESLICE_MILLISECONDS = 5_000;
|
|
|
85
93
|
export const VOICE_RECORDING_OWNER_HEARTBEAT_MILLISECONDS = 5_000;
|
|
86
94
|
export const VOICE_RECORDING_OWNER_STALE_MILLISECONDS = 30_000;
|
|
87
95
|
export const VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS = 600;
|
|
96
|
+
// Cancel deletes only an accidental start; anything longer is kept for the user.
|
|
97
|
+
const VOICE_RECORDING_DISCARD_ON_CANCEL_MAX_MILLISECONDS = 3_000;
|
|
88
98
|
export const VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS = 8 * 60 * 60;
|
|
89
99
|
export const VOICE_RECORDING_RECOVERY_STATUS_POLL_MILLISECONDS = 2_000;
|
|
90
100
|
export const VOICE_RECORDING_RECOVERY_MAX_MUTATION_DELAY_MILLISECONDS = 30_000;
|
|
@@ -159,6 +169,9 @@ export function useVoiceInput({
|
|
|
159
169
|
const recorderRef = useRef<MediaRecorder | null>(null);
|
|
160
170
|
const streamRef = useRef<MediaStream | null>(null);
|
|
161
171
|
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
172
|
+
// Cancel/Escape/voice takeover keep a real dictation instead of deleting it.
|
|
173
|
+
const holdOnStopRef = useRef(false);
|
|
174
|
+
const recordingStartedAtRef = useRef(0);
|
|
162
175
|
const automaticRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
163
176
|
const automaticRetryAttemptRef = useRef(0);
|
|
164
177
|
const ownerHeartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
@@ -321,15 +334,31 @@ export function useVoiceInput({
|
|
|
321
334
|
generation === generationRef.current && workspaceIdRef.current === workspaceId;
|
|
322
335
|
const [store, ownerId] = await Promise.all([ensureStore(), ensureOwnerId()]);
|
|
323
336
|
if (!active()) return;
|
|
324
|
-
const
|
|
337
|
+
const heartbeatStaleBefore = new Date(
|
|
325
338
|
readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
|
|
326
339
|
).toISOString();
|
|
327
|
-
await store
|
|
340
|
+
await store
|
|
341
|
+
.cleanupHandedOffManifests({ ownerId, staleBefore: heartbeatStaleBefore })
|
|
342
|
+
.catch(() => undefined);
|
|
328
343
|
if (!active()) return;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
// A closed or crashed tab releases its owner lock at once: recover its
|
|
345
|
+
// recording now rather than after the heartbeat window (the user who
|
|
346
|
+
// reopens the app right away must still see their dictation).
|
|
347
|
+
const liveOwners =
|
|
348
|
+
createOwnerIdRef.current === undefined ? await liveVoiceRecordingOwnerIds() : null;
|
|
349
|
+
if (!active()) return;
|
|
350
|
+
const staleBefore = liveOwners ? readNow().toISOString() : heartbeatStaleBefore;
|
|
351
|
+
const manifests = (
|
|
352
|
+
await store.listRecoverableManifests(workspaceId, { ownerId, staleBefore })
|
|
353
|
+
).filter(
|
|
354
|
+
(manifest) =>
|
|
355
|
+
!liveOwners ||
|
|
356
|
+
!manifest.ownerId ||
|
|
357
|
+
manifest.ownerId === ownerId ||
|
|
358
|
+
!liveOwners.has(manifest.ownerId) ||
|
|
359
|
+
!manifest.ownerHeartbeatAt ||
|
|
360
|
+
manifest.ownerHeartbeatAt <= heartbeatStaleBefore,
|
|
361
|
+
);
|
|
333
362
|
if (!active()) return;
|
|
334
363
|
for (const candidate of manifests) {
|
|
335
364
|
try {
|
|
@@ -353,14 +382,20 @@ export function useVoiceInput({
|
|
|
353
382
|
}
|
|
354
383
|
rememberManifest(claimed);
|
|
355
384
|
beginOwnerHeartbeat(claimed);
|
|
385
|
+
const transcriptReady =
|
|
386
|
+
claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null;
|
|
356
387
|
setStatus(
|
|
357
|
-
|
|
388
|
+
transcriptReady
|
|
358
389
|
? "transcript-ready"
|
|
359
390
|
: claimed.recoveryMode === "automatic"
|
|
360
391
|
? "retrying"
|
|
361
392
|
: "recovered",
|
|
362
393
|
);
|
|
363
|
-
|
|
394
|
+
// An append-mode transcript may already be in the draft (the page
|
|
395
|
+
// closed between appending and recording the handoff).
|
|
396
|
+
setError(
|
|
397
|
+
transcriptReady && candidate.handoffMode === "append" ? "handoff_uncertain" : null,
|
|
398
|
+
);
|
|
364
399
|
return;
|
|
365
400
|
} catch (reason) {
|
|
366
401
|
if (reason instanceof VoiceRecordingOwnedError) continue;
|
|
@@ -464,8 +499,9 @@ export function useVoiceInput({
|
|
|
464
499
|
|
|
465
500
|
automaticRetryAttemptRef.current = 0;
|
|
466
501
|
if (ready.handoffMode === "explicit") {
|
|
502
|
+
// Never appended: offer an explicit insert, not an "already inserted?" warning.
|
|
467
503
|
setStatus("transcript-ready");
|
|
468
|
-
setError(
|
|
504
|
+
setError(null);
|
|
469
505
|
focusInput();
|
|
470
506
|
return;
|
|
471
507
|
}
|
|
@@ -824,6 +860,15 @@ export function useVoiceInput({
|
|
|
824
860
|
await preserveForRetry(retained, attemptCaptureLimitError, generation);
|
|
825
861
|
return;
|
|
826
862
|
}
|
|
863
|
+
if (holdOnStopRef.current) {
|
|
864
|
+
holdOnStopRef.current = false;
|
|
865
|
+
const retained = await updateManifestBestEffort(stopped, {
|
|
866
|
+
recoveryMode: "manual",
|
|
867
|
+
handoffMode: "explicit",
|
|
868
|
+
});
|
|
869
|
+
await preserveForRetry(retained, null, generation);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
827
872
|
await finalizePersistedRecording(generation);
|
|
828
873
|
});
|
|
829
874
|
};
|
|
@@ -832,6 +877,8 @@ export function useVoiceInput({
|
|
|
832
877
|
resolveCaptureSettledRef.current = resolve;
|
|
833
878
|
});
|
|
834
879
|
attemptRecordingStartedAt = readNow().getTime();
|
|
880
|
+
recordingStartedAtRef.current = attemptRecordingStartedAt;
|
|
881
|
+
holdOnStopRef.current = false;
|
|
835
882
|
recorder.start(VOICE_RECORDING_TIMESLICE_MILLISECONDS);
|
|
836
883
|
recorderStarted = true;
|
|
837
884
|
setStatus("recording");
|
|
@@ -1069,7 +1116,27 @@ export function useVoiceInput({
|
|
|
1069
1116
|
]);
|
|
1070
1117
|
|
|
1071
1118
|
const cancel = useCallback(() => {
|
|
1072
|
-
if (status === "recording"
|
|
1119
|
+
if (status === "recording") {
|
|
1120
|
+
const recorder = recorderRef.current;
|
|
1121
|
+
const elapsed = readNow().getTime() - recordingStartedAtRef.current;
|
|
1122
|
+
if (
|
|
1123
|
+
recorder &&
|
|
1124
|
+
recorder.state !== "inactive" &&
|
|
1125
|
+
elapsed >= VOICE_RECORDING_DISCARD_ON_CANCEL_MAX_MILLISECONDS
|
|
1126
|
+
) {
|
|
1127
|
+
// Never delete real speech on Cancel/Escape: stop, keep it locally, and
|
|
1128
|
+
// let the user transcribe or explicitly discard it.
|
|
1129
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
1130
|
+
timerRef.current = null;
|
|
1131
|
+
holdOnStopRef.current = true;
|
|
1132
|
+
setStatus("saving");
|
|
1133
|
+
recorder.stop();
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
void discard();
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (status === "requesting-permission") {
|
|
1073
1140
|
void discard();
|
|
1074
1141
|
return;
|
|
1075
1142
|
}
|
|
@@ -1093,7 +1160,7 @@ export function useVoiceInput({
|
|
|
1093
1160
|
await preserveForRetry(retained, null, generation);
|
|
1094
1161
|
});
|
|
1095
1162
|
}
|
|
1096
|
-
}, [discard, preserveForRetry, status, updateManifestBestEffort]);
|
|
1163
|
+
}, [discard, preserveForRetry, readNow, status, updateManifestBestEffort]);
|
|
1097
1164
|
|
|
1098
1165
|
useEffect(() => {
|
|
1099
1166
|
const current = manifestRef.current;
|
|
@@ -1156,6 +1223,8 @@ export function useVoiceInput({
|
|
|
1156
1223
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
1157
1224
|
if (
|
|
1158
1225
|
event.key === "Escape" &&
|
|
1226
|
+
// A dialog, menu, or popover that handled Escape owns it.
|
|
1227
|
+
!event.defaultPrevented &&
|
|
1159
1228
|
(status === "requesting-permission" ||
|
|
1160
1229
|
status === "recording" ||
|
|
1161
1230
|
status === "saving" ||
|
|
@@ -1179,6 +1248,19 @@ export function useVoiceInput({
|
|
|
1179
1248
|
automaticRetryTimerRef.current = null;
|
|
1180
1249
|
const manifest = manifestRef.current;
|
|
1181
1250
|
const ownerReady = ownerIdPromiseRef.current;
|
|
1251
|
+
const knownOwnerId = ownerIdRef.current;
|
|
1252
|
+
const ownerLease = ownerLeaseRef.current;
|
|
1253
|
+
const closingStore = storeRef.current;
|
|
1254
|
+
const ownsClosingStore = ownsStoreRef.current;
|
|
1255
|
+
// Detach synchronously: a remount of this instance (React StrictMode,
|
|
1256
|
+
// keyed re-render) must open a fresh store and owner instead of reusing
|
|
1257
|
+
// the connection this cleanup is about to close.
|
|
1258
|
+
storeRef.current = null;
|
|
1259
|
+
storePromiseRef.current = null;
|
|
1260
|
+
ownerIdRef.current = null;
|
|
1261
|
+
ownerIdPromiseRef.current = null;
|
|
1262
|
+
ownerLeaseRef.current = null;
|
|
1263
|
+
manifestRef.current = null;
|
|
1182
1264
|
stopOwnerHeartbeat();
|
|
1183
1265
|
const recorder = recorderRef.current;
|
|
1184
1266
|
let captureSettled = persistenceQueueRef.current;
|
|
@@ -1190,9 +1272,8 @@ export function useVoiceInput({
|
|
|
1190
1272
|
void captureSettled
|
|
1191
1273
|
.catch(() => undefined)
|
|
1192
1274
|
.then(async () => {
|
|
1193
|
-
const ownerId =
|
|
1194
|
-
|
|
1195
|
-
const store = storeRef.current;
|
|
1275
|
+
const ownerId = knownOwnerId ?? (ownerReady ? await ownerReady.catch(() => null) : null);
|
|
1276
|
+
const store = closingStore;
|
|
1196
1277
|
if (store && manifest && ownerId) {
|
|
1197
1278
|
const wasProcessing =
|
|
1198
1279
|
statusRef.current === "saving" ||
|
|
@@ -1221,12 +1302,11 @@ export function useVoiceInput({
|
|
|
1221
1302
|
)
|
|
1222
1303
|
.catch(() => undefined);
|
|
1223
1304
|
}
|
|
1224
|
-
if (
|
|
1305
|
+
if (ownsClosingStore) await store?.close();
|
|
1225
1306
|
})
|
|
1226
1307
|
.catch(() => undefined)
|
|
1227
1308
|
.finally(() => {
|
|
1228
|
-
|
|
1229
|
-
ownerLeaseRef.current = null;
|
|
1309
|
+
ownerLease?.release();
|
|
1230
1310
|
});
|
|
1231
1311
|
},
|
|
1232
1312
|
[clearCaptureRuntime, readNow, stopOwnerHeartbeat],
|
|
@@ -1239,6 +1319,17 @@ export function useVoiceInput({
|
|
|
1239
1319
|
stream,
|
|
1240
1320
|
recordingId,
|
|
1241
1321
|
durationSeconds,
|
|
1322
|
+
recordingStartedAt: status === "recording" ? recordingStartedAtRef.current || null : null,
|
|
1323
|
+
maxRecordingSeconds: capability
|
|
1324
|
+
? Math.min(
|
|
1325
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
1326
|
+
? capability.resumable.maxDurationSeconds
|
|
1327
|
+
: capability.maxDurationSeconds,
|
|
1328
|
+
capability.resumable && isResumableVoiceInputClient(client)
|
|
1329
|
+
? VOICE_RECORDING_RESUMABLE_CLIENT_MAX_DURATION_SECONDS
|
|
1330
|
+
: VOICE_RECORDING_CLIENT_MAX_DURATION_SECONDS,
|
|
1331
|
+
)
|
|
1332
|
+
: null,
|
|
1242
1333
|
locallySaved,
|
|
1243
1334
|
hasRecoverableRecording: manifestRef.current !== null && status !== "recording",
|
|
1244
1335
|
savedTranscript:
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { EffectiveSessionControl, SessionEvent, SessionStatus } from "@opengeni/sdk";
|
|
2
|
+
import { projectSessionRealtimeLifecycle } from "@opengeni/sdk/realtime";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
RealtimeVoiceControl,
|
|
7
|
+
useSessionRealtime,
|
|
8
|
+
type RealtimeModelOption,
|
|
9
|
+
} from "./realtime-control";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The stock conversation's live voice button (lazy chunk). The conversation
|
|
13
|
+
* mounts it only when the workspace catalog offers an available voice model;
|
|
14
|
+
* this owns choosing among those models and the call itself.
|
|
15
|
+
*/
|
|
16
|
+
export default function EmbeddedRealtimeVoice(props: {
|
|
17
|
+
/** The conversation's client, which serves the realtime routes (or a proxy of them). */
|
|
18
|
+
client: unknown;
|
|
19
|
+
workspaceId: string;
|
|
20
|
+
/** Available voice models, already filtered by the conversation. */
|
|
21
|
+
models: readonly RealtimeModelOption[];
|
|
22
|
+
sessionId: string;
|
|
23
|
+
sessionStatus: SessionStatus;
|
|
24
|
+
effectiveControl: EffectiveSessionControl;
|
|
25
|
+
events: SessionEvent[];
|
|
26
|
+
eventsReady: boolean;
|
|
27
|
+
onVoiceActiveChange?: ((active: boolean) => void) | undefined;
|
|
28
|
+
}) {
|
|
29
|
+
const lifecycle = useMemo(() => projectSessionRealtimeLifecycle(props.events), [props.events]);
|
|
30
|
+
// A call already running (this browser after reload, or another one) fixes the model.
|
|
31
|
+
const activeModel =
|
|
32
|
+
lifecycle?.state === "active" && !(Date.parse(lifecycle.leaseExpiresAt) <= Date.now())
|
|
33
|
+
? lifecycle.model
|
|
34
|
+
: null;
|
|
35
|
+
const [chosen, setChosen] = useState<string | null>(null);
|
|
36
|
+
const fallback = props.models.find((model) => model.recommended) ?? props.models[0]!;
|
|
37
|
+
const selected =
|
|
38
|
+
props.models.find((model) => model.id === (activeModel ?? chosen)) ??
|
|
39
|
+
(activeModel ? { ...fallback, id: activeModel } : fallback);
|
|
40
|
+
const realtime = useSessionRealtime({
|
|
41
|
+
client: props.client as Parameters<typeof useSessionRealtime>[0]["client"],
|
|
42
|
+
workspaceId: props.workspaceId,
|
|
43
|
+
sessionId: props.sessionId,
|
|
44
|
+
sessionStatus: props.sessionStatus,
|
|
45
|
+
effectiveControl: props.effectiveControl,
|
|
46
|
+
events: props.events,
|
|
47
|
+
eventsReady: props.eventsReady,
|
|
48
|
+
codexConnected: false,
|
|
49
|
+
model: selected.id,
|
|
50
|
+
modelAvailable: selected.available,
|
|
51
|
+
modelUnavailableReason: selected.unavailableReason,
|
|
52
|
+
});
|
|
53
|
+
const voiceActive = realtime.snapshot.mode?.state === "active";
|
|
54
|
+
const onVoiceActiveChange = props.onVoiceActiveChange;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
onVoiceActiveChange?.(voiceActive);
|
|
57
|
+
}, [onVoiceActiveChange, voiceActive]);
|
|
58
|
+
useEffect(() => () => onVoiceActiveChange?.(false), [onVoiceActiveChange]);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<RealtimeVoiceControl
|
|
62
|
+
snapshot={realtime.snapshot}
|
|
63
|
+
canStart={realtime.canStart}
|
|
64
|
+
admissionBlocker={realtime.admissionBlocker}
|
|
65
|
+
modelAvailable={selected.available}
|
|
66
|
+
menuSide="top"
|
|
67
|
+
modelMenu="split"
|
|
68
|
+
showDiagnostics={false}
|
|
69
|
+
audioRef={realtime.audioRef}
|
|
70
|
+
selectedModel={selected}
|
|
71
|
+
models={props.models}
|
|
72
|
+
onSelectModel={realtime.lifecycleActive ? undefined : setChosen}
|
|
73
|
+
onStart={realtime.start}
|
|
74
|
+
onStop={realtime.stop}
|
|
75
|
+
onRetry={realtime.retry}
|
|
76
|
+
onRetryAudibleOutput={realtime.retryAudibleOutput}
|
|
77
|
+
onSetInputMuted={realtime.setInputMuted}
|
|
78
|
+
onSetOutputMuted={realtime.setOutputMuted}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMPOSER_VOICE_INPUT_START_EVENT,
|
|
3
|
+
composerVoiceEventScope,
|
|
4
|
+
} from "../composer-voice-events";
|
|
1
5
|
import { type EffectiveSessionControl, type SessionEvent, type SessionStatus } from "@opengeni/sdk";
|
|
2
6
|
import {
|
|
3
7
|
hasStoredSessionRealtimeOwnerProof,
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
SquareIcon,
|
|
23
27
|
Volume2Icon,
|
|
24
28
|
VolumeXIcon,
|
|
29
|
+
XIcon,
|
|
25
30
|
} from "lucide-react";
|
|
26
31
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
27
32
|
import {
|
|
@@ -72,7 +77,7 @@ export type RealtimeModelOption = {
|
|
|
72
77
|
function realtimeRefusalLabel(code: string | null | undefined): string | null {
|
|
73
78
|
switch (code) {
|
|
74
79
|
case "insufficient_credits":
|
|
75
|
-
return "
|
|
80
|
+
return "Credits needed";
|
|
76
81
|
case "allowance_exhausted":
|
|
77
82
|
case "monthly_model_cost_limit":
|
|
78
83
|
return "Usage limit reached";
|
|
@@ -103,19 +108,30 @@ type RealtimeModelProvider = (typeof REALTIME_MODEL_PROVIDERS)[number];
|
|
|
103
108
|
|
|
104
109
|
const REALTIME_PROVIDER_META: Record<
|
|
105
110
|
RealtimeModelProvider,
|
|
106
|
-
{ billingClass: BillingClass; hint: string }
|
|
111
|
+
{ billingClass: BillingClass; label: string; hint: string }
|
|
107
112
|
> = {
|
|
108
|
-
|
|
113
|
+
// The wire value keeps its historical spelling; the product name is Opengeni.
|
|
114
|
+
OpenGeni: { billingClass: "opengeni_credits", label: "Opengeni", hint: "Uses Opengeni credits" },
|
|
109
115
|
"Connected Codex": {
|
|
110
116
|
billingClass: "codex_subscription",
|
|
117
|
+
label: "Connected Codex",
|
|
111
118
|
hint: "ChatGPT / Codex plan",
|
|
112
119
|
},
|
|
113
120
|
"Connected SuperGrok": {
|
|
114
121
|
billingClass: "supergrok_subscription",
|
|
122
|
+
label: "Connected SuperGrok",
|
|
115
123
|
hint: "SuperGrok / xAI plan",
|
|
116
124
|
},
|
|
117
|
-
"Your Gateway": {
|
|
125
|
+
"Your Gateway": {
|
|
126
|
+
billingClass: "byok",
|
|
127
|
+
label: "Your Gateway",
|
|
128
|
+
hint: "Billed to your AI Gateway",
|
|
129
|
+
},
|
|
118
130
|
};
|
|
131
|
+
/** Display name; the catalog's `provider` value stays the wire identifier. */
|
|
132
|
+
function realtimeProviderLabel(provider: RealtimeModelProvider): string {
|
|
133
|
+
return provider === "OpenGeni" ? "Opengeni" : provider;
|
|
134
|
+
}
|
|
119
135
|
const REALTIME_MODEL_STORAGE_PREFIX = "opengeni:realtime-model";
|
|
120
136
|
const REALTIME_MODEL_CATALOG_CACHE_TTL_MS = 60_000;
|
|
121
137
|
const REALTIME_MODEL_CATALOG_CACHE_MAX_WORKSPACES = 64;
|
|
@@ -541,14 +557,13 @@ export function SessionRealtimeControl(props: {
|
|
|
541
557
|
}
|
|
542
558
|
if (!canStart) return;
|
|
543
559
|
autostartStartedRef.current = true;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
});
|
|
560
|
+
// One automatic attempt. A failed start stays visible with its reason and
|
|
561
|
+
// the user retries explicitly; re-arming would loop on a definitive refusal.
|
|
562
|
+
const consume = () => {
|
|
563
|
+
autostartModelRef.current = null;
|
|
564
|
+
onRealtimeAutostartConsumed?.();
|
|
565
|
+
};
|
|
566
|
+
void start().then(consume, consume);
|
|
552
567
|
}, [
|
|
553
568
|
canStart,
|
|
554
569
|
lifecycleActive,
|
|
@@ -584,6 +599,18 @@ export function SessionRealtimeControl(props: {
|
|
|
584
599
|
);
|
|
585
600
|
}
|
|
586
601
|
|
|
602
|
+
/**
|
|
603
|
+
* First available model; otherwise the model whose blocker the user can act on
|
|
604
|
+
* (for example adding credits) rather than an unrelated "connect" prompt.
|
|
605
|
+
*/
|
|
606
|
+
function fallbackRealtimeModelId(models: readonly RealtimeModelOption[]): SessionRealtimeModel {
|
|
607
|
+
return (
|
|
608
|
+
models.find((model) => model.available)?.id ??
|
|
609
|
+
models.find((model) => model.unavailableCode)?.id ??
|
|
610
|
+
CODEX_LIVE_MODEL.id
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
587
614
|
export function useRealtimeModelSelection(options: {
|
|
588
615
|
client?: RealtimeControllerClient | undefined;
|
|
589
616
|
workspaceId?: string | undefined;
|
|
@@ -610,7 +637,7 @@ export function useRealtimeModelSelection(options: {
|
|
|
610
637
|
if (initialCachedCatalog.some((model) => model.id === preferred && model.available)) {
|
|
611
638
|
return preferred;
|
|
612
639
|
}
|
|
613
|
-
return initialCachedCatalog
|
|
640
|
+
return fallbackRealtimeModelId(initialCachedCatalog);
|
|
614
641
|
});
|
|
615
642
|
|
|
616
643
|
useEffect(() => {
|
|
@@ -621,7 +648,7 @@ export function useRealtimeModelSelection(options: {
|
|
|
621
648
|
setCatalog(models);
|
|
622
649
|
setSelectedModelId((current) => {
|
|
623
650
|
if (models.some((model) => model.id === current && model.available)) return current;
|
|
624
|
-
return models
|
|
651
|
+
return fallbackRealtimeModelId(models);
|
|
625
652
|
});
|
|
626
653
|
};
|
|
627
654
|
const cached = readCachedRealtimeModelCatalog(client, workspaceId);
|
|
@@ -848,6 +875,11 @@ export function RealtimeVoiceControl(props: {
|
|
|
848
875
|
props.snapshot.status !== "active" &&
|
|
849
876
|
props.snapshot.diagnostic?.recoverable === true;
|
|
850
877
|
const audioBlocked = props.snapshot.audibleOutput === "blocked" && !props.snapshot.outputMuted;
|
|
878
|
+
// A call that ended in a failure keeps its reason on screen, not only in a
|
|
879
|
+
// tooltip or the model menu, until the user starts again or changes model.
|
|
880
|
+
const startFailed = props.snapshot.status === "error" && !modeOwned;
|
|
881
|
+
const failureText =
|
|
882
|
+
status.label === "Voice unavailable" ? status.detail : `${status.label}. ${status.detail}`;
|
|
851
883
|
const mainDisabled =
|
|
852
884
|
props.snapshot.status === "stopping" ||
|
|
853
885
|
props.snapshot.status === "lost_owner" ||
|
|
@@ -859,7 +891,11 @@ export function RealtimeVoiceControl(props: {
|
|
|
859
891
|
? "Retry voice connection"
|
|
860
892
|
: modeOwned
|
|
861
893
|
? "End voice conversation"
|
|
862
|
-
:
|
|
894
|
+
: !props.canStart && status.phase === "unavailable"
|
|
895
|
+
? `${status.label}: ${status.detail}`
|
|
896
|
+
: startFailed
|
|
897
|
+
? `Try voice again with ${selectedModel.label}`
|
|
898
|
+
: `Start voice with ${selectedModel.label}`;
|
|
863
899
|
const runMainAction = audioBlocked
|
|
864
900
|
? props.onRetryAudibleOutput
|
|
865
901
|
: retryConnection
|
|
@@ -876,6 +912,20 @@ export function RealtimeVoiceControl(props: {
|
|
|
876
912
|
selectedModel.provider,
|
|
877
913
|
);
|
|
878
914
|
const [pickerDirection, setPickerDirection] = useState<1 | -1>(1);
|
|
915
|
+
const [failureDismissed, setFailureDismissed] = useState(false);
|
|
916
|
+
useEffect(() => {
|
|
917
|
+
if (!startFailed) setFailureDismissed(false);
|
|
918
|
+
}, [startFailed]);
|
|
919
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
920
|
+
useEffect(() => {
|
|
921
|
+
if (!startFailed) return;
|
|
922
|
+
// Starting dictation in this composer supersedes a stale start failure.
|
|
923
|
+
const scope = composerVoiceEventScope(rootRef.current);
|
|
924
|
+
if (!scope) return;
|
|
925
|
+
const dismiss = () => setFailureDismissed(true);
|
|
926
|
+
scope.addEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss);
|
|
927
|
+
return () => scope.removeEventListener(COMPOSER_VOICE_INPUT_START_EVENT, dismiss);
|
|
928
|
+
}, [startFailed]);
|
|
879
929
|
|
|
880
930
|
useEffect(() => {
|
|
881
931
|
if (!pickerOpen) setPickerProvider(selectedModel.provider);
|
|
@@ -883,6 +933,7 @@ export function RealtimeVoiceControl(props: {
|
|
|
883
933
|
|
|
884
934
|
return (
|
|
885
935
|
<div
|
|
936
|
+
ref={rootRef}
|
|
886
937
|
role="group"
|
|
887
938
|
aria-label="Realtime voice"
|
|
888
939
|
data-picker-side={props.menuSide ?? "top"}
|
|
@@ -964,6 +1015,20 @@ export function RealtimeVoiceControl(props: {
|
|
|
964
1015
|
</motion.div>
|
|
965
1016
|
) : null}
|
|
966
1017
|
</AnimatePresence>
|
|
1018
|
+
{startFailed && !failureDismissed ? (
|
|
1019
|
+
<button
|
|
1020
|
+
type="button"
|
|
1021
|
+
data-realtime-attention=""
|
|
1022
|
+
data-testid="realtime-failure-reason"
|
|
1023
|
+
title="Dismiss"
|
|
1024
|
+
aria-label={`Dismiss: ${failureText}`}
|
|
1025
|
+
onClick={() => setFailureDismissed(true)}
|
|
1026
|
+
className="og-realtime-failure mr-1.5 inline-flex min-w-0 items-center gap-1 rounded-og-sm text-right text-og-xs leading-tight text-og-status-failed outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/45"
|
|
1027
|
+
>
|
|
1028
|
+
<span className="og-realtime-failure-label line-clamp-2 max-w-56">{failureText}</span>
|
|
1029
|
+
<XIcon className="size-3 shrink-0 opacity-70" aria-hidden />
|
|
1030
|
+
</button>
|
|
1031
|
+
) : null}
|
|
967
1032
|
<div className="inline-flex shrink-0 items-center">
|
|
968
1033
|
<motion.button
|
|
969
1034
|
type="button"
|
|
@@ -1220,7 +1285,7 @@ export function RealtimeModelPickerMenu(props: {
|
|
|
1220
1285
|
const body = props.provider ? (
|
|
1221
1286
|
<div data-testid="realtime-model-picker-models">
|
|
1222
1287
|
<PickerBackHeader
|
|
1223
|
-
label={props.provider}
|
|
1288
|
+
label={realtimeProviderLabel(props.provider)}
|
|
1224
1289
|
icon={
|
|
1225
1290
|
<BillingClassMark
|
|
1226
1291
|
billingClass={REALTIME_PROVIDER_META[props.provider].billingClass}
|
|
@@ -1263,7 +1328,7 @@ export function RealtimeModelPickerMenu(props: {
|
|
|
1263
1328
|
return (
|
|
1264
1329
|
<PickerNavRow
|
|
1265
1330
|
key={provider}
|
|
1266
|
-
label={provider}
|
|
1331
|
+
label={realtimeProviderLabel(provider)}
|
|
1267
1332
|
hint={meta.hint}
|
|
1268
1333
|
icon={<BillingClassMark billingClass={meta.billingClass} aria-label="" />}
|
|
1269
1334
|
active={props.selectedModel.provider === provider}
|
|
@@ -98,6 +98,28 @@ async function createUnderlyingOwnerLease(): Promise<UnderlyingOwnerLease> {
|
|
|
98
98
|
return { ownerId, release: () => undefined };
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Owner ids whose document still holds its Web Lock, or null when this browser
|
|
103
|
+
* cannot answer. A lock is released the moment its tab closes or crashes, so a
|
|
104
|
+
* recording whose owner is absent here is recoverable immediately instead of
|
|
105
|
+
* after the heartbeat stale window.
|
|
106
|
+
*/
|
|
107
|
+
export async function liveVoiceRecordingOwnerIds(): Promise<ReadonlySet<string> | null> {
|
|
108
|
+
if (!hasWebLocks() || typeof navigator.locks.query !== "function") return null;
|
|
109
|
+
try {
|
|
110
|
+
const snapshot = await navigator.locks.query();
|
|
111
|
+
const live = new Set<string>();
|
|
112
|
+
for (const lock of [...(snapshot.held ?? []), ...(snapshot.pending ?? [])]) {
|
|
113
|
+
if (lock.name?.startsWith(VOICE_RECORDING_OWNER_LOCK_PREFIX)) {
|
|
114
|
+
live.add(lock.name.slice(VOICE_RECORDING_OWNER_LOCK_PREFIX.length));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return live;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
function hasWebLocks(): boolean {
|
|
102
124
|
return (
|
|
103
125
|
typeof navigator !== "undefined" &&
|