@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
@@ -1,4 +1,6 @@
1
- import { useEffect, useRef, useState, type ReactNode } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { dequal } from "dequal";
3
+ import { ChevronLeftIcon, ChevronRightIcon, ShieldCheckIcon } from "lucide-react";
2
4
  import {
3
5
  OpenGeniApiError,
4
6
  toolReviewAction,
@@ -13,6 +15,9 @@ import { toolDisplayName } from "../timeline/tool-display-name";
13
15
  import {
14
16
  ToolActionReviewCard,
15
17
  ToolActionReviewDetails,
18
+ ToolReviewFrame,
19
+ DeclineButton,
20
+ UNRECOVERABLE_TEXT,
16
21
  type ToolReviewDetailsLoader,
17
22
  } from "./tool-action-review";
18
23
 
@@ -37,10 +42,15 @@ export const defaultApprovalSurfaceMessages: ApprovalSurfaceMessages = {
37
42
  formatToolName: (name) =>
38
43
  name.includes("__") || /^[a-f0-9]{64}$/.test(name)
39
44
  ? toolDisplayName(name)
40
- : name.replaceAll("_", " ").replaceAll(".", " › "),
45
+ : humanToolName(name.replaceAll("_", " ").replaceAll(".", " › ")),
41
46
  showDetails: "View full details",
42
47
  hideDetails: "Back to review",
43
48
  };
49
+ /** "batch modify messages" → "Modify messages": how a call is grouped is not what it does. */
50
+ function humanToolName(name: string): string {
51
+ const phrase = name.replace(/^batch\s+/i, "").trim();
52
+ return phrase ? phrase.charAt(0).toUpperCase() + phrase.slice(1) : name;
53
+ }
44
54
  export type ApprovalSurfaceProps = {
45
55
  approvals: PendingApproval[];
46
56
  onApprove: (approval: PendingApproval) => void | Promise<void>;
@@ -88,9 +98,10 @@ export function ApprovalSurface({
88
98
  }: ApprovalSurfaceProps) {
89
99
  const messages = { ...defaultApprovalSurfaceMessages, ...overrides };
90
100
  const formatError = useErrorMessage();
91
- const [pending, setPending] = useState<{ id: string; decision: "approve" | "reject" } | null>(
92
- null,
93
- );
101
+ const [pending, setPending] = useState<{
102
+ id: string;
103
+ decision: "approve" | "reject";
104
+ } | null>(null);
94
105
  const pendingRef = useRef<string | null>(null);
95
106
  const [decisionError, setDecisionError] = useState<{ cause: unknown } | null>(null);
96
107
  const [localSelectedId, setLocalSelectedId] = useState<string | null>(null);
@@ -137,39 +148,43 @@ export function ApprovalSurface({
137
148
  <section
138
149
  ref={surface}
139
150
  aria-label={messages.title}
140
- className={cn("og-root box-border w-full min-w-0 border-t border-og-border", className)}
151
+ className={cn("og-root box-border w-full min-w-0 space-y-2 py-2", className)}
141
152
  data-og-approval-surface=""
142
153
  >
143
154
  {approvals.length > 1 && (
144
155
  <nav
145
156
  aria-label="Actions to review"
146
- className="flex flex-wrap items-center justify-between gap-2 pt-3 text-og-xs text-og-fg-muted"
157
+ className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-og-xs text-og-fg-muted"
147
158
  >
148
- <span>{approvals.length} actions need review</span>
149
- <div className="flex items-center gap-3">
159
+ <span className="font-medium text-og-status-waiting">
160
+ {approvals.length} actions are waiting for you
161
+ </span>
162
+ <div className="flex items-center gap-1">
150
163
  <button
151
164
  type="button"
165
+ aria-label="Previous action"
152
166
  disabled={selectedIndex === 0}
153
167
  onClick={() => setSelectedId(approvals[selectedIndex - 1]!.id)}
154
- className="min-h-11 disabled:opacity-40"
168
+ className="inline-flex min-h-8 min-w-8 items-center justify-center rounded-og-md hover:bg-og-hover hover:text-og-fg disabled:opacity-40 disabled:hover:bg-transparent [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
155
169
  >
156
- Previous
170
+ <ChevronLeftIcon className="size-4" aria-hidden="true" />
157
171
  </button>
158
- <span>
172
+ <span className="tabular-nums" aria-live="polite">
159
173
  {selectedIndex + 1} of {approvals.length}
160
174
  </span>
161
175
  <button
162
176
  type="button"
177
+ aria-label="Next action"
163
178
  disabled={selectedIndex === approvals.length - 1}
164
179
  onClick={() => setSelectedId(approvals[selectedIndex + 1]!.id)}
165
- className="min-h-11 disabled:opacity-40"
180
+ className="inline-flex min-h-8 min-w-8 items-center justify-center rounded-og-md hover:bg-og-hover hover:text-og-fg disabled:opacity-40 disabled:hover:bg-transparent [@media(pointer:coarse)]:min-h-11 [@media(pointer:coarse)]:min-w-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
166
181
  >
167
- Next
182
+ <ChevronRightIcon className="size-4" aria-hidden="true" />
168
183
  </button>
169
184
  </div>
170
185
  </nav>
171
186
  )}
172
- <div className="divide-y divide-og-border">
187
+ <div>
173
188
  {[selected].map((approval) => (
174
189
  <ApprovalRequest
175
190
  key={approval.id}
@@ -187,7 +202,7 @@ export function ApprovalSurface({
187
202
  ))}
188
203
  </div>
189
204
  {errorText && (
190
- <p role="alert" className="m-0 pb-3 text-og-sm text-og-status-failed">
205
+ <p role="alert" className="m-0 px-1 text-og-sm text-og-status-failed">
191
206
  {errorText}
192
207
  </p>
193
208
  )}
@@ -215,96 +230,197 @@ function ApprovalRequest({
215
230
  onApprove: () => void;
216
231
  onReject: () => void;
217
232
  }) {
218
- const [loaded, setLoaded] = useState<ToolActionReview | null>(null);
219
- const [failed, setFailed] = useState(false);
220
- const [legacy, setLegacy] = useState(false);
221
233
  const [retry, setRetry] = useState(0);
234
+ // Event projection recreates the same approval on unrelated timeline updates.
235
+ // Keep one request for equal facts, but never reuse it across changed facts,
236
+ // an explicit retry, or a loader change (the host's client/access boundary).
237
+ const requestRef = useRef({ approval, loadReview, retry });
238
+ if (
239
+ requestRef.current.loadReview !== loadReview ||
240
+ requestRef.current.retry !== retry ||
241
+ !dequal(requestRef.current.approval, approval)
242
+ ) {
243
+ requestRef.current = { approval, loadReview, retry };
244
+ }
245
+ const request = requestRef.current;
246
+ const [result, setResult] = useState<{
247
+ request: typeof request;
248
+ review?: ToolActionReview;
249
+ failed?: boolean;
250
+ legacy?: boolean;
251
+ denied?: boolean;
252
+ } | null>(null);
253
+ // Hide stale facts during render, before passive effect cleanup can run.
254
+ const current = result?.request === request ? result : null;
255
+ const loaded = current?.review;
256
+ const failed = current?.failed;
257
+ const legacy = current?.legacy;
258
+ const denied = current?.denied;
222
259
  const [detailPath, setDetailPath] = useState<string | null>(null);
223
260
  useEffect(() => {
224
261
  let active = true;
225
- setLoaded(null);
226
- setFailed(false);
227
- setLegacy(false);
228
- if (loadReview)
229
- void loadReview(approval).then(
262
+ if (request.loadReview)
263
+ void request.loadReview(request.approval).then(
230
264
  (review) => {
231
265
  if (active) {
232
- if (review.id !== approval.id) setFailed(true);
233
- else setLoaded(review);
266
+ setResult(
267
+ review.id !== request.approval.id ? { request, failed: true } : { request, review },
268
+ );
234
269
  }
235
270
  },
236
271
  (error) => {
237
272
  if (active) {
238
- if (
273
+ // 404: no saved review (older or non-connector actions); use the
274
+ // approval's own arguments. 401/403: this person cannot review it.
275
+ if (error instanceof OpenGeniApiError && error.status === 404)
276
+ setResult({ request, legacy: true });
277
+ else if (
239
278
  error instanceof OpenGeniApiError &&
240
- error.status === 404 &&
241
- approval.arguments !== undefined
279
+ (error.status === 401 || error.status === 403)
242
280
  )
243
- setLegacy(true);
244
- else setFailed(true);
281
+ setResult({ request, failed: true, denied: true });
282
+ else setResult({ request, failed: true });
245
283
  }
246
284
  },
247
285
  );
248
286
  return () => {
249
287
  active = false;
250
288
  };
251
- }, [approval, retry, loadReview]);
289
+ }, [request]);
290
+ const stableApproval = request.approval;
291
+ const { formatToolName, approve: approveLabel } = messages;
292
+ const fallback = useMemo<ToolActionReview>(
293
+ () => ({
294
+ version: 1,
295
+ id: stableApproval.id,
296
+ actionDigest: "",
297
+ revision: "legacy",
298
+ status: "pending",
299
+ ...toolReviewAction(stableApproval.name, stableApproval.arguments, {
300
+ kind: "generic",
301
+ title:
302
+ stableApproval.display?.title ??
303
+ formatToolName(stableApproval.display?.toolName ?? stableApproval.name),
304
+ }),
305
+ ...(stableApproval.display?.accountLabel
306
+ ? { accountLabel: stableApproval.display.accountLabel }
307
+ : {}),
308
+ ...toolReviewFields(stableApproval.arguments),
309
+ reason: "",
310
+ createdAt: "",
311
+ updatedAt: "",
312
+ approveLabel,
313
+ availableActions: stableApproval.arguments === undefined ? [] : ["approve", "reject"],
314
+ detailsAvailable: stableApproval.arguments !== undefined,
315
+ }),
316
+ [stableApproval, formatToolName, approveLabel],
317
+ );
318
+ const loadLegacyDetails = useCallback<ToolReviewDetailsLoader>(
319
+ async (value, path, offset) => ({
320
+ version: 1,
321
+ id: value.id,
322
+ actionDigest: value.actionDigest,
323
+ ...toolReviewDetails(stableApproval.arguments, undefined, path, offset),
324
+ }),
325
+ [stableApproval],
326
+ );
252
327
  if (loadReview && !loaded && !failed && !legacy)
253
328
  return (
254
- <div className="py-5 text-og-sm text-og-fg-muted" role="status">
255
- Loading action details…
256
- </div>
329
+ <ToolReviewFrame
330
+ tone="waiting"
331
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
332
+ eyebrow={messages.title}
333
+ title={fallback.title}
334
+ subtitle={fallback.accountLabel}
335
+ aria-busy
336
+ data-og-tool-review-loading=""
337
+ >
338
+ <div role="status" className="space-y-2">
339
+ <span className="sr-only">Loading action details…</span>
340
+ <div className="h-3 w-3/4 animate-pulse rounded-og-sm bg-og-fg/8" aria-hidden="true" />
341
+ <div className="h-3 w-1/2 animate-pulse rounded-og-sm bg-og-fg/8" aria-hidden="true" />
342
+ </div>
343
+ </ToolReviewFrame>
344
+ );
345
+ if (denied)
346
+ return (
347
+ <ToolReviewFrame
348
+ tone="waiting"
349
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
350
+ eyebrow={messages.title}
351
+ title={fallback.title}
352
+ subtitle={fallback.accountLabel}
353
+ >
354
+ <p role="alert" className="m-0 text-og-sm text-og-fg">
355
+ You don't have access to review this action. Ask someone with access to this chat to
356
+ decide.
357
+ </p>
358
+ </ToolReviewFrame>
257
359
  );
258
360
  if (failed)
259
361
  return (
260
- <div className="py-4 text-og-sm text-og-fg" role="alert">
261
- Action details could not be loaded.{" "}
262
- <button
263
- type="button"
264
- className="min-h-11 underline underline-offset-4"
265
- onClick={() => setRetry((value) => value + 1)}
362
+ <ToolReviewFrame
363
+ tone="waiting"
364
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
365
+ eyebrow={messages.title}
366
+ title={fallback.title}
367
+ subtitle={fallback.accountLabel}
368
+ footer={
369
+ <div className="ml-auto">
370
+ <DeclineButton
371
+ busy={Boolean(actions.disabled || actions.submitting)}
372
+ submitting={actions.submitting === "reject"}
373
+ label={messages.reject}
374
+ onClick={actions.onReject}
375
+ />
376
+ </div>
377
+ }
378
+ >
379
+ <p role="alert" className="m-0 text-og-sm text-og-fg">
380
+ Couldn't load this action, so it can't be approved yet. You can still decline it.{" "}
381
+ <button
382
+ type="button"
383
+ className="min-h-8 font-medium underline underline-offset-4 [@media(pointer:coarse)]:min-h-11 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-og-accent/40"
384
+ onClick={() => setRetry((value) => value + 1)}
385
+ >
386
+ Try again
387
+ </button>
388
+ </p>
389
+ </ToolReviewFrame>
390
+ );
391
+ if (!loaded && stableApproval.arguments === undefined)
392
+ return (
393
+ <div data-approval-id={approval.id} data-review-origin="pending">
394
+ {custom}
395
+ <ToolReviewFrame
396
+ tone="waiting"
397
+ icon={<ShieldCheckIcon className="size-4" aria-hidden="true" />}
398
+ eyebrow={messages.title}
399
+ title={fallback.title}
400
+ subtitle={fallback.accountLabel}
401
+ data-og-tool-review=""
402
+ footer={
403
+ <div className="ml-auto">
404
+ <DeclineButton
405
+ busy={Boolean(actions.disabled || actions.submitting)}
406
+ submitting={actions.submitting === "reject"}
407
+ label={messages.reject}
408
+ onClick={actions.onReject}
409
+ />
410
+ </div>
411
+ }
266
412
  >
267
- Try again
268
- </button>
413
+ <p className="m-0 text-og-sm text-og-fg">{UNRECOVERABLE_TEXT}</p>
414
+ </ToolReviewFrame>
269
415
  </div>
270
416
  );
271
- const fallback: ToolActionReview = {
272
- version: 1,
273
- id: approval.id,
274
- actionDigest: "",
275
- revision: "legacy",
276
- status: "pending",
277
- ...toolReviewAction(approval.name, approval.arguments, {
278
- kind: "generic",
279
- title:
280
- approval.display?.title ??
281
- messages.formatToolName(approval.display?.toolName ?? approval.name),
282
- }),
283
- ...(approval.display?.accountLabel ? { accountLabel: approval.display.accountLabel } : {}),
284
- ...toolReviewFields(approval.arguments),
285
- reason: "This action requires your approval.",
286
- createdAt: "",
287
- updatedAt: "",
288
- approveLabel: messages.approve,
289
- availableActions: approval.arguments === undefined ? [] : ["approve", "reject"],
290
- detailsAvailable: approval.arguments !== undefined,
291
- };
292
417
  const review = loaded ?? fallback;
293
418
  if (detailPath !== null)
294
419
  return (
295
420
  <ToolActionReviewDetails
296
421
  review={review}
297
422
  path={detailPath}
298
- load={
299
- loaded && loadDetails
300
- ? loadDetails
301
- : async (value, path, offset) => ({
302
- version: 1,
303
- id: value.id,
304
- actionDigest: value.actionDigest,
305
- ...toolReviewDetails(approval.arguments, undefined, path, offset),
306
- })
307
- }
423
+ load={loaded && loadDetails ? loadDetails : loadLegacyDetails}
308
424
  onBack={() => {
309
425
  const path = detailPath;
310
426
  setDetailPath(null);
@@ -340,11 +456,6 @@ function ApprovalRequest({
340
456
  : undefined
341
457
  }
342
458
  />
343
- {!loaded && approval.arguments === undefined && (
344
- <p className="pb-3 text-og-sm text-og-fg-muted">
345
- Open the task with a compatible client to review this action.
346
- </p>
347
- )}
348
459
  </div>
349
460
  );
350
461
  }
@@ -203,7 +203,7 @@ function EditableBody({
203
203
  workspaceId: string;
204
204
  sessionId: string;
205
205
  result:
206
- | { kind: "ready"; config: NonNullable<ClientConfig["artifacts"]> }
206
+ | { kind: "ready"; config: Exclude<NonNullable<ClientConfig["artifacts"]>, false> }
207
207
  | { kind: "unavailable" }
208
208
  | { kind: "error"; error: unknown };
209
209
  } | null>(null);
@@ -359,7 +359,9 @@ function EditableBody({
359
359
  );
360
360
  }
361
361
 
362
- function artifactCapabilityKey(config: NonNullable<ClientConfig["artifacts"]>): string {
362
+ function artifactCapabilityKey(
363
+ config: Exclude<NonNullable<ClientConfig["artifacts"]>, false>,
364
+ ): string {
363
365
  return JSON.stringify([
364
366
  config.editableLiveUrl,
365
367
  config.cachePartition.accountId,
@@ -1,3 +1,7 @@
1
+ import {
2
+ COMPOSER_VOICE_INPUT_START_EVENT,
3
+ composerVoiceEventScope,
4
+ } from "../composer-voice-events";
1
5
  import type {
2
6
  ClientVoiceInputConfig,
3
7
  OpenGeniClient,
@@ -35,6 +39,10 @@ export type ComposerTranscriptionMessages = {
35
39
  stop: string;
36
40
  cancel: string;
37
41
  retry: string;
42
+ /** Transcribe a recording that was stopped without transcribing or recovered. */
43
+ transcribeSaved: string;
44
+ /** Shown near the automatic stop; `{seconds}` is replaced. */
45
+ timeRemaining: string;
38
46
  pauseRetrying: string;
39
47
  requestingPermission: string;
40
48
  recording: string;
@@ -50,6 +58,9 @@ export type ComposerTranscriptionMessages = {
50
58
  errorPermissionDenied: string;
51
59
  errorNotSupported: string;
52
60
  errorUnavailable: string;
61
+ errorInsufficientCredits: string;
62
+ errorAllowanceExhausted: string;
63
+ errorPolicyBlocked: string;
53
64
  errorTooLarge: string;
54
65
  errorInvalidAudio: string;
55
66
  errorStorageUnavailable: string;
@@ -61,28 +72,34 @@ export type ComposerTranscriptionMessages = {
61
72
  const defaultMessages: ComposerTranscriptionMessages = {
62
73
  start: "Start voice input",
63
74
  stop: "Stop and transcribe",
64
- cancel: "Cancel recording",
75
+ cancel: "Stop without transcribing",
65
76
  retry: "Retry voice input",
77
+ transcribeSaved: "Transcribe saved recording",
78
+ timeRemaining: "Stops and transcribes in {seconds}s",
66
79
  pauseRetrying: "Pause automatic retry",
67
80
  requestingPermission: "Requesting microphone…",
68
- recording: "Recording. Press Escape to cancel.",
81
+ recording: "Recording. Press Escape to stop without transcribing.",
69
82
  saving: "Saving audio locally…",
70
83
  transcribing: "Transcribing…",
71
- retrying: "Recording saved. Retrying automatically…",
72
- recovered: "Recording recovered and saved locally.",
73
- recoveredTranscript: "Transcript saved locally. Check your draft before inserting.",
84
+ retrying: "Saved. Retrying transcription…",
85
+ recovered: "Recording saved on this device.",
86
+ recoveredTranscript: "Transcript saved. Insert it into your draft?",
74
87
  insertRecoveredTranscript: "Insert saved transcript",
75
88
  discardRecovered: "Discard saved recording",
76
89
  unavailableDisabled: "Voice input is unavailable while the composer is disabled.",
77
90
  unavailable: "Voice input is unavailable for this workspace.",
78
- errorPermissionDenied: "Microphone permission was denied. Your draft was not changed.",
91
+ errorPermissionDenied: "Microphone access is blocked. Allow it in site settings, then try again.",
79
92
  errorNotSupported: "Voice input is not supported on this device.",
80
93
  errorUnavailable: "Voice input is not configured.",
94
+ errorInsufficientCredits:
95
+ "Voice input needs credits. Recording saved; retry after adding credits.",
96
+ errorAllowanceExhausted: "Usage limit reached. Recording saved for later.",
97
+ errorPolicyBlocked: "Voice input isn't allowed for this account or workspace.",
81
98
  errorTooLarge: "Recording is too large. Try a shorter message.",
82
99
  errorInvalidAudio: "The recording could not be read. Try again.",
83
100
  errorStorageUnavailable: "Voice input stopped because audio could not be saved safely.",
84
- errorRetryable: "Recording is saved locally. Retry transcription when ready.",
85
- errorHandoffUncertain: "Transcript is saved. Check your draft before inserting it again.",
101
+ errorRetryable: "Saved. Transcription failed, retry when ready.",
102
+ errorHandoffUncertain: "Transcript saved. Check your draft before inserting again.",
86
103
  errorUnknown: "Voice input could not start. Try again.",
87
104
  };
88
105
 
@@ -146,12 +163,18 @@ export function ComposerTranscriptionControl({
146
163
  const capturing = status === "requesting-permission" || status === "recording";
147
164
 
148
165
  const cancelTranscription = transcription.cancel;
166
+ const discardTranscription = transcription.discard;
167
+ const hasRecoverableRecording = transcription.hasRecoverableRecording;
149
168
  useEffect(() => {
150
169
  if (!suppressed) return;
151
170
  if (status === "recording" || status === "requesting-permission") {
152
171
  cancelTranscription();
172
+ } else if (status === "error" && !hasRecoverableRecording) {
173
+ // Live voice took over: a stale start error (nothing saved) would only
174
+ // duplicate whatever live voice reports next to it.
175
+ void discardTranscription();
153
176
  }
154
- }, [cancelTranscription, status, suppressed]);
177
+ }, [cancelTranscription, discardTranscription, hasRecoverableRecording, status, suppressed]);
155
178
  const active = capturing || status === "saving" || status === "transcribing";
156
179
  const recoverable =
157
180
  transcription.hasRecoverableRecording &&
@@ -187,11 +210,22 @@ export function ComposerTranscriptionControl({
187
210
  ? (errorMessage ?? messages.errorUnknown)
188
211
  : unavailableMessage;
189
212
 
213
+ const recoveredLabel = savedTranscript
214
+ ? (errorMessage ?? messages.recoveredTranscript)
215
+ : retrying
216
+ ? messages.retrying
217
+ : status === "error"
218
+ ? (errorMessage ?? messages.errorRetryable)
219
+ : messages.recovered;
220
+
190
221
  function start(event: MouseEvent<HTMLButtonElement>) {
191
222
  if (unavailableMessage) {
192
223
  event.preventDefault();
193
224
  return;
194
225
  }
226
+ composerVoiceEventScope(event.currentTarget)?.dispatchEvent(
227
+ new Event(COMPOSER_VOICE_INPUT_START_EVENT),
228
+ );
195
229
  void transcription.start();
196
230
  }
197
231
 
@@ -213,6 +247,9 @@ export function ComposerTranscriptionControl({
213
247
  )}
214
248
  data-transcription-status={status}
215
249
  data-transcription-capturing={capturing ? "" : undefined}
250
+ data-transcription-attention={
251
+ !capturing && (recoverable || (status === "error" && errorMessage)) ? "" : undefined
252
+ }
216
253
  >
217
254
  <span className="inline-flex min-w-0 items-center gap-1.5">
218
255
  <AnimatePresence mode="popLayout" initial={false}>
@@ -228,14 +265,11 @@ export function ComposerTranscriptionControl({
228
265
  "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
229
266
  )}
230
267
  >
231
- <span className="og-transcription-recovered-label max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28">
232
- {savedTranscript
233
- ? (errorMessage ?? messages.recoveredTranscript)
234
- : retrying
235
- ? messages.retrying
236
- : status === "error"
237
- ? (errorMessage ?? messages.errorRetryable)
238
- : messages.recovered}
268
+ <span
269
+ title={recoveredLabel}
270
+ className="og-transcription-recovered-label max-w-56 line-clamp-2 text-og-xs leading-tight text-og-fg-muted max-sm:max-w-32"
271
+ >
272
+ {recoveredLabel}
239
273
  </span>
240
274
  <Tip
241
275
  tip={
@@ -243,7 +277,9 @@ export function ComposerTranscriptionControl({
243
277
  ? messages.insertRecoveredTranscript
244
278
  : retrying
245
279
  ? messages.pauseRetrying
246
- : messages.retry
280
+ : status === "recovered"
281
+ ? messages.transcribeSaved
282
+ : messages.retry
247
283
  }
248
284
  >
249
285
  <button
@@ -261,7 +297,9 @@ export function ComposerTranscriptionControl({
261
297
  ? messages.insertRecoveredTranscript
262
298
  : retrying
263
299
  ? messages.pauseRetrying
264
- : messages.retry
300
+ : status === "recovered"
301
+ ? messages.transcribeSaved
302
+ : messages.retry
265
303
  }
266
304
  className={cn(
267
305
  "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
@@ -323,6 +361,13 @@ export function ComposerTranscriptionControl({
323
361
  stream={status === "recording" ? transcription.stream : null}
324
362
  mode={status === "recording" ? "recording" : "transcribing"}
325
363
  />
364
+ {status === "recording" && transcription.recordingStartedAt !== null ? (
365
+ <RecordingClock
366
+ startedAt={transcription.recordingStartedAt}
367
+ maxSeconds={transcription.maxRecordingSeconds}
368
+ remainingLabel={messages.timeRemaining}
369
+ />
370
+ ) : null}
326
371
  </span>
327
372
  )}
328
373
  {status === "recording" ? (
@@ -394,14 +439,22 @@ export function ComposerTranscriptionControl({
394
439
  )}
395
440
  </AnimatePresence>
396
441
  {status === "error" && errorMessage && !recoverable ? (
397
- <Tip tip={errorMessage}>
398
- <span
399
- aria-hidden="true"
400
- className="og-transcription-error-label max-w-40 truncate text-og-xs text-og-status-failed max-sm:max-w-24"
401
- >
442
+ // Nothing is saved in this state, so dismissing loses no speech.
443
+ <button
444
+ type="button"
445
+ title={errorMessage}
446
+ aria-label={`Dismiss: ${errorMessage}`}
447
+ onClick={() => void transcription.discard()}
448
+ className={cn(
449
+ "inline-flex min-w-0 items-center gap-1 rounded-og-sm text-left outline-hidden",
450
+ "text-og-status-failed focus-visible:ring-2 focus-visible:ring-og-accent/45",
451
+ )}
452
+ >
453
+ <span className="og-transcription-error-label max-w-56 line-clamp-2 text-og-xs leading-tight max-sm:max-w-32">
402
454
  {errorMessage}
403
455
  </span>
404
- </Tip>
456
+ <XIcon className="size-3 shrink-0 opacity-70" aria-hidden />
457
+ </button>
405
458
  ) : null}
406
459
  <span
407
460
  className="sr-only"
@@ -417,6 +470,42 @@ export function ComposerTranscriptionControl({
417
470
  );
418
471
  }
419
472
 
473
+ /** Elapsed dictation time; warns before the automatic stop-and-transcribe. */
474
+ function RecordingClock({
475
+ startedAt,
476
+ maxSeconds,
477
+ remainingLabel,
478
+ }: {
479
+ startedAt: number;
480
+ maxSeconds: number | null;
481
+ remainingLabel: string;
482
+ }) {
483
+ const [now, setNow] = useState(() => Date.now());
484
+ useEffect(() => {
485
+ const timer = setInterval(() => setNow(Date.now()), 500);
486
+ return () => clearInterval(timer);
487
+ }, []);
488
+ const elapsed = Math.max(0, Math.floor((now - startedAt) / 1_000));
489
+ const remaining = maxSeconds === null ? null : Math.max(0, maxSeconds - elapsed);
490
+ const warn = remaining !== null && remaining <= 15;
491
+ const label = `${Math.floor(elapsed / 60)}:${String(elapsed % 60).padStart(2, "0")}`;
492
+ return (
493
+ <span
494
+ data-recording-clock
495
+ title={warn ? remainingLabel.replace("{seconds}", String(remaining)) : undefined}
496
+ className={cn(
497
+ "min-w-[2.25rem] text-right text-og-xs tabular-nums",
498
+ warn ? "font-medium text-og-status-failed" : "text-og-fg-muted",
499
+ )}
500
+ >
501
+ {label}
502
+ {warn ? (
503
+ <span className="sr-only">{remainingLabel.replace("{seconds}", String(remaining))}</span>
504
+ ) : null}
505
+ </span>
506
+ );
507
+ }
508
+
420
509
  function VoiceWaveform({
421
510
  stream,
422
511
  mode,
@@ -518,6 +607,13 @@ function restingLevels(count: number): number[] {
518
607
 
519
608
  function transcriptionErrorMessage(code: string, messages: ComposerTranscriptionMessages): string {
520
609
  switch (code) {
610
+ case "insufficient_credits":
611
+ return messages.errorInsufficientCredits;
612
+ case "allowance_exhausted":
613
+ case "monthly_model_cost_limit":
614
+ return messages.errorAllowanceExhausted;
615
+ case "policy_blocked":
616
+ return messages.errorPolicyBlocked;
521
617
  case "permission_denied":
522
618
  return messages.errorPermissionDenied;
523
619
  case "not_supported":
@@ -967,6 +967,7 @@ export const Surface = forwardRef<HTMLDivElement, ComposerSurfaceProps>(function
967
967
  const controller = useComposerController();
968
968
  return (
969
969
  <div
970
+ data-og-composer-surface=""
970
971
  {...props}
971
972
  ref={ref}
972
973
  onDragOver={controller.attachments ? controller.handleDragOver : undefined}