@opengeni/react 1.3.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.
Files changed (240) hide show
  1. package/README.md +29 -1
  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-WGJ43YGI.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-A4UJB2MM.js → chunk-32CGLXW2.js} +4 -3
  16. package/dist/{chunk-A4UJB2MM.js.map → chunk-32CGLXW2.js.map} +1 -1
  17. package/dist/{chunk-5CVXZA3Y.js → chunk-45NUOSXQ.js} +3 -2
  18. package/dist/{chunk-5CVXZA3Y.js.map → chunk-45NUOSXQ.js.map} +1 -1
  19. package/dist/{chunk-WUISZ3WU.js → chunk-53KJBGPZ.js} +5 -4
  20. package/dist/{chunk-WUISZ3WU.js.map → chunk-53KJBGPZ.js.map} +1 -1
  21. package/dist/{chunk-TMYGL6X5.js → chunk-55DXD22O.js} +73 -7
  22. package/dist/chunk-55DXD22O.js.map +1 -0
  23. package/dist/{chunk-JT4JW7BC.js → chunk-5I4VRWBB.js} +3 -1
  24. package/dist/{chunk-JT4JW7BC.js.map → chunk-5I4VRWBB.js.map} +1 -1
  25. package/dist/{chunk-UK4TINKT.js → chunk-B27E3V37.js} +3 -1
  26. package/dist/{chunk-UK4TINKT.js.map → chunk-B27E3V37.js.map} +1 -1
  27. package/dist/{chunk-J6DBS4T3.js → chunk-BNKV5ZLF.js} +5 -4
  28. package/dist/{chunk-J6DBS4T3.js.map → chunk-BNKV5ZLF.js.map} +1 -1
  29. package/dist/{chunk-YM2DE7BQ.js → chunk-C5BF4L26.js} +13 -12
  30. package/dist/{chunk-YM2DE7BQ.js.map → chunk-C5BF4L26.js.map} +1 -1
  31. package/dist/{chunk-YBXQDQFX.js → chunk-C7CMD56T.js} +6 -5
  32. package/dist/{chunk-YBXQDQFX.js.map → chunk-C7CMD56T.js.map} +1 -1
  33. package/dist/{chunk-UGLEYEMP.js → chunk-CM7LVUKC.js} +3 -1
  34. package/dist/{chunk-UGLEYEMP.js.map → chunk-CM7LVUKC.js.map} +1 -1
  35. package/dist/{chunk-PGSX7UCP.js → chunk-CREMJMVN.js} +1502 -833
  36. package/dist/chunk-CREMJMVN.js.map +1 -0
  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-Y2PRMYLC.js → chunk-G3U2A6GS.js} +3 -2
  40. package/dist/{chunk-Y2PRMYLC.js.map → chunk-G3U2A6GS.js.map} +1 -1
  41. package/dist/{chunk-WWEUO2PL.js → chunk-G6YRCIYJ.js} +202 -14
  42. package/dist/chunk-G6YRCIYJ.js.map +1 -0
  43. package/dist/{chunk-B3V6TG77.js → chunk-GC3PH6N7.js} +3 -1
  44. package/dist/{chunk-B3V6TG77.js.map → chunk-GC3PH6N7.js.map} +1 -1
  45. package/dist/{chunk-FZCP527Y.js → chunk-HXYOSSQO.js} +3 -2
  46. package/dist/{chunk-FZCP527Y.js.map → chunk-HXYOSSQO.js.map} +1 -1
  47. package/dist/{chunk-BRM6FVZA.js → chunk-IEE7WCLN.js} +4 -3
  48. package/dist/{chunk-BRM6FVZA.js.map → chunk-IEE7WCLN.js.map} +1 -1
  49. package/dist/{chunk-DYWBUX5Q.js → chunk-IMSUVYQB.js} +6 -5
  50. package/dist/{chunk-DYWBUX5Q.js.map → chunk-IMSUVYQB.js.map} +1 -1
  51. package/dist/{chunk-CY7WK5FD.js → chunk-IUHHPMAX.js} +203 -46
  52. package/dist/chunk-IUHHPMAX.js.map +1 -0
  53. package/dist/{chunk-SGKD2OCN.js → chunk-IYQCWGU5.js} +6 -5
  54. package/dist/{chunk-SGKD2OCN.js.map → chunk-IYQCWGU5.js.map} +1 -1
  55. package/dist/{chunk-J2QWVSQY.js → chunk-JH7V627O.js} +8 -7
  56. package/dist/{chunk-J2QWVSQY.js.map → chunk-JH7V627O.js.map} +1 -1
  57. package/dist/{chunk-EESD3PY3.js → chunk-JHTIMBOL.js} +6 -5
  58. package/dist/{chunk-EESD3PY3.js.map → chunk-JHTIMBOL.js.map} +1 -1
  59. package/dist/{chunk-LNIRAOA3.js → chunk-L2J3PU7A.js} +7 -6
  60. package/dist/{chunk-LNIRAOA3.js.map → chunk-L2J3PU7A.js.map} +1 -1
  61. package/dist/{chunk-ZYRGEKWS.js → chunk-LJUMYUBO.js} +3 -1
  62. package/dist/{chunk-ZYRGEKWS.js.map → chunk-LJUMYUBO.js.map} +1 -1
  63. package/dist/{chunk-4RVYOY4O.js → chunk-LSW2OJVV.js} +4 -3
  64. package/dist/{chunk-4RVYOY4O.js.map → chunk-LSW2OJVV.js.map} +1 -1
  65. package/dist/{chunk-UTRTG6LH.js → chunk-LVS3MH5T.js} +3 -1
  66. package/dist/{chunk-UTRTG6LH.js.map → chunk-LVS3MH5T.js.map} +1 -1
  67. package/dist/chunk-M357MHH3.js +2 -0
  68. package/dist/{chunk-GYVRFZDY.js → chunk-MDIXFKA3.js} +9 -8
  69. package/dist/{chunk-GYVRFZDY.js.map → chunk-MDIXFKA3.js.map} +1 -1
  70. package/dist/{chunk-WRHIZYZF.js → chunk-MEQM4LI3.js} +6 -5
  71. package/dist/{chunk-WRHIZYZF.js.map → chunk-MEQM4LI3.js.map} +1 -1
  72. package/dist/chunk-NX4ADH73.js +14 -0
  73. package/dist/chunk-NX4ADH73.js.map +1 -0
  74. package/dist/{chunk-XCHWDJ22.js → chunk-OGTTUYNT.js} +3 -1
  75. package/dist/{chunk-XCHWDJ22.js.map → chunk-OGTTUYNT.js.map} +1 -1
  76. package/dist/{chunk-GIMRELBW.js → chunk-OYQQJL3J.js} +3 -1
  77. package/dist/{chunk-GIMRELBW.js.map → chunk-OYQQJL3J.js.map} +1 -1
  78. package/dist/{chunk-RSI7XYXL.js → chunk-PBCZL4JP.js} +6 -5
  79. package/dist/{chunk-RSI7XYXL.js.map → chunk-PBCZL4JP.js.map} +1 -1
  80. package/dist/{chunk-I7IWRQLN.js → chunk-PHBFJCEU.js} +17 -11
  81. package/dist/chunk-PHBFJCEU.js.map +1 -0
  82. package/dist/{chunk-3CTRFMAK.js → chunk-PKCAYHDC.js} +5 -4
  83. package/dist/{chunk-3CTRFMAK.js.map → chunk-PKCAYHDC.js.map} +1 -1
  84. package/dist/{chunk-K3HQAKQX.js → chunk-PMV53IMM.js} +3 -1
  85. package/dist/{chunk-K3HQAKQX.js.map → chunk-PMV53IMM.js.map} +1 -1
  86. package/dist/{chunk-YQEAA6LT.js → chunk-QHUE7E4N.js} +3 -2
  87. package/dist/{chunk-YQEAA6LT.js.map → chunk-QHUE7E4N.js.map} +1 -1
  88. package/dist/{chunk-RC6BZ44Q.js → chunk-QMMCJGFI.js} +4 -3
  89. package/dist/{chunk-RC6BZ44Q.js.map → chunk-QMMCJGFI.js.map} +1 -1
  90. package/dist/{chunk-YV4L3ILF.js → chunk-QV3DJVRZ.js} +3 -1
  91. package/dist/{chunk-YV4L3ILF.js.map → chunk-QV3DJVRZ.js.map} +1 -1
  92. package/dist/{chunk-447JLCBU.js → chunk-S2QH6EZ5.js} +3 -1
  93. package/dist/{chunk-447JLCBU.js.map → chunk-S2QH6EZ5.js.map} +1 -1
  94. package/dist/{chunk-PNY3JKBJ.js → chunk-SFH43JT2.js} +5 -4
  95. package/dist/{chunk-PNY3JKBJ.js.map → chunk-SFH43JT2.js.map} +1 -1
  96. package/dist/{chunk-ERGYJIBB.js → chunk-T46QZH3E.js} +4 -2
  97. package/dist/{chunk-ERGYJIBB.js.map → chunk-T46QZH3E.js.map} +1 -1
  98. package/dist/{chunk-5J4FOW6Q.js → chunk-TNEZNMSN.js} +3 -1
  99. package/dist/{chunk-5J4FOW6Q.js.map → chunk-TNEZNMSN.js.map} +1 -1
  100. package/dist/{chunk-22KP6LR5.js → chunk-TXS65BB2.js} +3 -1
  101. package/dist/{chunk-22KP6LR5.js.map → chunk-TXS65BB2.js.map} +1 -1
  102. package/dist/{chunk-742CZ3L3.js → chunk-UDPH5SLV.js} +19 -202
  103. package/dist/chunk-UDPH5SLV.js.map +1 -0
  104. package/dist/{chunk-HDJWDK7W.js → chunk-VCSERYXH.js} +10 -9
  105. package/dist/{chunk-HDJWDK7W.js.map → chunk-VCSERYXH.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 +7 -0
  122. package/dist/components/open-geni-chat.d.ts +6 -2
  123. package/dist/components/session-conversation.d.ts +31 -19
  124. package/dist/components/session-list.d.ts +4 -1
  125. package/dist/components/tool-action-review.d.ts +29 -2
  126. package/dist/components/tool-review-history.d.ts +8 -4
  127. package/dist/composer-voice-events.d.ts +8 -0
  128. package/dist/composer.js +15 -13
  129. package/dist/computer-viewer-46UIN7F3.js +20 -0
  130. package/dist/connect.js +15 -12
  131. package/dist/connect.js.map +1 -1
  132. package/dist/desktop.js +6 -5
  133. package/dist/desktop.js.map +1 -1
  134. package/dist/diffs.js +2 -1
  135. package/dist/diffs.js.map +1 -1
  136. package/dist/editor.js +4 -3
  137. package/dist/editor.js.map +1 -1
  138. package/dist/embedded-voice-NIL4RDX3.js +69 -0
  139. package/dist/embedded-voice-NIL4RDX3.js.map +1 -0
  140. package/dist/{fleet-decision-row-YZ3ZKWMM.js → fleet-decision-row-SI63O5V4.js} +3 -1
  141. package/dist/{fleet-decision-row-YZ3ZKWMM.js.map → fleet-decision-row-SI63O5V4.js.map} +1 -1
  142. package/dist/hooks/use-client-config-flags.d.ts +43 -0
  143. package/dist/hooks/use-realtime-voice-models.d.ts +9 -0
  144. package/dist/hooks/use-voice-input.d.ts +8 -1
  145. package/dist/index.d.ts +3 -3
  146. package/dist/index.js +60 -48
  147. package/dist/index.js.map +1 -1
  148. package/dist/interaction.js +14 -13
  149. package/dist/interaction.js.map +1 -1
  150. package/dist/{latest-question-YTOBHCWR.js → latest-question-2POFPYNB.js} +5 -4
  151. package/dist/{latest-question-YTOBHCWR.js.map → latest-question-2POFPYNB.js.map} +1 -1
  152. package/dist/machines.js +8 -7
  153. package/dist/{markdown-table-layout-QXSLK4BY.js → markdown-table-layout-DQ4DRNVT.js} +3 -1
  154. package/dist/{markdown-table-layout-QXSLK4BY.js.map → markdown-table-layout-DQ4DRNVT.js.map} +1 -1
  155. package/dist/model-policy-picker-menu-WRNSRFQC.js +15 -0
  156. package/dist/model-policy.js +2 -1
  157. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js → noto-sans-arabic-loader-XSH2KYWJ.js} +3 -1
  158. package/dist/{noto-sans-arabic-loader-IA2T4X2A.js.map → noto-sans-arabic-loader-XSH2KYWJ.js.map} +1 -1
  159. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js → noto-sans-jp-loader.generated-MR5L6V7K.js} +3 -1
  160. package/dist/{noto-sans-jp-loader.generated-HFGLYBR3.js.map → noto-sans-jp-loader.generated-MR5L6V7K.js.map} +1 -1
  161. package/dist/{platform-activity-row-627BFZ46.js → platform-activity-row-PJJVATTZ.js} +3 -1
  162. package/dist/{platform-activity-row-627BFZ46.js.map → platform-activity-row-PJJVATTZ.js.map} +1 -1
  163. package/dist/{queue-surface-implementation-ZHFI5JBP.js → queue-surface-implementation-67FS7FUI.js} +10 -9
  164. package/dist/{queue-surface-implementation-ZHFI5JBP.js.map → queue-surface-implementation-67FS7FUI.js.map} +1 -1
  165. package/dist/realtime/embedded-voice.d.ts +20 -0
  166. package/dist/realtime/realtime-control.d.ts +2 -0
  167. package/dist/realtime.js +22 -1332
  168. package/dist/realtime.js.map +1 -1
  169. package/dist/sandbox-files-34CJJNOY.js +21 -0
  170. package/dist/session-capability-policy.d.ts +20 -1
  171. package/dist/session-ui.js +32 -30
  172. package/dist/session-ui.js.map +1 -1
  173. package/dist/session.js +12 -11
  174. package/dist/sites.js +2 -1
  175. package/dist/terminal.js +6 -5
  176. package/dist/terminal.js.map +1 -1
  177. package/dist/timeline/index.d.ts +2 -0
  178. package/dist/timeline/retained-loaders.d.ts +27 -0
  179. package/dist/{timeline-annotation-markers-GNDHTGBD.js → timeline-annotation-markers-U3G66KUD.js} +5 -4
  180. package/dist/{timeline-annotation-markers-GNDHTGBD.js.map → timeline-annotation-markers-U3G66KUD.js.map} +1 -1
  181. package/dist/{timeline-annotation-selection-DMLMM2TX.js → timeline-annotation-selection-BI7QTPTZ.js} +4 -3
  182. package/dist/{timeline-annotation-selection-DMLMM2TX.js.map → timeline-annotation-selection-BI7QTPTZ.js.map} +1 -1
  183. package/dist/{timeline-annotations-dialog-ELRDBHTS.js → timeline-annotations-dialog-FOMHSDTE.js} +7 -6
  184. package/dist/{timeline-annotations-dialog-ELRDBHTS.js.map → timeline-annotations-dialog-FOMHSDTE.js.map} +1 -1
  185. package/dist/usage.js +6 -5
  186. package/dist/usage.js.map +1 -1
  187. package/dist/voice-recording-owner.d.ts +7 -0
  188. package/package.json +3 -3
  189. package/src/components/approval-surface.tsx +190 -79
  190. package/src/components/artifacts/session-artifact-viewer.tsx +4 -2
  191. package/src/components/composer-transcription-control.tsx +121 -25
  192. package/src/components/composer.tsx +1 -0
  193. package/src/components/markdown.tsx +7 -2
  194. package/src/components/message-timeline.tsx +10 -1
  195. package/src/components/open-geni-chat.tsx +53 -13
  196. package/src/components/session-conversation.tsx +273 -63
  197. package/src/components/session-list.tsx +9 -2
  198. package/src/components/session-proxy-scope.tsx +26 -5
  199. package/src/components/tool-action-review.tsx +502 -239
  200. package/src/components/tool-review-history.tsx +18 -43
  201. package/src/composer-voice-events.ts +12 -0
  202. package/src/connection-discovery.tsx +4 -1
  203. package/src/hooks/use-client-config-flags.ts +110 -0
  204. package/src/hooks/use-goal.ts +12 -1
  205. package/src/hooks/use-realtime-voice-models.ts +67 -0
  206. package/src/hooks/use-session-events.ts +19 -16
  207. package/src/hooks/use-transcription.ts +6 -0
  208. package/src/hooks/use-voice-input.ts +115 -18
  209. package/src/index.ts +9 -0
  210. package/src/realtime/embedded-voice.tsx +81 -0
  211. package/src/realtime/realtime-control.tsx +109 -17
  212. package/src/session-capability-policy.ts +119 -6
  213. package/src/timeline/index.ts +10 -0
  214. package/src/timeline/retained-loaders.ts +86 -0
  215. package/src/timeline/tool-display-name.ts +5 -1
  216. package/src/timeline/tool-renderers.tsx +143 -10
  217. package/src/voice-recording-owner.ts +22 -0
  218. package/styles/compiled.css +135 -37
  219. package/styles/index.css +49 -0
  220. package/styles/responsive.css +61 -4
  221. package/styles/session-capability.css +1 -1
  222. package/styles/tokens.css +1 -1
  223. package/dist/browser-viewer-SHYKQRNX.js +0 -16
  224. package/dist/chat-interactive-block-UGBTNB3L.js +0 -11
  225. package/dist/chunk-742CZ3L3.js.map +0 -1
  226. package/dist/chunk-CY7WK5FD.js.map +0 -1
  227. package/dist/chunk-I7IWRQLN.js.map +0 -1
  228. package/dist/chunk-IWIDV7SB.js +0 -1
  229. package/dist/chunk-PGSX7UCP.js.map +0 -1
  230. package/dist/chunk-TMYGL6X5.js.map +0 -1
  231. package/dist/chunk-WWEUO2PL.js.map +0 -1
  232. package/dist/computer-viewer-6SRGWRUX.js +0 -19
  233. package/dist/model-policy-picker-menu-DXHUL73H.js +0 -14
  234. package/dist/sandbox-files-GORZOUUJ.js +0 -20
  235. /package/dist/{browser-viewer-SHYKQRNX.js.map → browser-viewer-WGJ43YGI.js.map} +0 -0
  236. /package/dist/{chat-interactive-block-UGBTNB3L.js.map → chat-interactive-block-ZFHOKTMS.js.map} +0 -0
  237. /package/dist/{chunk-IWIDV7SB.js.map → chunk-M357MHH3.js.map} +0 -0
  238. /package/dist/{computer-viewer-6SRGWRUX.js.map → computer-viewer-46UIN7F3.js.map} +0 -0
  239. /package/dist/{model-policy-picker-menu-DXHUL73H.js.map → model-policy-picker-menu-WRNSRFQC.js.map} +0 -0
  240. /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
- /** 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:
@@ -1503,6 +1594,12 @@ function errorCode(error: unknown): string {
1503
1594
  }
1504
1595
 
1505
1596
  function isRetryableVoiceInputError(error: unknown): boolean {
1597
+ if (
1598
+ ["insufficient_credits", "allowance_exhausted", "monthly_model_cost_limit"].includes(
1599
+ errorCode(error),
1600
+ )
1601
+ )
1602
+ return false;
1506
1603
  if (error instanceof OpenGeniApiError) {
1507
1604
  return error.retryable && error.status !== 409;
1508
1605
  }
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export {
16
16
  export {
17
17
  attachSessionCapability,
18
18
  completeSessionCapabilityOAuth,
19
+ prepareSessionCapabilityAccess,
20
+ applySessionCapabilityAccess,
21
+ type SessionCapabilityAccessPlan,
19
22
  } from "./session-capability-policy";
20
23
  export { retainedImageId } from "./components/markdown";
21
24
  // oxlint-disable-next-line typescript/triple-slash-reference -- package consumers must load the ambient type for this optional untyped peer without emitting a runtime import.
@@ -388,14 +391,20 @@ export type {
388
391
  // Tool-renderer registry + the per-tool renderers (the timeline's extension API)
389
392
  export {
390
393
  createDefaultToolRegistry,
394
+ createSessionRetainedScreenshotLoader,
391
395
  createToolRegistry,
396
+ createWorkspaceRetainedArtifactLoader,
397
+ createWorkspaceRetainedVideoLoader,
392
398
  defaultToolRegistry,
393
399
  rawTypeOf,
394
400
  } from "./timeline";
395
401
  export type {
396
402
  CreateToolRegistryOptions,
397
403
  RetainedArtifactLoader,
404
+ RetainedArtifactLoaderClient,
398
405
  RetainedScreenshotLoader,
406
+ RetainedScreenshotLoaderClient,
407
+ RetainedVideoLoaderClient,
399
408
  VideoArtifactPlaybackLoader,
400
409
  ToolRegistry,
401
410
  ToolRegistryEntry,
@@ -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 {
@@ -63,9 +68,26 @@ export type RealtimeModelOption = {
63
68
  description: string;
64
69
  available: boolean;
65
70
  unavailableReason: string | null;
71
+ /** Machine-readable reason, e.g. `insufficient_credits` (same codes as voice input). */
72
+ unavailableCode?: string | null | undefined;
66
73
  recommended: boolean;
67
74
  };
68
75
 
76
+ /** Short status label for a credit/availability refusal (shared with the model menu). */
77
+ function realtimeRefusalLabel(code: string | null | undefined): string | null {
78
+ switch (code) {
79
+ case "insufficient_credits":
80
+ return "Credits needed";
81
+ case "allowance_exhausted":
82
+ case "monthly_model_cost_limit":
83
+ return "Usage limit reached";
84
+ case "realtime_voice_unavailable":
85
+ return "Voice unavailable";
86
+ default:
87
+ return null;
88
+ }
89
+ }
90
+
69
91
  const CODEX_LIVE_MODEL: RealtimeModelOption = {
70
92
  id: "gpt-live-1-boulder-alpha",
71
93
  label: "Codex Live",
@@ -86,19 +108,30 @@ type RealtimeModelProvider = (typeof REALTIME_MODEL_PROVIDERS)[number];
86
108
 
87
109
  const REALTIME_PROVIDER_META: Record<
88
110
  RealtimeModelProvider,
89
- { billingClass: BillingClass; hint: string }
111
+ { billingClass: BillingClass; label: string; hint: string }
90
112
  > = {
91
- 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" },
92
115
  "Connected Codex": {
93
116
  billingClass: "codex_subscription",
117
+ label: "Connected Codex",
94
118
  hint: "ChatGPT / Codex plan",
95
119
  },
96
120
  "Connected SuperGrok": {
97
121
  billingClass: "supergrok_subscription",
122
+ label: "Connected SuperGrok",
98
123
  hint: "SuperGrok / xAI plan",
99
124
  },
100
- "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
+ },
101
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
+ }
102
135
  const REALTIME_MODEL_STORAGE_PREFIX = "opengeni:realtime-model";
103
136
  const REALTIME_MODEL_CATALOG_CACHE_TTL_MS = 60_000;
104
137
  const REALTIME_MODEL_CATALOG_CACHE_MAX_WORKSPACES = 64;
@@ -524,14 +557,13 @@ export function SessionRealtimeControl(props: {
524
557
  }
525
558
  if (!canStart) return;
526
559
  autostartStartedRef.current = true;
527
- void start()
528
- .then(() => {
529
- autostartModelRef.current = null;
530
- onRealtimeAutostartConsumed?.();
531
- })
532
- .catch(() => {
533
- autostartStartedRef.current = false;
534
- });
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);
535
567
  }, [
536
568
  canStart,
537
569
  lifecycleActive,
@@ -567,6 +599,18 @@ export function SessionRealtimeControl(props: {
567
599
  );
568
600
  }
569
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
+
570
614
  export function useRealtimeModelSelection(options: {
571
615
  client?: RealtimeControllerClient | undefined;
572
616
  workspaceId?: string | undefined;
@@ -593,7 +637,7 @@ export function useRealtimeModelSelection(options: {
593
637
  if (initialCachedCatalog.some((model) => model.id === preferred && model.available)) {
594
638
  return preferred;
595
639
  }
596
- return initialCachedCatalog.find((model) => model.available)?.id ?? CODEX_LIVE_MODEL.id;
640
+ return fallbackRealtimeModelId(initialCachedCatalog);
597
641
  });
598
642
 
599
643
  useEffect(() => {
@@ -604,7 +648,7 @@ export function useRealtimeModelSelection(options: {
604
648
  setCatalog(models);
605
649
  setSelectedModelId((current) => {
606
650
  if (models.some((model) => model.id === current && model.available)) return current;
607
- return models.find((model) => model.available)?.id ?? CODEX_LIVE_MODEL.id;
651
+ return fallbackRealtimeModelId(models);
608
652
  });
609
653
  };
610
654
  const cached = readCachedRealtimeModelCatalog(client, workspaceId);
@@ -822,6 +866,7 @@ export function RealtimeVoiceControl(props: {
822
866
  props.canStart,
823
867
  props.admissionBlocker ?? null,
824
868
  selectedModel.label,
869
+ selectedModel.unavailableCode ?? null,
825
870
  );
826
871
  const modeOwned = props.snapshot.mode?.state === "active";
827
872
  const retryConnection =
@@ -830,6 +875,11 @@ export function RealtimeVoiceControl(props: {
830
875
  props.snapshot.status !== "active" &&
831
876
  props.snapshot.diagnostic?.recoverable === true;
832
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}`;
833
883
  const mainDisabled =
834
884
  props.snapshot.status === "stopping" ||
835
885
  props.snapshot.status === "lost_owner" ||
@@ -841,7 +891,11 @@ export function RealtimeVoiceControl(props: {
841
891
  ? "Retry voice connection"
842
892
  : modeOwned
843
893
  ? "End voice conversation"
844
- : `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}`;
845
899
  const runMainAction = audioBlocked
846
900
  ? props.onRetryAudibleOutput
847
901
  : retryConnection
@@ -858,6 +912,20 @@ export function RealtimeVoiceControl(props: {
858
912
  selectedModel.provider,
859
913
  );
860
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]);
861
929
 
862
930
  useEffect(() => {
863
931
  if (!pickerOpen) setPickerProvider(selectedModel.provider);
@@ -865,6 +933,7 @@ export function RealtimeVoiceControl(props: {
865
933
 
866
934
  return (
867
935
  <div
936
+ ref={rootRef}
868
937
  role="group"
869
938
  aria-label="Realtime voice"
870
939
  data-picker-side={props.menuSide ?? "top"}
@@ -946,6 +1015,20 @@ export function RealtimeVoiceControl(props: {
946
1015
  </motion.div>
947
1016
  ) : null}
948
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}
949
1032
  <div className="inline-flex shrink-0 items-center">
950
1033
  <motion.button
951
1034
  type="button"
@@ -1202,7 +1285,7 @@ export function RealtimeModelPickerMenu(props: {
1202
1285
  const body = props.provider ? (
1203
1286
  <div data-testid="realtime-model-picker-models">
1204
1287
  <PickerBackHeader
1205
- label={props.provider}
1288
+ label={realtimeProviderLabel(props.provider)}
1206
1289
  icon={
1207
1290
  <BillingClassMark
1208
1291
  billingClass={REALTIME_PROVIDER_META[props.provider].billingClass}
@@ -1245,7 +1328,7 @@ export function RealtimeModelPickerMenu(props: {
1245
1328
  return (
1246
1329
  <PickerNavRow
1247
1330
  key={provider}
1248
- label={provider}
1331
+ label={realtimeProviderLabel(provider)}
1249
1332
  hint={meta.hint}
1250
1333
  icon={<BillingClassMark billingClass={meta.billingClass} aria-label="" />}
1251
1334
  active={props.selectedModel.provider === provider}
@@ -1333,6 +1416,7 @@ function statusContent(
1333
1416
  canStart: boolean,
1334
1417
  admissionBlocker: string | null,
1335
1418
  modelLabel: string,
1419
+ unavailableCode: string | null = null,
1336
1420
  ): { phase: RealtimeVisualPhase; label: string; detail: string } {
1337
1421
  if (snapshot.audibleOutput === "blocked" && !snapshot.outputMuted) {
1338
1422
  return {
@@ -1379,6 +1463,13 @@ function statusContent(
1379
1463
  detail: "Return to the browser that started it, or wait for that connection to expire.",
1380
1464
  };
1381
1465
  case "error":
1466
+ if (snapshot.refusal) {
1467
+ return {
1468
+ phase: "unavailable",
1469
+ label: realtimeRefusalLabel(snapshot.refusal.code) ?? "Voice unavailable",
1470
+ detail: snapshot.refusal.message,
1471
+ };
1472
+ }
1382
1473
  return {
1383
1474
  phase: "error",
1384
1475
  label: "Voice unavailable",
@@ -1388,7 +1479,7 @@ function statusContent(
1388
1479
  if (!modelAvailable) {
1389
1480
  return {
1390
1481
  phase: "unavailable",
1391
- label: "Voice model unavailable",
1482
+ label: realtimeRefusalLabel(unavailableCode) ?? "Voice model unavailable",
1392
1483
  detail: admissionBlocker ?? "Choose another voice model.",
1393
1484
  };
1394
1485
  }
@@ -1580,6 +1671,7 @@ function writeRealtimeModelPreference(workspaceId: string, model: SessionRealtim
1580
1671
 
1581
1672
  function isRealtimeModel(value: string | null): value is SessionRealtimeModel {
1582
1673
  return (
1674
+ value === "opengeni-azure/gpt-live-1" ||
1583
1675
  value === "gpt-live-1-boulder-alpha" ||
1584
1676
  value === "opengeni-gateway/openai/gpt-realtime-2.1" ||
1585
1677
  value === "opengeni-gateway/openai/gpt-realtime-mini" ||