@opengeni/react 1.4.0 → 1.4.4

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 (218) hide show
  1. package/README.md +8 -0
  2. package/dist/accounts.js +2 -0
  3. package/dist/accounts.js.map +1 -1
  4. package/dist/artifacts-document.js +5 -4
  5. package/dist/artifacts-presentation.js +5 -4
  6. package/dist/artifacts-spreadsheet.js +4 -3
  7. package/dist/artifacts.js +14 -13
  8. package/dist/artifacts.js.map +1 -1
  9. package/dist/browser-viewer-5EEAS3IU.js +17 -0
  10. package/dist/chat-interactive-block-ZFHOKTMS.js +12 -0
  11. package/dist/{chunk-WEHMPOXK.js → chunk-2DDJ6YH6.js} +3 -1
  12. package/dist/{chunk-WEHMPOXK.js.map → chunk-2DDJ6YH6.js.map} +1 -1
  13. package/dist/{chunk-C5X5AJXK.js → chunk-2HVLQ26H.js} +5 -4
  14. package/dist/{chunk-C5X5AJXK.js.map → chunk-2HVLQ26H.js.map} +1 -1
  15. package/dist/{chunk-HDJWDK7W.js → chunk-2OQA3CPB.js} +300 -166
  16. package/dist/{chunk-HDJWDK7W.js.map → chunk-2OQA3CPB.js.map} +1 -1
  17. package/dist/{chunk-A4UJB2MM.js → chunk-32CGLXW2.js} +4 -3
  18. package/dist/{chunk-A4UJB2MM.js.map → chunk-32CGLXW2.js.map} +1 -1
  19. package/dist/{chunk-5CVXZA3Y.js → chunk-45NUOSXQ.js} +3 -2
  20. package/dist/{chunk-5CVXZA3Y.js.map → chunk-45NUOSXQ.js.map} +1 -1
  21. package/dist/{chunk-WUISZ3WU.js → chunk-53KJBGPZ.js} +5 -4
  22. package/dist/{chunk-WUISZ3WU.js.map → chunk-53KJBGPZ.js.map} +1 -1
  23. package/dist/{chunk-MBMPHOMA.js → chunk-55DXD22O.js} +3 -2
  24. package/dist/{chunk-MBMPHOMA.js.map → chunk-55DXD22O.js.map} +1 -1
  25. package/dist/{chunk-JT4JW7BC.js → chunk-5I4VRWBB.js} +3 -1
  26. package/dist/{chunk-JT4JW7BC.js.map → chunk-5I4VRWBB.js.map} +1 -1
  27. package/dist/{chunk-UK4TINKT.js → chunk-B27E3V37.js} +3 -1
  28. package/dist/{chunk-UK4TINKT.js.map → chunk-B27E3V37.js.map} +1 -1
  29. package/dist/{chunk-J6DBS4T3.js → chunk-BNKV5ZLF.js} +5 -4
  30. package/dist/{chunk-J6DBS4T3.js.map → chunk-BNKV5ZLF.js.map} +1 -1
  31. package/dist/{chunk-YM2DE7BQ.js → chunk-C5BF4L26.js} +13 -12
  32. package/dist/{chunk-YM2DE7BQ.js.map → chunk-C5BF4L26.js.map} +1 -1
  33. package/dist/{chunk-YBXQDQFX.js → chunk-C7CMD56T.js} +6 -5
  34. package/dist/{chunk-YBXQDQFX.js.map → chunk-C7CMD56T.js.map} +1 -1
  35. package/dist/{chunk-UGLEYEMP.js → chunk-CM7LVUKC.js} +3 -1
  36. package/dist/{chunk-UGLEYEMP.js.map → chunk-CM7LVUKC.js.map} +1 -1
  37. package/dist/{chunk-JVYQRRC4.js → chunk-CVYCLFMH.js} +3 -1
  38. package/dist/{chunk-JVYQRRC4.js.map → chunk-CVYCLFMH.js.map} +1 -1
  39. package/dist/{chunk-LJ2UTJJE.js → chunk-EAOSEIXB.js} +182 -97
  40. package/dist/chunk-EAOSEIXB.js.map +1 -0
  41. package/dist/{chunk-Y2PRMYLC.js → chunk-G3U2A6GS.js} +3 -2
  42. package/dist/{chunk-Y2PRMYLC.js.map → chunk-G3U2A6GS.js.map} +1 -1
  43. package/dist/{chunk-WZMYFJD3.js → chunk-G6YRCIYJ.js} +16 -7
  44. package/dist/{chunk-WZMYFJD3.js.map → chunk-G6YRCIYJ.js.map} +1 -1
  45. package/dist/{chunk-B3V6TG77.js → chunk-GC3PH6N7.js} +3 -1
  46. package/dist/{chunk-B3V6TG77.js.map → chunk-GC3PH6N7.js.map} +1 -1
  47. package/dist/{chunk-CJUO7J5Z.js → chunk-HXYOSSQO.js} +3 -2
  48. package/dist/{chunk-CJUO7J5Z.js.map → chunk-HXYOSSQO.js.map} +1 -1
  49. package/dist/{chunk-GYVRFZDY.js → chunk-I6OY3MWC.js} +310 -115
  50. package/dist/chunk-I6OY3MWC.js.map +1 -0
  51. package/dist/{chunk-BRM6FVZA.js → chunk-IEE7WCLN.js} +4 -3
  52. package/dist/{chunk-BRM6FVZA.js.map → chunk-IEE7WCLN.js.map} +1 -1
  53. package/dist/{chunk-DYWBUX5Q.js → chunk-IMSUVYQB.js} +6 -5
  54. package/dist/{chunk-DYWBUX5Q.js.map → chunk-IMSUVYQB.js.map} +1 -1
  55. package/dist/{chunk-LN6TCXD3.js → chunk-IUHHPMAX.js} +184 -48
  56. package/dist/chunk-IUHHPMAX.js.map +1 -0
  57. package/dist/{chunk-SGKD2OCN.js → chunk-IYQCWGU5.js} +6 -5
  58. package/dist/{chunk-SGKD2OCN.js.map → chunk-IYQCWGU5.js.map} +1 -1
  59. package/dist/{chunk-J2QWVSQY.js → chunk-JH7V627O.js} +8 -7
  60. package/dist/{chunk-J2QWVSQY.js.map → chunk-JH7V627O.js.map} +1 -1
  61. package/dist/{chunk-EESD3PY3.js → chunk-JHTIMBOL.js} +6 -5
  62. package/dist/{chunk-EESD3PY3.js.map → chunk-JHTIMBOL.js.map} +1 -1
  63. package/dist/{chunk-LNIRAOA3.js → chunk-L2J3PU7A.js} +7 -6
  64. package/dist/{chunk-LNIRAOA3.js.map → chunk-L2J3PU7A.js.map} +1 -1
  65. package/dist/{chunk-ZYRGEKWS.js → chunk-LJUMYUBO.js} +3 -1
  66. package/dist/{chunk-ZYRGEKWS.js.map → chunk-LJUMYUBO.js.map} +1 -1
  67. package/dist/{chunk-4RVYOY4O.js → chunk-LSW2OJVV.js} +4 -3
  68. package/dist/{chunk-4RVYOY4O.js.map → chunk-LSW2OJVV.js.map} +1 -1
  69. package/dist/{chunk-UTRTG6LH.js → chunk-LVS3MH5T.js} +3 -1
  70. package/dist/{chunk-UTRTG6LH.js.map → chunk-LVS3MH5T.js.map} +1 -1
  71. package/dist/chunk-M357MHH3.js +2 -0
  72. package/dist/{chunk-WRHIZYZF.js → chunk-MEQM4LI3.js} +6 -5
  73. package/dist/{chunk-WRHIZYZF.js.map → chunk-MEQM4LI3.js.map} +1 -1
  74. package/dist/chunk-NX4ADH73.js +14 -0
  75. package/dist/chunk-NX4ADH73.js.map +1 -0
  76. package/dist/{chunk-XCHWDJ22.js → chunk-OGTTUYNT.js} +3 -1
  77. package/dist/{chunk-XCHWDJ22.js.map → chunk-OGTTUYNT.js.map} +1 -1
  78. package/dist/{chunk-GIMRELBW.js → chunk-OYQQJL3J.js} +3 -1
  79. package/dist/{chunk-GIMRELBW.js.map → chunk-OYQQJL3J.js.map} +1 -1
  80. package/dist/{chunk-RSI7XYXL.js → chunk-PBCZL4JP.js} +6 -5
  81. package/dist/{chunk-RSI7XYXL.js.map → chunk-PBCZL4JP.js.map} +1 -1
  82. package/dist/{chunk-I7IWRQLN.js → chunk-PHBFJCEU.js} +17 -11
  83. package/dist/chunk-PHBFJCEU.js.map +1 -0
  84. package/dist/{chunk-3CTRFMAK.js → chunk-PKCAYHDC.js} +5 -4
  85. package/dist/{chunk-3CTRFMAK.js.map → chunk-PKCAYHDC.js.map} +1 -1
  86. package/dist/{chunk-K3HQAKQX.js → chunk-PMV53IMM.js} +3 -1
  87. package/dist/{chunk-K3HQAKQX.js.map → chunk-PMV53IMM.js.map} +1 -1
  88. package/dist/{chunk-YQEAA6LT.js → chunk-QHUE7E4N.js} +3 -2
  89. package/dist/{chunk-YQEAA6LT.js.map → chunk-QHUE7E4N.js.map} +1 -1
  90. package/dist/{chunk-OA6HFRQP.js → chunk-QMMCJGFI.js} +4 -3
  91. package/dist/{chunk-OA6HFRQP.js.map → chunk-QMMCJGFI.js.map} +1 -1
  92. package/dist/{chunk-YV4L3ILF.js → chunk-QV3DJVRZ.js} +3 -1
  93. package/dist/{chunk-YV4L3ILF.js.map → chunk-QV3DJVRZ.js.map} +1 -1
  94. package/dist/{chunk-447JLCBU.js → chunk-S2QH6EZ5.js} +3 -1
  95. package/dist/{chunk-447JLCBU.js.map → chunk-S2QH6EZ5.js.map} +1 -1
  96. package/dist/{chunk-PNY3JKBJ.js → chunk-SFH43JT2.js} +5 -4
  97. package/dist/{chunk-PNY3JKBJ.js.map → chunk-SFH43JT2.js.map} +1 -1
  98. package/dist/{chunk-AWZLIMKX.js → chunk-T46QZH3E.js} +3 -1
  99. package/dist/{chunk-AWZLIMKX.js.map → chunk-T46QZH3E.js.map} +1 -1
  100. package/dist/{chunk-5J4FOW6Q.js → chunk-TNEZNMSN.js} +3 -1
  101. package/dist/{chunk-5J4FOW6Q.js.map → chunk-TNEZNMSN.js.map} +1 -1
  102. package/dist/{chunk-22KP6LR5.js → chunk-TXS65BB2.js} +3 -1
  103. package/dist/{chunk-22KP6LR5.js.map → chunk-TXS65BB2.js.map} +1 -1
  104. package/dist/{chunk-H5X55XH7.js → chunk-UDPH5SLV.js} +4 -3
  105. package/dist/{chunk-H5X55XH7.js.map → chunk-UDPH5SLV.js.map} +1 -1
  106. package/dist/{chunk-VZTQ73XT.js → chunk-XEA5BCMA.js} +3 -1
  107. package/dist/{chunk-VZTQ73XT.js.map → chunk-XEA5BCMA.js.map} +1 -1
  108. package/dist/{chunk-5F4WR7X7.js → chunk-XS67YG5R.js} +3 -1
  109. package/dist/{chunk-5F4WR7X7.js.map → chunk-XS67YG5R.js.map} +1 -1
  110. package/dist/{chunk-TKOTKRWY.js → chunk-XTD7TZHW.js} +3 -1
  111. package/dist/{chunk-TKOTKRWY.js.map → chunk-XTD7TZHW.js.map} +1 -1
  112. package/dist/{chunk-A2PI6UMY.js → chunk-YSRBFJMQ.js} +3 -1
  113. package/dist/{chunk-A2PI6UMY.js.map → chunk-YSRBFJMQ.js.map} +1 -1
  114. package/dist/chunk-YVFRBXST.js +1420 -0
  115. package/dist/chunk-YVFRBXST.js.map +1 -0
  116. package/dist/{chunk-SCOTPE2I.js → chunk-YYLBXXTE.js} +3 -1
  117. package/dist/{chunk-SCOTPE2I.js.map → chunk-YYLBXXTE.js.map} +1 -1
  118. package/dist/{chunk-RHREZ7BO.js → chunk-ZUWF4NF7.js} +3 -1
  119. package/dist/{chunk-RHREZ7BO.js.map → chunk-ZUWF4NF7.js.map} +1 -1
  120. package/dist/clipboard.js +3 -2
  121. package/dist/components/composer-transcription-control.d.ts +4 -0
  122. package/dist/components/session-conversation.d.ts +6 -0
  123. package/dist/composer-voice-events.d.ts +8 -0
  124. package/dist/composer.js +15 -13
  125. package/dist/computer-viewer-ELNLZGHI.js +20 -0
  126. package/dist/connect.js +15 -12
  127. package/dist/connect.js.map +1 -1
  128. package/dist/desktop.js +6 -5
  129. package/dist/desktop.js.map +1 -1
  130. package/dist/diffs.js +2 -1
  131. package/dist/diffs.js.map +1 -1
  132. package/dist/editor.js +4 -3
  133. package/dist/editor.js.map +1 -1
  134. package/dist/embedded-voice-NIL4RDX3.js +69 -0
  135. package/dist/embedded-voice-NIL4RDX3.js.map +1 -0
  136. package/dist/{fleet-decision-row-YZ3ZKWMM.js → fleet-decision-row-SI63O5V4.js} +3 -1
  137. package/dist/{fleet-decision-row-YZ3ZKWMM.js.map → fleet-decision-row-SI63O5V4.js.map} +1 -1
  138. package/dist/hooks/use-client-config-flags.d.ts +6 -0
  139. package/dist/hooks/use-computer-session.d.ts +2 -0
  140. package/dist/hooks/use-realtime-voice-models.d.ts +9 -0
  141. package/dist/hooks/use-voice-input.d.ts +8 -1
  142. package/dist/index.js +47 -45
  143. package/dist/index.js.map +1 -1
  144. package/dist/interaction.js +14 -13
  145. package/dist/interaction.js.map +1 -1
  146. package/dist/{latest-question-GPYM2JNU.js → latest-question-2POFPYNB.js} +5 -4
  147. package/dist/{latest-question-GPYM2JNU.js.map → latest-question-2POFPYNB.js.map} +1 -1
  148. package/dist/machines.js +8 -7
  149. package/dist/{markdown-table-layout-QXSLK4BY.js → markdown-table-layout-DQ4DRNVT.js} +3 -1
  150. package/dist/{markdown-table-layout-QXSLK4BY.js.map → markdown-table-layout-DQ4DRNVT.js.map} +1 -1
  151. package/dist/model-policy-picker-menu-WRNSRFQC.js +15 -0
  152. package/dist/model-policy.js +2 -1
  153. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js → noto-sans-arabic-loader-XSH2KYWJ.js} +3 -1
  154. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js.map → noto-sans-arabic-loader-XSH2KYWJ.js.map} +1 -1
  155. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js → noto-sans-jp-loader.generated-MR5L6V7K.js} +3 -1
  156. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js.map → noto-sans-jp-loader.generated-MR5L6V7K.js.map} +1 -1
  157. package/dist/{platform-activity-row-627BFZ46.js → platform-activity-row-PJJVATTZ.js} +3 -1
  158. package/dist/{platform-activity-row-627BFZ46.js.map → platform-activity-row-PJJVATTZ.js.map} +1 -1
  159. package/dist/{queue-surface-implementation-ZHFI5JBP.js → queue-surface-implementation-67FS7FUI.js} +10 -9
  160. package/dist/{queue-surface-implementation-ZHFI5JBP.js.map → queue-surface-implementation-67FS7FUI.js.map} +1 -1
  161. package/dist/realtime/embedded-voice.d.ts +20 -0
  162. package/dist/realtime.js +22 -1353
  163. package/dist/realtime.js.map +1 -1
  164. package/dist/sandbox-files-34CJJNOY.js +21 -0
  165. package/dist/session-ui.js +32 -30
  166. package/dist/session-ui.js.map +1 -1
  167. package/dist/session.js +10 -9
  168. package/dist/sites.js +2 -1
  169. package/dist/terminal.js +6 -5
  170. package/dist/terminal.js.map +1 -1
  171. package/dist/{timeline-annotation-markers-GNDHTGBD.js → timeline-annotation-markers-U3G66KUD.js} +5 -4
  172. package/dist/{timeline-annotation-markers-GNDHTGBD.js.map → timeline-annotation-markers-U3G66KUD.js.map} +1 -1
  173. package/dist/{timeline-annotation-selection-DMLMM2TX.js → timeline-annotation-selection-BI7QTPTZ.js} +4 -3
  174. package/dist/{timeline-annotation-selection-DMLMM2TX.js.map → timeline-annotation-selection-BI7QTPTZ.js.map} +1 -1
  175. package/dist/{timeline-annotations-dialog-ELRDBHTS.js → timeline-annotations-dialog-FOMHSDTE.js} +7 -6
  176. package/dist/{timeline-annotations-dialog-ELRDBHTS.js.map → timeline-annotations-dialog-FOMHSDTE.js.map} +1 -1
  177. package/dist/usage.js +6 -5
  178. package/dist/usage.js.map +1 -1
  179. package/dist/voice-recording-owner.d.ts +7 -0
  180. package/package.json +3 -3
  181. package/src/components/composer-transcription-control.tsx +109 -27
  182. package/src/components/computer-viewer.tsx +139 -30
  183. package/src/components/markdown.tsx +7 -2
  184. package/src/components/message-timeline.tsx +10 -1
  185. package/src/components/session-conversation.tsx +44 -0
  186. package/src/components/session-list.tsx +1 -1
  187. package/src/components/session-proxy-scope.tsx +26 -5
  188. package/src/composer-voice-events.ts +12 -0
  189. package/src/connection-discovery.tsx +4 -1
  190. package/src/hooks/use-browser-session.ts +445 -168
  191. package/src/hooks/use-client-config-flags.ts +8 -0
  192. package/src/hooks/use-computer-session.ts +334 -98
  193. package/src/hooks/use-goal.ts +12 -1
  194. package/src/hooks/use-realtime-voice-models.ts +67 -0
  195. package/src/hooks/use-voice-input.ts +109 -18
  196. package/src/realtime/embedded-voice.tsx +81 -0
  197. package/src/realtime/realtime-control.tsx +82 -17
  198. package/src/voice-recording-owner.ts +22 -0
  199. package/styles/compiled.css +77 -18
  200. package/styles/index.css +49 -0
  201. package/styles/responsive.css +61 -4
  202. package/styles/session-capability.css +1 -1
  203. package/dist/browser-viewer-SHYKQRNX.js +0 -16
  204. package/dist/chat-interactive-block-UGBTNB3L.js +0 -11
  205. package/dist/chunk-GYVRFZDY.js.map +0 -1
  206. package/dist/chunk-I7IWRQLN.js.map +0 -1
  207. package/dist/chunk-IWIDV7SB.js +0 -1
  208. package/dist/chunk-LJ2UTJJE.js.map +0 -1
  209. package/dist/chunk-LN6TCXD3.js.map +0 -1
  210. package/dist/computer-viewer-6SRGWRUX.js +0 -19
  211. package/dist/model-policy-picker-menu-DXHUL73H.js +0 -14
  212. package/dist/sandbox-files-GORZOUUJ.js +0 -20
  213. /package/dist/{browser-viewer-SHYKQRNX.js.map → browser-viewer-5EEAS3IU.js.map} +0 -0
  214. /package/dist/{chat-interactive-block-UGBTNB3L.js.map → chat-interactive-block-ZFHOKTMS.js.map} +0 -0
  215. /package/dist/{chunk-IWIDV7SB.js.map → chunk-M357MHH3.js.map} +0 -0
  216. /package/dist/{computer-viewer-6SRGWRUX.js.map → computer-viewer-ELNLZGHI.js.map} +0 -0
  217. /package/dist/{model-policy-picker-menu-DXHUL73H.js.map → model-policy-picker-menu-WRNSRFQC.js.map} +0 -0
  218. /package/dist/{sandbox-files-GORZOUUJ.js.map → sandbox-files-34CJJNOY.js.map} +0 -0
@@ -84,6 +84,9 @@ export function useGoal(
84
84
  const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
85
85
  const generation = useRef(0);
86
86
  const targetKeyRef = useRef<string | null>(null);
87
+ // True after the server answered 404. Only a goal event can create a goal,
88
+ // so turn/session traffic must not refetch (and 404) while none exists.
89
+ const goalAbsentRef = useRef(false);
87
90
  const loadAbort = useRef<AbortController | null>(null);
88
91
 
89
92
  const retireLoads = useCallback(() => {
@@ -105,6 +108,7 @@ export function useGoal(
105
108
  signal: controller.signal,
106
109
  });
107
110
  if (ticket === generation.current) {
111
+ goalAbsentRef.current = false;
108
112
  setGoal(fetched);
109
113
  setError(null);
110
114
  setLoading(false);
@@ -115,6 +119,7 @@ export function useGoal(
115
119
  }
116
120
  if (cause instanceof OpenGeniApiError && cause.status === 404) {
117
121
  // No goal is a normal state, not an error.
122
+ goalAbsentRef.current = true;
118
123
  setGoal(null);
119
124
  setError(null);
120
125
  } else {
@@ -130,6 +135,7 @@ export function useGoal(
130
135
  const targetKey = `${workspaceId} ${sessionId ?? ""}`;
131
136
  if (targetKeyRef.current !== targetKey) {
132
137
  targetKeyRef.current = targetKey;
138
+ goalAbsentRef.current = false;
133
139
  setGoal(null);
134
140
  setError(null);
135
141
  }
@@ -181,7 +187,12 @@ export function useGoal(
181
187
  ]);
182
188
 
183
189
  const scheduleRefresh = useDebouncedCallback(() => void load());
184
- useSessionEventTrigger(client, workspaceId, sessionId, isGoalRefreshEvent, scheduleRefresh, {
190
+ const isRefreshEvent = useCallback(
191
+ (event: SessionEvent) =>
192
+ goalAbsentRef.current ? isGoalEvent(event) : isGoalRefreshEvent(event),
193
+ [],
194
+ );
195
+ useSessionEventTrigger(client, workspaceId, sessionId, isRefreshEvent, scheduleRefresh, {
185
196
  enabled,
186
197
  ...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
187
198
  });
@@ -0,0 +1,67 @@
1
+ import type { WorkspaceRealtimeModelCatalogResponse } from "@opengeni/sdk";
2
+ import { useEffect, useState } from "react";
3
+
4
+ type CatalogClient = {
5
+ getWorkspaceRealtimeModelCatalog?: (
6
+ workspaceId: string,
7
+ options?: { signal?: AbortSignal | undefined },
8
+ ) => Promise<WorkspaceRealtimeModelCatalogResponse>;
9
+ };
10
+
11
+ type AvailableVoiceModels = WorkspaceRealtimeModelCatalogResponse["models"];
12
+
13
+ const CACHE_TTL_MS = 60_000;
14
+ const cache = new WeakMap<
15
+ object,
16
+ Map<string, { models: Promise<AvailableVoiceModels>; expiresAt: number }>
17
+ >();
18
+
19
+ function load(client: CatalogClient, workspaceId: string): Promise<AvailableVoiceModels> {
20
+ let byWorkspace = cache.get(client);
21
+ if (!byWorkspace) {
22
+ byWorkspace = new Map();
23
+ cache.set(client, byWorkspace);
24
+ }
25
+ const hit = byWorkspace.get(workspaceId);
26
+ if (hit && hit.expiresAt > Date.now()) return hit.models;
27
+ // A throwing structural client (one without voice) just means no voice.
28
+ const models = Promise.resolve()
29
+ .then(() => client.getWorkspaceRealtimeModelCatalog!(workspaceId))
30
+ .then((catalog) => catalog.models.filter((model) => model.available));
31
+ const entry = { models, expiresAt: Date.now() + CACHE_TTL_MS };
32
+ byWorkspace.set(workspaceId, entry);
33
+ models.catch(() => {
34
+ if (byWorkspace.get(workspaceId) === entry) byWorkspace.delete(workspaceId);
35
+ });
36
+ return models;
37
+ }
38
+
39
+ /**
40
+ * Voice models this user can start right now in the workspace, or an empty
41
+ * list while unknown, disabled, unsupported (older API or proxy), or failed.
42
+ * Availability is advisory; Opengeni still admits and meters every call.
43
+ */
44
+ export function useRealtimeVoiceModels(
45
+ client: unknown,
46
+ workspaceId: string,
47
+ enabled: boolean,
48
+ ): AvailableVoiceModels {
49
+ const [models, setModels] = useState<AvailableVoiceModels>([]);
50
+ useEffect(() => {
51
+ setModels([]);
52
+ const catalogClient = client as CatalogClient;
53
+ if (!enabled || !workspaceId) return;
54
+ if (typeof catalogClient.getWorkspaceRealtimeModelCatalog !== "function") return;
55
+ let live = true;
56
+ load(catalogClient, workspaceId).then(
57
+ (available) => {
58
+ if (live) setModels(available);
59
+ },
60
+ () => undefined,
61
+ );
62
+ return () => {
63
+ live = false;
64
+ };
65
+ }, [client, workspaceId, enabled]);
66
+ return models;
67
+ }
@@ -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
- /** Cancel active work. A stopped/transcribing recording remains recoverable. */
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 staleBefore = new Date(
337
+ const heartbeatStaleBefore = new Date(
325
338
  readNow().getTime() - VOICE_RECORDING_OWNER_STALE_MILLISECONDS,
326
339
  ).toISOString();
327
- await store.cleanupHandedOffManifests({ ownerId, staleBefore }).catch(() => undefined);
340
+ await store
341
+ .cleanupHandedOffManifests({ ownerId, staleBefore: heartbeatStaleBefore })
342
+ .catch(() => undefined);
328
343
  if (!active()) return;
329
- const manifests = await store.listRecoverableManifests(workspaceId, {
330
- ownerId,
331
- staleBefore,
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
- claimed.finalizationState === "transcript-ready" && claimed.transcriptText !== null
388
+ transcriptReady
358
389
  ? "transcript-ready"
359
390
  : claimed.recoveryMode === "automatic"
360
391
  ? "retrying"
361
392
  : "recovered",
362
393
  );
363
- setError(null);
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("handoff_uncertain");
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" || status === "requesting-permission") {
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
- ownerIdRef.current ?? (ownerReady ? await ownerReady.catch(() => null) : null);
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 (ownsStoreRef.current) await store?.close();
1305
+ if (ownsClosingStore) await store?.close();
1225
1306
  })
1226
1307
  .catch(() => undefined)
1227
1308
  .finally(() => {
1228
- ownerLeaseRef.current?.release();
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 "Out of credits";
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
- OpenGeni: { billingClass: "opengeni_credits", hint: "Will use credits" },
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": { billingClass: "byok", hint: "Billed to your AI 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
- void start()
545
- .then(() => {
546
- autostartModelRef.current = null;
547
- onRealtimeAutostartConsumed?.();
548
- })
549
- .catch(() => {
550
- autostartStartedRef.current = false;
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.find((model) => model.available)?.id ?? CODEX_LIVE_MODEL.id;
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.find((model) => model.available)?.id ?? CODEX_LIVE_MODEL.id;
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
- : `Start voice with ${selectedModel.label}`;
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" &&