@agent-native/toolkit 0.5.1 → 0.7.0

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 (349) hide show
  1. package/README.md +17 -1
  2. package/dist/chat-history/ChatHistoryList.d.ts +103 -0
  3. package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
  4. package/dist/chat-history/ChatHistoryList.js +109 -0
  5. package/dist/chat-history/ChatHistoryList.js.map +1 -0
  6. package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
  7. package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
  8. package/dist/chat-history/ChatHistoryList.spec.js +283 -0
  9. package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
  10. package/dist/chat-history/index.d.ts +2 -0
  11. package/dist/chat-history/index.d.ts.map +1 -0
  12. package/dist/chat-history/index.js +2 -0
  13. package/dist/chat-history/index.js.map +1 -0
  14. package/dist/chat-history.css +314 -0
  15. package/dist/composer/AgentComposerFrame.d.ts +19 -0
  16. package/dist/composer/AgentComposerFrame.d.ts.map +1 -0
  17. package/dist/composer/AgentComposerFrame.js +14 -0
  18. package/dist/composer/AgentComposerFrame.js.map +1 -0
  19. package/dist/composer/ComposerPlusMenu.d.ts +17 -0
  20. package/dist/composer/ComposerPlusMenu.d.ts.map +1 -0
  21. package/dist/composer/ComposerPlusMenu.js +501 -0
  22. package/dist/composer/ComposerPlusMenu.js.map +1 -0
  23. package/dist/composer/MentionPopover.d.ts +29 -0
  24. package/dist/composer/MentionPopover.d.ts.map +1 -0
  25. package/dist/composer/MentionPopover.js +180 -0
  26. package/dist/composer/MentionPopover.js.map +1 -0
  27. package/dist/composer/PastedTextChip.d.ts +9 -0
  28. package/dist/composer/PastedTextChip.d.ts.map +1 -0
  29. package/dist/composer/PastedTextChip.js +47 -0
  30. package/dist/composer/PastedTextChip.js.map +1 -0
  31. package/dist/composer/PromptComposer.d.ts +114 -0
  32. package/dist/composer/PromptComposer.d.ts.map +1 -0
  33. package/dist/composer/PromptComposer.js +302 -0
  34. package/dist/composer/PromptComposer.js.map +1 -0
  35. package/dist/composer/PromptComposer.spec.d.ts +2 -0
  36. package/dist/composer/PromptComposer.spec.d.ts.map +1 -0
  37. package/dist/composer/PromptComposer.spec.js +64 -0
  38. package/dist/composer/PromptComposer.spec.js.map +1 -0
  39. package/dist/composer/RealtimeVoiceMode.d.ts +104 -0
  40. package/dist/composer/RealtimeVoiceMode.d.ts.map +1 -0
  41. package/dist/composer/RealtimeVoiceMode.js +245 -0
  42. package/dist/composer/RealtimeVoiceMode.js.map +1 -0
  43. package/dist/composer/RealtimeVoiceMode.spec.d.ts +2 -0
  44. package/dist/composer/RealtimeVoiceMode.spec.d.ts.map +1 -0
  45. package/dist/composer/RealtimeVoiceMode.spec.js +439 -0
  46. package/dist/composer/RealtimeVoiceMode.spec.js.map +1 -0
  47. package/dist/composer/TiptapComposer.d.ts +221 -0
  48. package/dist/composer/TiptapComposer.d.ts.map +1 -0
  49. package/dist/composer/TiptapComposer.js +1838 -0
  50. package/dist/composer/TiptapComposer.js.map +1 -0
  51. package/dist/composer/TiptapComposer.spec.d.ts +2 -0
  52. package/dist/composer/TiptapComposer.spec.d.ts.map +1 -0
  53. package/dist/composer/TiptapComposer.spec.js +277 -0
  54. package/dist/composer/TiptapComposer.spec.js.map +1 -0
  55. package/dist/composer/VoiceButton.d.ts +25 -0
  56. package/dist/composer/VoiceButton.d.ts.map +1 -0
  57. package/dist/composer/VoiceButton.js +100 -0
  58. package/dist/composer/VoiceButton.js.map +1 -0
  59. package/dist/composer/VoiceButton.spec.d.ts +2 -0
  60. package/dist/composer/VoiceButton.spec.d.ts.map +1 -0
  61. package/dist/composer/VoiceButton.spec.js +19 -0
  62. package/dist/composer/VoiceButton.spec.js.map +1 -0
  63. package/dist/composer/asset-picker-url.d.ts +16 -0
  64. package/dist/composer/asset-picker-url.d.ts.map +1 -0
  65. package/dist/composer/asset-picker-url.js +64 -0
  66. package/dist/composer/asset-picker-url.js.map +1 -0
  67. package/dist/composer/asset-picker-url.spec.d.ts +2 -0
  68. package/dist/composer/asset-picker-url.spec.d.ts.map +1 -0
  69. package/dist/composer/asset-picker-url.spec.js +23 -0
  70. package/dist/composer/asset-picker-url.spec.js.map +1 -0
  71. package/dist/composer/attachment-accept.d.ts +9 -0
  72. package/dist/composer/attachment-accept.d.ts.map +1 -0
  73. package/dist/composer/attachment-accept.js +65 -0
  74. package/dist/composer/attachment-accept.js.map +1 -0
  75. package/dist/composer/attachment-accept.spec.d.ts +2 -0
  76. package/dist/composer/attachment-accept.spec.d.ts.map +1 -0
  77. package/dist/composer/attachment-accept.spec.js +15 -0
  78. package/dist/composer/attachment-accept.spec.js.map +1 -0
  79. package/dist/composer/draft-key.d.ts +2 -0
  80. package/dist/composer/draft-key.d.ts.map +1 -0
  81. package/dist/composer/draft-key.js +8 -0
  82. package/dist/composer/draft-key.js.map +1 -0
  83. package/dist/composer/draft-key.spec.d.ts +2 -0
  84. package/dist/composer/draft-key.spec.d.ts.map +1 -0
  85. package/dist/composer/draft-key.spec.js +13 -0
  86. package/dist/composer/draft-key.spec.js.map +1 -0
  87. package/dist/composer/extensions/FileReference.d.ts +3 -0
  88. package/dist/composer/extensions/FileReference.d.ts.map +1 -0
  89. package/dist/composer/extensions/FileReference.js +36 -0
  90. package/dist/composer/extensions/FileReference.js.map +1 -0
  91. package/dist/composer/extensions/MentionReference.d.ts +3 -0
  92. package/dist/composer/extensions/MentionReference.d.ts.map +1 -0
  93. package/dist/composer/extensions/MentionReference.js +66 -0
  94. package/dist/composer/extensions/MentionReference.js.map +1 -0
  95. package/dist/composer/extensions/SkillReference.d.ts +3 -0
  96. package/dist/composer/extensions/SkillReference.d.ts.map +1 -0
  97. package/dist/composer/extensions/SkillReference.js +40 -0
  98. package/dist/composer/extensions/SkillReference.js.map +1 -0
  99. package/dist/composer/index.d.ts +14 -0
  100. package/dist/composer/index.d.ts.map +1 -0
  101. package/dist/composer/index.js +13 -0
  102. package/dist/composer/index.js.map +1 -0
  103. package/dist/composer/pasted-text.d.ts +31 -0
  104. package/dist/composer/pasted-text.d.ts.map +1 -0
  105. package/dist/composer/pasted-text.js +130 -0
  106. package/dist/composer/pasted-text.js.map +1 -0
  107. package/dist/composer/pasted-text.spec.d.ts +2 -0
  108. package/dist/composer/pasted-text.spec.d.ts.map +1 -0
  109. package/dist/composer/pasted-text.spec.js +107 -0
  110. package/dist/composer/pasted-text.spec.js.map +1 -0
  111. package/dist/composer/prompt-attachments.d.ts +18 -0
  112. package/dist/composer/prompt-attachments.d.ts.map +1 -0
  113. package/dist/composer/prompt-attachments.js +65 -0
  114. package/dist/composer/prompt-attachments.js.map +1 -0
  115. package/dist/composer/prompt-attachments.spec.d.ts +2 -0
  116. package/dist/composer/prompt-attachments.spec.d.ts.map +1 -0
  117. package/dist/composer/prompt-attachments.spec.js +63 -0
  118. package/dist/composer/prompt-attachments.spec.js.map +1 -0
  119. package/dist/composer/realtime-voice-audio-level.d.ts +14 -0
  120. package/dist/composer/realtime-voice-audio-level.d.ts.map +1 -0
  121. package/dist/composer/realtime-voice-audio-level.js +47 -0
  122. package/dist/composer/realtime-voice-audio-level.js.map +1 -0
  123. package/dist/composer/realtime-voice-audio-level.spec.d.ts +2 -0
  124. package/dist/composer/realtime-voice-audio-level.spec.d.ts.map +1 -0
  125. package/dist/composer/realtime-voice-audio-level.spec.js +25 -0
  126. package/dist/composer/realtime-voice-audio-level.spec.js.map +1 -0
  127. package/dist/composer/realtime-voice-transcript.d.ts +26 -0
  128. package/dist/composer/realtime-voice-transcript.d.ts.map +1 -0
  129. package/dist/composer/realtime-voice-transcript.js +103 -0
  130. package/dist/composer/realtime-voice-transcript.js.map +1 -0
  131. package/dist/composer/realtime-voice-transcript.spec.d.ts +2 -0
  132. package/dist/composer/realtime-voice-transcript.spec.d.ts.map +1 -0
  133. package/dist/composer/realtime-voice-transcript.spec.js +76 -0
  134. package/dist/composer/realtime-voice-transcript.spec.js.map +1 -0
  135. package/dist/composer/runtime-adapters.d.ts +163 -0
  136. package/dist/composer/runtime-adapters.d.ts.map +1 -0
  137. package/dist/composer/runtime-adapters.js +130 -0
  138. package/dist/composer/runtime-adapters.js.map +1 -0
  139. package/dist/composer/types.d.ts +60 -0
  140. package/dist/composer/types.d.ts.map +1 -0
  141. package/dist/composer/types.js +2 -0
  142. package/dist/composer/types.js.map +1 -0
  143. package/dist/composer/use-file-search.d.ts +6 -0
  144. package/dist/composer/use-file-search.d.ts.map +1 -0
  145. package/dist/composer/use-file-search.js +53 -0
  146. package/dist/composer/use-file-search.js.map +1 -0
  147. package/dist/composer/use-mention-search.d.ts +6 -0
  148. package/dist/composer/use-mention-search.d.ts.map +1 -0
  149. package/dist/composer/use-mention-search.js +75 -0
  150. package/dist/composer/use-mention-search.js.map +1 -0
  151. package/dist/composer/use-skills.d.ts +7 -0
  152. package/dist/composer/use-skills.d.ts.map +1 -0
  153. package/dist/composer/use-skills.js +40 -0
  154. package/dist/composer/use-skills.js.map +1 -0
  155. package/dist/composer/useRealtimeVoiceMode.d.ts +179 -0
  156. package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -0
  157. package/dist/composer/useRealtimeVoiceMode.js +1657 -0
  158. package/dist/composer/useRealtimeVoiceMode.js.map +1 -0
  159. package/dist/composer/useRealtimeVoiceMode.spec.d.ts +2 -0
  160. package/dist/composer/useRealtimeVoiceMode.spec.d.ts.map +1 -0
  161. package/dist/composer/useRealtimeVoiceMode.spec.js +618 -0
  162. package/dist/composer/useRealtimeVoiceMode.spec.js.map +1 -0
  163. package/dist/composer/useVoiceDictation.d.ts +57 -0
  164. package/dist/composer/useVoiceDictation.d.ts.map +1 -0
  165. package/dist/composer/useVoiceDictation.js +1004 -0
  166. package/dist/composer/useVoiceDictation.js.map +1 -0
  167. package/dist/composer/useVoiceDictation.spec.d.ts +2 -0
  168. package/dist/composer/useVoiceDictation.spec.d.ts.map +1 -0
  169. package/dist/composer/useVoiceDictation.spec.js +32 -0
  170. package/dist/composer/useVoiceDictation.spec.js.map +1 -0
  171. package/dist/editor/RegistryBlockContext.d.ts +68 -0
  172. package/dist/editor/RegistryBlockContext.d.ts.map +1 -0
  173. package/dist/editor/RegistryBlockContext.js +10 -0
  174. package/dist/editor/RegistryBlockContext.js.map +1 -0
  175. package/dist/editor/RegistryBlockNode.d.ts +66 -0
  176. package/dist/editor/RegistryBlockNode.d.ts.map +1 -0
  177. package/dist/editor/RegistryBlockNode.js +406 -0
  178. package/dist/editor/RegistryBlockNode.js.map +1 -0
  179. package/dist/editor/RegistryBlockNode.spec.d.ts +2 -0
  180. package/dist/editor/RegistryBlockNode.spec.d.ts.map +1 -0
  181. package/dist/editor/RegistryBlockNode.spec.js +215 -0
  182. package/dist/editor/RegistryBlockNode.spec.js.map +1 -0
  183. package/dist/editor/index.d.ts +2 -0
  184. package/dist/editor/index.d.ts.map +1 -1
  185. package/dist/editor/index.js +2 -0
  186. package/dist/editor/index.js.map +1 -1
  187. package/dist/editor/registrySlashCommands.d.ts +68 -0
  188. package/dist/editor/registrySlashCommands.d.ts.map +1 -0
  189. package/dist/editor/registrySlashCommands.js +51 -0
  190. package/dist/editor/registrySlashCommands.js.map +1 -0
  191. package/package.json +32 -1
  192. package/src/app-shell/header-actions.tsx +84 -0
  193. package/src/app-shell/index.ts +1 -0
  194. package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
  195. package/src/chat-history/ChatHistoryList.tsx +469 -0
  196. package/src/chat-history/index.ts +7 -0
  197. package/src/chat-history.css +314 -0
  198. package/src/collab-ui/AgentPresenceChip.tsx +69 -0
  199. package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
  200. package/src/collab-ui/PresenceBar.tsx +346 -0
  201. package/src/collab-ui/RecentEditHighlights.tsx +225 -0
  202. package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
  203. package/src/collab-ui/agent-identity.ts +16 -0
  204. package/src/collab-ui/index.ts +39 -0
  205. package/src/collab-ui/lead-client.ts +32 -0
  206. package/src/collab-ui/types.spec.ts +18 -0
  207. package/src/collab-ui/types.ts +96 -0
  208. package/src/composer/AgentComposerFrame.tsx +59 -0
  209. package/src/composer/ComposerPlusMenu.tsx +983 -0
  210. package/src/composer/MentionPopover.tsx +412 -0
  211. package/src/composer/PastedTextChip.tsx +150 -0
  212. package/src/composer/PromptComposer.spec.ts +73 -0
  213. package/src/composer/PromptComposer.tsx +718 -0
  214. package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
  215. package/src/composer/RealtimeVoiceMode.tsx +856 -0
  216. package/src/composer/TiptapComposer.spec.ts +390 -0
  217. package/src/composer/TiptapComposer.tsx +2936 -0
  218. package/src/composer/VoiceButton.spec.ts +33 -0
  219. package/src/composer/VoiceButton.tsx +255 -0
  220. package/src/composer/asset-picker-url.spec.ts +56 -0
  221. package/src/composer/asset-picker-url.ts +80 -0
  222. package/src/composer/attachment-accept.spec.ts +22 -0
  223. package/src/composer/attachment-accept.ts +71 -0
  224. package/src/composer/draft-key.spec.ts +19 -0
  225. package/src/composer/draft-key.ts +7 -0
  226. package/src/composer/extensions/FileReference.tsx +55 -0
  227. package/src/composer/extensions/MentionReference.tsx +93 -0
  228. package/src/composer/extensions/SkillReference.tsx +56 -0
  229. package/src/composer/index.ts +79 -0
  230. package/src/composer/pasted-text.spec.ts +162 -0
  231. package/src/composer/pasted-text.ts +160 -0
  232. package/src/composer/prompt-attachments.spec.ts +80 -0
  233. package/src/composer/prompt-attachments.ts +93 -0
  234. package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
  235. package/src/composer/realtime-voice-audio-level.ts +64 -0
  236. package/src/composer/realtime-voice-transcript.spec.ts +95 -0
  237. package/src/composer/realtime-voice-transcript.ts +134 -0
  238. package/src/composer/runtime-adapters.tsx +343 -0
  239. package/src/composer/types.ts +66 -0
  240. package/src/composer/use-file-search.ts +64 -0
  241. package/src/composer/use-mention-search.ts +90 -0
  242. package/src/composer/use-skills.ts +45 -0
  243. package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
  244. package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
  245. package/src/composer/useVoiceDictation.spec.ts +45 -0
  246. package/src/composer/useVoiceDictation.ts +1191 -0
  247. package/src/context-ui/ContextMeter.tsx +101 -0
  248. package/src/context-ui/ContextSegmentRow.tsx +184 -0
  249. package/src/context-ui/ContextTreemap.tsx +125 -0
  250. package/src/context-ui/ContextXRayPanel.tsx +377 -0
  251. package/src/context-ui/format.ts +42 -0
  252. package/src/context-ui/index.ts +15 -0
  253. package/src/context-ui/types.ts +43 -0
  254. package/src/design-tweaks/index.ts +1 -0
  255. package/src/design-tweaks/visual-style-controls.tsx +1084 -0
  256. package/src/editor/BubbleToolbar.tsx +253 -0
  257. package/src/editor/CodeBlockNode.tsx +254 -0
  258. package/src/editor/DragHandle.spec.ts +588 -0
  259. package/src/editor/DragHandle.ts +1437 -0
  260. package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
  261. package/src/editor/ImageExtension.ts +306 -0
  262. package/src/editor/RegistryBlockContext.tsx +107 -0
  263. package/src/editor/RegistryBlockNode.spec.ts +279 -0
  264. package/src/editor/RegistryBlockNode.tsx +652 -0
  265. package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
  266. package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
  267. package/src/editor/RichMarkdownEditor.tsx +81 -0
  268. package/src/editor/RunId.ts +61 -0
  269. package/src/editor/SharedRichEditor.tsx +321 -0
  270. package/src/editor/SlashCommandMenu.tsx +310 -0
  271. package/src/editor/disableHistory.spec.ts +85 -0
  272. package/src/editor/extensions.ts +402 -0
  273. package/src/editor/gfmDoc.spec.ts +230 -0
  274. package/src/editor/gfmDoc.ts +92 -0
  275. package/src/editor/index.ts +93 -0
  276. package/src/editor/registrySlashCommands.ts +131 -0
  277. package/src/editor/structuralReorderUndo.spec.ts +144 -0
  278. package/src/editor/surgical-apply.spec.ts +279 -0
  279. package/src/editor/surgical-apply.ts +192 -0
  280. package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
  281. package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
  282. package/src/editor/useCollabReconcile.ts +589 -0
  283. package/src/editor.css +510 -0
  284. package/src/hooks/index.ts +2 -0
  285. package/src/hooks/use-mobile.tsx +21 -0
  286. package/src/hooks/use-toast.ts +188 -0
  287. package/src/index.ts +8 -0
  288. package/src/onboarding/index.ts +1 -0
  289. package/src/provider.tsx +47 -0
  290. package/src/sharing/VisibilityBadge.spec.ts +96 -0
  291. package/src/sharing/VisibilityBadge.tsx +37 -0
  292. package/src/sharing/index.ts +4 -0
  293. package/src/styles.css +21 -0
  294. package/src/ui/accordion.tsx +56 -0
  295. package/src/ui/alert-dialog.tsx +141 -0
  296. package/src/ui/alert.tsx +59 -0
  297. package/src/ui/aspect-ratio.tsx +5 -0
  298. package/src/ui/avatar.tsx +48 -0
  299. package/src/ui/badge.tsx +37 -0
  300. package/src/ui/breadcrumb.tsx +115 -0
  301. package/src/ui/button.tsx +78 -0
  302. package/src/ui/calendar.tsx +214 -0
  303. package/src/ui/card.tsx +86 -0
  304. package/src/ui/carousel.tsx +260 -0
  305. package/src/ui/chart.tsx +375 -0
  306. package/src/ui/checkbox.tsx +28 -0
  307. package/src/ui/collapsible.tsx +9 -0
  308. package/src/ui/command.spec.tsx +51 -0
  309. package/src/ui/command.tsx +174 -0
  310. package/src/ui/context-menu.tsx +202 -0
  311. package/src/ui/date-picker.tsx +64 -0
  312. package/src/ui/dialog.spec.tsx +43 -0
  313. package/src/ui/dialog.tsx +146 -0
  314. package/src/ui/drawer.tsx +126 -0
  315. package/src/ui/dropdown-menu.tsx +218 -0
  316. package/src/ui/form.tsx +177 -0
  317. package/src/ui/hover-card.tsx +27 -0
  318. package/src/ui/index.ts +52 -0
  319. package/src/ui/input-otp.tsx +69 -0
  320. package/src/ui/input.tsx +22 -0
  321. package/src/ui/label.tsx +24 -0
  322. package/src/ui/logo.tsx +36 -0
  323. package/src/ui/menubar.tsx +235 -0
  324. package/src/ui/navigation-menu.tsx +128 -0
  325. package/src/ui/pagination.tsx +121 -0
  326. package/src/ui/popover.tsx +44 -0
  327. package/src/ui/progress.tsx +26 -0
  328. package/src/ui/radio-group.tsx +42 -0
  329. package/src/ui/resizable.tsx +43 -0
  330. package/src/ui/scroll-area.tsx +46 -0
  331. package/src/ui/select.tsx +158 -0
  332. package/src/ui/separator.tsx +29 -0
  333. package/src/ui/sheet.tsx +160 -0
  334. package/src/ui/sidebar.tsx +777 -0
  335. package/src/ui/skeleton.tsx +18 -0
  336. package/src/ui/slider.tsx +26 -0
  337. package/src/ui/sonner.tsx +58 -0
  338. package/src/ui/spinner.tsx +17 -0
  339. package/src/ui/switch.tsx +27 -0
  340. package/src/ui/table.tsx +117 -0
  341. package/src/ui/tabs.tsx +53 -0
  342. package/src/ui/textarea.tsx +23 -0
  343. package/src/ui/toast.tsx +127 -0
  344. package/src/ui/toaster.tsx +33 -0
  345. package/src/ui/toggle-group.tsx +59 -0
  346. package/src/ui/toggle.tsx +43 -0
  347. package/src/ui/tooltip.tsx +53 -0
  348. package/src/ui/use-toast.ts +3 -0
  349. package/src/utils.ts +8 -0
@@ -0,0 +1,2936 @@
1
+ import { Skeleton } from "@agent-native/toolkit/ui/skeleton";
2
+ import { useComposer, useComposerRuntime } from "@assistant-ui/react";
3
+ import {
4
+ IconArrowUp,
5
+ IconCheck,
6
+ IconChevronDown,
7
+ IconChevronRight,
8
+ IconBulb,
9
+ IconClock,
10
+ IconBolt,
11
+ IconTool,
12
+ IconX,
13
+ IconClipboardList,
14
+ IconKey,
15
+ IconPencil,
16
+ IconPlugConnected,
17
+ } from "@tabler/icons-react";
18
+ import Placeholder from "@tiptap/extension-placeholder";
19
+ import type { EditorView } from "@tiptap/pm/view";
20
+ import { useEditor, EditorContent } from "@tiptap/react";
21
+ import StarterKit from "@tiptap/starter-kit";
22
+ import React, {
23
+ useState,
24
+ useRef,
25
+ useEffect,
26
+ useCallback,
27
+ useImperativeHandle,
28
+ useMemo,
29
+ } from "react";
30
+
31
+ import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
32
+ import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip.js";
33
+ import { ComposerPlusMenu } from "./ComposerPlusMenu.js";
34
+ import { getComposerDraftKey } from "./draft-key.js";
35
+ import { FileReference } from "./extensions/FileReference.js";
36
+ import { MentionReference } from "./extensions/MentionReference.js";
37
+ import { SkillReference } from "./extensions/SkillReference.js";
38
+ import { MentionPopover, type MentionPopoverRef } from "./MentionPopover.js";
39
+ import {
40
+ createPastedAttachmentFile,
41
+ readClipboardPaste,
42
+ shouldConvertClipboardToAttachment,
43
+ } from "./pasted-text.js";
44
+ import {
45
+ AGENT_CHAT_INSERT_REFERENCE_EVENT,
46
+ AGENT_CHAT_INSERT_REFERENCE_MESSAGE_TYPE,
47
+ DEFAULT_REASONING_EFFORT,
48
+ formatPromptContextItems,
49
+ getReasoningEffortOptionsForModel,
50
+ reasoningEffortLabel,
51
+ resolveReasoningEffortSelection,
52
+ type AgentChatContextItem,
53
+ type AgentComposerReference,
54
+ type AgentComposerReferenceInsertPayload,
55
+ type ReasoningEffort,
56
+ type VoiceContextPack,
57
+ useComposerRuntimeAdapters,
58
+ } from "./runtime-adapters.js";
59
+ import type {
60
+ MentionItem,
61
+ SkillResult,
62
+ Reference,
63
+ SlashCommand,
64
+ ComposerMode,
65
+ AgentComposerLayoutVariant,
66
+ } from "./types.js";
67
+ import { useMentionSearch } from "./use-mention-search.js";
68
+ import { useSkills } from "./use-skills.js";
69
+ import { RealtimeVoiceModeBoundary } from "./useRealtimeVoiceMode.js";
70
+ import { useVoiceDictation } from "./useVoiceDictation.js";
71
+ import { VoiceButton, VoiceRecordingOverlay } from "./VoiceButton.js";
72
+ export interface TiptapComposerHandle {
73
+ focus(): void;
74
+ setText(text: string): void;
75
+ insertReference(ref: AgentComposerReference): void;
76
+ }
77
+
78
+ export type ComposerSubmitIntent = "immediate" | "queued";
79
+
80
+ export const DEFAULT_VOICE_DICTATION_ENABLED = false;
81
+
82
+ export interface TiptapComposerSubmitOptions {
83
+ intent?: ComposerSubmitIntent;
84
+ }
85
+
86
+ export function canSubmitComposerContent(options: {
87
+ hasEditorContent: boolean;
88
+ attachmentCount: number;
89
+ disabled?: boolean;
90
+ }): boolean {
91
+ return (
92
+ !options.disabled &&
93
+ (options.hasEditorContent || options.attachmentCount > 0)
94
+ );
95
+ }
96
+
97
+ const VOICE_TERMINAL_PUNCTUATION = /[.!?…。!?:;](?:["'”’»)\]}]*)$/;
98
+
99
+ export function formatVoiceTranscriptForComposer(text: string): string {
100
+ const trimmed = text.trim();
101
+ if (!trimmed) return "";
102
+ return `${trimmed}${VOICE_TERMINAL_PUNCTUATION.test(trimmed) ? "" : "."} `;
103
+ }
104
+
105
+ export function canRemoveVoicePreview(options: {
106
+ documentSize: number;
107
+ anchor: number;
108
+ previewText: string;
109
+ currentText: string;
110
+ }): boolean {
111
+ if (!options.previewText) return false;
112
+ if (options.anchor < 1) return false;
113
+ if (options.anchor + options.previewText.length > options.documentSize) {
114
+ return false;
115
+ }
116
+ return options.currentText === options.previewText;
117
+ }
118
+
119
+ export function resolveComposerPrimaryAction(options: {
120
+ canSubmit: boolean;
121
+ hasStopButton: boolean;
122
+ }): "send" | "stop" {
123
+ return !options.canSubmit && options.hasStopButton ? "stop" : "send";
124
+ }
125
+
126
+ export type ContextChipBackspaceAction =
127
+ | { type: "select"; key: string }
128
+ | { type: "remove"; key: string }
129
+ | null;
130
+
131
+ export function resolveContextChipBackspaceAction(options: {
132
+ contextItemKeys: string[];
133
+ selectedKey: string | null;
134
+ cursorAtStart: boolean;
135
+ }): ContextChipBackspaceAction {
136
+ if (!options.cursorAtStart || options.contextItemKeys.length === 0) {
137
+ return null;
138
+ }
139
+ if (
140
+ options.selectedKey &&
141
+ options.contextItemKeys.includes(options.selectedKey)
142
+ ) {
143
+ return { type: "remove", key: options.selectedKey };
144
+ }
145
+ return {
146
+ type: "select",
147
+ key: options.contextItemKeys[options.contextItemKeys.length - 1],
148
+ };
149
+ }
150
+
151
+ const MAX_DOCUMENT_ATTACHMENT_BYTES = 4 * 1024 * 1024;
152
+
153
+ function composerReferenceFromMentionItem(
154
+ item: MentionItem,
155
+ ): AgentComposerReference {
156
+ return {
157
+ label: item.label,
158
+ icon: item.icon || "file",
159
+ source: item.source,
160
+ refType: item.refType,
161
+ refId: item.refId || null,
162
+ refPath: item.refPath || null,
163
+ slotKey: item.slotKey,
164
+ slotLabel: item.slotLabel,
165
+ metadata: item.metadata,
166
+ clearsSlots: item.clearsSlots,
167
+ relatedReferences: item.relatedReferences,
168
+ };
169
+ }
170
+
171
+ function mentionReferenceAttrs(ref: AgentComposerReference) {
172
+ return {
173
+ label: ref.label,
174
+ icon: ref.icon || "file",
175
+ source: ref.source,
176
+ refType: ref.refType,
177
+ refId: ref.refId || null,
178
+ refPath: ref.refPath || null,
179
+ slotKey: ref.slotKey || null,
180
+ slotLabel: ref.slotLabel || null,
181
+ metadata: ref.metadata || null,
182
+ };
183
+ }
184
+
185
+ function referenceFromComposerReference(
186
+ ref: AgentComposerReference,
187
+ ): Reference {
188
+ return {
189
+ type:
190
+ ref.refType === "file"
191
+ ? "file"
192
+ : ref.refType === "agent"
193
+ ? "agent"
194
+ : ref.refType === "custom-agent"
195
+ ? "custom-agent"
196
+ : "mention",
197
+ path: ref.refPath || "",
198
+ name: ref.label,
199
+ source: ref.source || "",
200
+ refType: ref.refType,
201
+ refId: ref.refId || undefined,
202
+ slotKey: ref.slotKey,
203
+ slotLabel: ref.slotLabel,
204
+ metadata: ref.metadata,
205
+ };
206
+ }
207
+
208
+ function applySlotReferenceChanges(
209
+ current: AgentComposerReference[],
210
+ references: AgentComposerReference[],
211
+ ): AgentComposerReference[] {
212
+ let next = current;
213
+
214
+ const applyOne = (ref: AgentComposerReference) => {
215
+ for (const related of ref.relatedReferences ?? []) {
216
+ applyOne(related);
217
+ }
218
+ if (!ref.slotKey) return;
219
+ const cleared = new Set([ref.slotKey, ...(ref.clearsSlots ?? [])]);
220
+ next = next.filter((existing) => !cleared.has(existing.slotKey ?? ""));
221
+ next = [...next, ref];
222
+ };
223
+
224
+ for (const ref of references) {
225
+ applyOne(ref);
226
+ }
227
+
228
+ return next;
229
+ }
230
+
231
+ function removeSlotReference(
232
+ current: AgentComposerReference[],
233
+ ref: AgentComposerReference,
234
+ ): AgentComposerReference[] {
235
+ const removed = new Set([ref.slotKey, ...(ref.clearsSlots ?? [])]);
236
+ return current.filter((existing) => !removed.has(existing.slotKey ?? ""));
237
+ }
238
+
239
+ function slotReferenceTitle(ref: AgentComposerReference): string {
240
+ return ref.slotLabel ? `${ref.slotLabel}: ${ref.label}` : ref.label;
241
+ }
242
+
243
+ function metadataString(
244
+ metadata: Record<string, unknown> | undefined,
245
+ key: string,
246
+ ): string | null {
247
+ const value = metadata?.[key];
248
+ return typeof value === "string" && value.trim() ? value.trim() : null;
249
+ }
250
+
251
+ function trimVoiceContextValue(value: string, maxChars: number): string | null {
252
+ const trimmed = value.replace(/\0/g, "").trim();
253
+ if (!trimmed) return null;
254
+ if (trimmed.length <= maxChars) return trimmed;
255
+ return `${trimmed.slice(0, maxChars).trimEnd()}\n[truncated]`;
256
+ }
257
+
258
+ function filterMentionItemsForSlots(
259
+ items: MentionItem[],
260
+ slotReferences: AgentComposerReference[],
261
+ ): MentionItem[] {
262
+ return items.filter((item) => {
263
+ const requiredSlotKey = metadataString(item.metadata, "requiredSlotKey");
264
+ const requiredRefId = metadataString(item.metadata, "requiredRefId");
265
+ if (!requiredSlotKey || !requiredRefId) return true;
266
+ const selected = slotReferences.find(
267
+ (ref) => ref.slotKey === requiredSlotKey,
268
+ );
269
+ if (!selected?.refId) return true;
270
+ return selected.refId === requiredRefId;
271
+ });
272
+ }
273
+
274
+ function isDocumentAttachment(value: Record<string, unknown>): boolean {
275
+ if (value.type === "document") return true;
276
+ const contentType = String(value.contentType ?? "").toLowerCase();
277
+ const name = String(value.name ?? "").toLowerCase();
278
+ return contentType === "application/pdf" || name.endsWith(".pdf");
279
+ }
280
+
281
+ export function getOversizedDocumentAttachmentError(
282
+ attachments: ReadonlyArray<unknown>,
283
+ ): string | null {
284
+ for (const attachment of attachments) {
285
+ if (!attachment || typeof attachment !== "object") continue;
286
+ const candidate = attachment as Record<string, unknown>;
287
+ if (!isDocumentAttachment(candidate)) continue;
288
+ const file = candidate.file;
289
+ if (!(file instanceof File)) continue;
290
+ if (file.size <= MAX_DOCUMENT_ATTACHMENT_BYTES) continue;
291
+ const name =
292
+ typeof candidate.name === "string" && candidate.name.trim()
293
+ ? candidate.name
294
+ : file.name;
295
+ const mb = (file.size / 1024 / 1024).toFixed(1);
296
+ const maxMb = (MAX_DOCUMENT_ATTACHMENT_BYTES / 1024 / 1024).toFixed(0);
297
+ return `"${name}" is ${mb} MB — PDFs are capped at ${maxMb} MB to stay within message limits. Please reduce the file size or split it into smaller parts.`;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ export function getComposerSubmitIntentForEnterKey(
303
+ event: Pick<KeyboardEvent, "key" | "shiftKey" | "metaKey" | "ctrlKey">,
304
+ isMac: boolean,
305
+ ): ComposerSubmitIntent | null {
306
+ if (event.key !== "Enter" || event.shiftKey) return null;
307
+
308
+ const queuedModifierPressed = isMac ? event.metaKey : event.ctrlKey;
309
+ if (queuedModifierPressed) return "queued";
310
+
311
+ if (!event.metaKey && !event.ctrlKey) return "immediate";
312
+
313
+ return null;
314
+ }
315
+
316
+ export function insertComposerHardBreakAndScrollIntoView(
317
+ view: Pick<EditorView, "state" | "dispatch">,
318
+ ): boolean {
319
+ const hardBreak = view.state.schema.nodes.hardBreak;
320
+ if (!hardBreak) return false;
321
+
322
+ view.dispatch(
323
+ view.state.tr.replaceSelectionWith(hardBreak.create()).scrollIntoView(),
324
+ );
325
+ return true;
326
+ }
327
+
328
+ export function getComposerPopoverPosition(
329
+ view: Pick<EditorView, "coordsAtPos">,
330
+ pos: number,
331
+ ): { top: number; left: number } | null {
332
+ try {
333
+ const coords = view.coordsAtPos(pos);
334
+ if (!Number.isFinite(coords.top) || !Number.isFinite(coords.left)) {
335
+ return null;
336
+ }
337
+ return { top: coords.top, left: coords.left };
338
+ } catch {
339
+ return null;
340
+ }
341
+ }
342
+
343
+ export function displayableComposerModeMessage(options: {
344
+ messagePrefix: string;
345
+ trimmedText: string;
346
+ attachmentCount: number;
347
+ }): string {
348
+ const modePrompt =
349
+ options.trimmedText ||
350
+ (options.attachmentCount > 0 ? "Use the attached context." : "");
351
+ return `${options.messagePrefix}${modePrompt}`;
352
+ }
353
+
354
+ function uniquifyComposerImageFile(file: File): File {
355
+ if (!file.type.startsWith("image/")) return file;
356
+ const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;
357
+ return new File([file], uniqueName, { type: file.type });
358
+ }
359
+
360
+ function composerDocumentHasContent(doc: {
361
+ textContent: string;
362
+ descendants: (callback: (node: any) => boolean | void) => void;
363
+ }): boolean {
364
+ if (doc.textContent.trim().length > 0) return true;
365
+ let hasContent = false;
366
+ doc.descendants((node: any) => {
367
+ if (
368
+ node.type.name === "mentionReference" ||
369
+ node.type.name === "fileReference" ||
370
+ node.type.name === "skillReference"
371
+ ) {
372
+ hasContent = true;
373
+ return false;
374
+ }
375
+ return true;
376
+ });
377
+ return hasContent;
378
+ }
379
+
380
+ export function handleComposerFileDrop(options: {
381
+ event: Pick<DragEvent, "dataTransfer" | "preventDefault" | "stopPropagation">;
382
+ addAttachment: (file: File) => Promise<unknown>;
383
+ onError?: (error: unknown) => void;
384
+ }): boolean {
385
+ const droppedFiles = Array.from(options.event.dataTransfer?.files ?? []);
386
+ if (droppedFiles.length === 0) return false;
387
+
388
+ options.event.preventDefault();
389
+ options.event.stopPropagation();
390
+ const attachments = droppedFiles.map(uniquifyComposerImageFile);
391
+ void Promise.all(
392
+ attachments.map((file) => options.addAttachment(file)),
393
+ ).catch((error) => {
394
+ options.onError?.(error);
395
+ });
396
+ return true;
397
+ }
398
+
399
+ const BUILT_IN_COMMANDS: SlashCommand[] = [
400
+ { name: "clear", description: "Start a new chat", icon: "clear" },
401
+ { name: "new", description: "Start a new chat", icon: "new" },
402
+ { name: "history", description: "Browse all chats", icon: "history" },
403
+ { name: "plan", description: "Switch to read-only planning", icon: "plan" },
404
+ { name: "act", description: "Switch back to acting", icon: "act" },
405
+ { name: "help", description: "Show available commands", icon: "help" },
406
+ ];
407
+
408
+ function normalizeSlashCommandName(name: string): string {
409
+ return name.replace(/^\/+/, "").trim().toLowerCase();
410
+ }
411
+
412
+ function mergeSlashCommands(commands: SlashCommand[]): SlashCommand[] {
413
+ const seen = new Set<string>();
414
+ const merged: SlashCommand[] = [];
415
+ for (const command of commands) {
416
+ const name = normalizeSlashCommandName(command.name);
417
+ if (!name || seen.has(name)) continue;
418
+ seen.add(name);
419
+ merged.push({ ...command, name });
420
+ }
421
+ return merged;
422
+ }
423
+
424
+ function mergeSlashSkills(skills: SkillResult[]): SkillResult[] {
425
+ const seen = new Set<string>();
426
+ const merged: SkillResult[] = [];
427
+ for (const skill of skills) {
428
+ const key = `${skill.source ?? ""}:${skill.path ?? ""}:${skill.name}`;
429
+ if (!skill.name || seen.has(key)) continue;
430
+ seen.add(key);
431
+ merged.push(skill);
432
+ }
433
+ return merged;
434
+ }
435
+
436
+ const COMPOSER_MODE_CONFIGS: Record<
437
+ ComposerMode,
438
+ {
439
+ label: string;
440
+ icon: React.ComponentType<{ className?: string }>;
441
+ placeholder: string;
442
+ messagePrefix: string;
443
+ getContext: (prompt: string) => string;
444
+ beforeSend?: () => void;
445
+ }
446
+ > = {
447
+ skill: {
448
+ label: "Create Skill",
449
+ icon: IconBulb,
450
+ placeholder: "Describe the skill you want to create...",
451
+ messagePrefix: "Create a skill: ",
452
+ getContext: (prompt) =>
453
+ `The user wants to create an agent skill. Their description: "${prompt}"
454
+
455
+ Follow the create-skill pattern to build this. Before writing:
456
+
457
+ 1. **Determine the skill name** — derive a hyphen-case name from the description (e.g. "code review" → "code-review")
458
+ 2. **Determine the skill type** — Pattern (architectural rule), Workflow (step-by-step), or Generator (scaffolding)
459
+ 3. **Write the skill** as a personal resource at path "skills/<name>/SKILL.md" using the \`resources\` tool with \`action: "write"\`
460
+
461
+ The skill file MUST have YAML frontmatter with name and description (under 40 words), then markdown with:
462
+ - Clear rule/purpose statement
463
+ - Why this skill exists
464
+ - How to follow it (with code examples where helpful)
465
+ - Common violations to avoid
466
+ - Related skills
467
+
468
+ After creating, update the shared AGENTS.md resource to reference the new skill in its skills table.
469
+
470
+ Keep the skill concise (under 500 lines) and actionable.`,
471
+ },
472
+ job: {
473
+ label: "Schedule Task",
474
+ icon: IconClock,
475
+ placeholder: "Describe what should happen and when...",
476
+ messagePrefix: "Create a recurring job: ",
477
+ getContext: (prompt) =>
478
+ `The user wants to create a recurring job. Their description: "${prompt}"
479
+
480
+ Use the manage-jobs tool with action "create" to create this. You need to:
481
+ 1. Derive a hyphen-case name from the description
482
+ 2. Convert the schedule to a cron expression (e.g., "every weekday at 9am" → "0 9 * * 1-5")
483
+ 3. Write clear, self-contained instructions for what the agent should do each time the job runs
484
+ 4. Create it in personal scope
485
+
486
+ The job will run automatically on the schedule. Make the instructions specific — include which actions to call and what to do with results.`,
487
+ },
488
+ automation: {
489
+ label: "Create Automation",
490
+ icon: IconBolt,
491
+ placeholder: "Describe what you want to automate...",
492
+ messagePrefix: "Create an automation: ",
493
+ beforeSend: () => {
494
+ window.dispatchEvent(
495
+ new CustomEvent("agent-panel:set-mode", {
496
+ detail: { mode: "chat" },
497
+ }),
498
+ );
499
+ },
500
+ getContext: (prompt) =>
501
+ `The user wants to create a new automation. Scope: personal. Their description: "${prompt}"
502
+
503
+ Use manage-automations with action=define to create it. Ask clarifying questions if needed about what event to trigger on, conditions, and what actions to take.`,
504
+ },
505
+ extension: {
506
+ label: "Create Extension",
507
+ icon: IconTool,
508
+ placeholder: "Describe the interactive extension you want to build...",
509
+ messagePrefix: "Create an extension: ",
510
+ getContext: (prompt) =>
511
+ `The user wants to create an interactive extension (sandboxed mini-app). Their description: "${prompt}"
512
+
513
+ Use the create-extension action with Alpine.js HTML content. The extension runs as a sandboxed iframe with Tailwind CSS and modest default canvas padding. For edge-to-edge layouts, put data-extension-layout="full-bleed" on the outermost element.
514
+
515
+ After creating the extension, navigate the user to it with the path returned by create-extension; extension URLs may include a friendly "/extensions/<id>/<slug>" suffix.
516
+
517
+ Make the extension functional and visually polished. Extensions can use extensionFetch() for external API calls, appAction()/appFetch() for app operations and app data writes, extensionData for per-extension persistence, and dbQuery() only for read-only inspection of existing app tables.
518
+
519
+ Prefer appAction()/appFetch() for app data. Some actions return JSON strings for CLI compatibility, so parse string results before counting rows or reading arrays. Do not guess raw SQL table names or columns for app data; use dbQuery() only when the table is known to exist in the current schema.`,
520
+ },
521
+ };
522
+
523
+ function ComposerModeChip({
524
+ mode,
525
+ onRemove,
526
+ }: {
527
+ mode: ComposerMode;
528
+ onRemove: () => void;
529
+ }) {
530
+ const config = COMPOSER_MODE_CONFIGS[mode];
531
+ const Icon = config.icon;
532
+ return (
533
+ <span className="inline-flex items-center gap-1 rounded-md border border-border bg-muted/50 px-1.5 py-0.5 text-xs font-medium text-foreground">
534
+ <Icon className="h-3 w-3 text-muted-foreground" />
535
+ {config.label}
536
+ <button
537
+ type="button"
538
+ onClick={onRemove}
539
+ className="ms-0.5 rounded-sm text-muted-foreground hover:text-foreground cursor-pointer"
540
+ >
541
+ <IconX className="h-3 w-3" />
542
+ </button>
543
+ </span>
544
+ );
545
+ }
546
+
547
+ type ExecMode = "build" | "plan";
548
+
549
+ export interface TiptapComposerProps {
550
+ placeholder?: string;
551
+ disabled?: boolean;
552
+ focusRef?: React.Ref<TiptapComposerHandle>;
553
+ /** Programmatically seed the editor with plain text. */
554
+ initialText?: string;
555
+ /** Stable key used to re-apply the seeded text. */
556
+ initialTextKey?: string | number;
557
+ /**
558
+ * When provided, called instead of composerRuntime.send(). Used for queue
559
+ * mode and standalone prompt popovers. Receives the live composer
560
+ * attachments so callers (e.g. PromptComposer) can surface uploaded files.
561
+ */
562
+ onSubmit?: (
563
+ text: string,
564
+ references: Reference[],
565
+ attachments?: ReadonlyArray<unknown>,
566
+ options?: TiptapComposerSubmitOptions,
567
+ ) => void;
568
+ /** Return false to stop a submit before it enters the chat runtime. */
569
+ onBeforeSubmit?: () => boolean | Promise<boolean>;
570
+ /**
571
+ * Clear the editor after an onSubmit handler runs. Standalone workflows that
572
+ * may fail outside the composer can keep the draft visible for quick edits.
573
+ */
574
+ clearOnSubmit?: boolean;
575
+ /** Called whenever the plain editor text changes. */
576
+ onTextChange?: (text: string) => void;
577
+ /** Custom action button (e.g. stop button) to render instead of the default send button. */
578
+ actionButton?: React.ReactNode;
579
+ /** Extra button to render alongside the primary action. */
580
+ extraActionButton?: React.ReactNode;
581
+ /**
582
+ * Stop control shown instead of the disabled send button while the composer
583
+ * has no sendable content. Typing or attaching content restores send.
584
+ */
585
+ stopButton?: React.ReactNode;
586
+ /** Custom attachment button to render instead of ComposerPrimitive.AddAttachment. */
587
+ attachButton?: React.ReactNode;
588
+ /** Custom host-owned control rendered next to the attachment affordance. */
589
+ modeControl?: React.ReactNode;
590
+ /** Explicit host-owned toolbar slot rendered next to the attachment affordance. */
591
+ toolbarSlot?: React.ReactNode;
592
+ /** Shared sizing/layout variant for host surfaces. Default keeps sidebar behavior. */
593
+ layoutVariant?: AgentComposerLayoutVariant;
594
+ /** Additional slash commands surfaced in the shared / menu. */
595
+ slashCommands?: SlashCommand[];
596
+ /** Additional slash skills surfaced in the shared / menu. */
597
+ slashSkills?: SkillResult[];
598
+ /** Include built-in sidebar slash commands like /clear and /help. Default true. */
599
+ includeDefaultSlashCommands?: boolean;
600
+ /** Include app-discovered skills from the default agent endpoint. Default true. */
601
+ includeDefaultSlashSkills?: boolean;
602
+ /** Called when a slash command (e.g. /clear, /help) is executed */
603
+ onSlashCommand?: (command: string) => void;
604
+ /** Current execution mode (build/plan) */
605
+ execMode?: ExecMode;
606
+ /** Callback to change execution mode */
607
+ onExecModeChange?: (mode: ExecMode) => void;
608
+ /** Disable Plan mode while leaving Act mode available. */
609
+ planModeDisabled?: boolean;
610
+ /** Explanation shown next to the disabled Plan option. */
611
+ planModeDisabledReason?: string;
612
+ /** Show the microphone button for voice dictation. Defaults to DEFAULT_VOICE_DICTATION_ENABLED. */
613
+ voiceEnabled?: boolean;
614
+ /** Selected model override for this conversation */
615
+ selectedModel?: string;
616
+ /** Selected reasoning effort override for this conversation */
617
+ selectedEffort?: ReasoningEffort;
618
+ /** Available models grouped by provider */
619
+ availableModels?: Array<{
620
+ engine: string;
621
+ label: string;
622
+ models: string[];
623
+ configured: boolean;
624
+ }>;
625
+ /** Whether the model list is still being resolved. */
626
+ modelListLoading?: boolean;
627
+ /** Callback when user picks a model */
628
+ onModelChange?: (model: string, engine: string) => void;
629
+ /** Callback when user picks a reasoning effort */
630
+ onEffortChange?: (effort: ReasoningEffort) => void;
631
+ /**
632
+ * Disable Builder/provider status polling for hosts that supply provider
633
+ * state through another channel, such as Electron IPC.
634
+ */
635
+ providerConnectStatusEnabled?: boolean;
636
+ /**
637
+ * Override the Builder.io connect action in the model picker. When provided,
638
+ * clicking "Connect Builder.io" calls this instead of opening a browser popup.
639
+ * Used by the Electron desktop app to route through the native IPC handler.
640
+ */
641
+ onConnectProvider?: () => void;
642
+ /** Route local runtime setup through the host's native bridge. */
643
+ onConnectLocalRuntime?: (engine: string) => void;
644
+ /**
645
+ * Optional secondary model menu (e.g. an image-generation model) rendered as
646
+ * an extra section inside the model picker. Opt-in; omit for chat-only apps.
647
+ */
648
+ imageModelMenu?: ComposerImageModelMenu;
649
+ /** Stable scope for persisted drafts, usually the active thread or tab id. */
650
+ draftScope?: string;
651
+ /** Keyed context nuggets staged for the next submitted prompt. */
652
+ contextItems?: AgentChatContextItem[];
653
+ /** Remove a staged context nugget by key. */
654
+ onRemoveContextItem?: (key: string) => void;
655
+ /**
656
+ * Controls the "+" menu next to the composer. `"full"` (default) shows the
657
+ * normal Upload / Skill / Job / Automation / Tool / MCP picker. `"upload-only"`
658
+ * collapses it to a single button that opens the file picker directly.
659
+ * `"hidden"` hides attachment controls for text-only prompt surfaces.
660
+ */
661
+ plusMenuMode?: "full" | "upload-only" | "hidden";
662
+ /**
663
+ * When true and the composer is running inside the Builder.io webview/iframe,
664
+ * intercept "build me an app/agent" prompts and forward them to the parent
665
+ * Builder chat via `builder.submitChat` instead of sending to the local
666
+ * agent. Off by default — the chat sidebar opts in; standalone prompt
667
+ * forms (NewWorkspaceAppFlow, etc.) handle delegation themselves with
668
+ * extra context (vault keys, computed app ids) that the raw composer
669
+ * text lacks.
670
+ */
671
+ interceptBuildRequestsForBuilder?: boolean;
672
+ /**
673
+ * Called when a drag-drop or paste attachment fails (e.g. unsupported format,
674
+ * size cap). Use this to surface a visible error in the parent chat surface
675
+ * rather than silently swallowing the problem.
676
+ */
677
+ onAttachmentError?: (message: string) => void;
678
+ }
679
+
680
+ function plainTextToDoc(text: string) {
681
+ const lines = text.length > 0 ? text.split(/\r?\n/) : [""];
682
+ return {
683
+ type: "doc",
684
+ content: lines.map((line) => ({
685
+ type: "paragraph",
686
+ content: line ? [{ type: "text", text: line }] : [],
687
+ })),
688
+ };
689
+ }
690
+
691
+ /** Tiptap keeps the Editor object truthy after destroy but clears commandManager. */
692
+ export function isComposerEditorUsable<T extends { isDestroyed?: boolean }>(
693
+ editor: T | null | undefined,
694
+ ): editor is T {
695
+ return Boolean(editor && editor.isDestroyed !== true);
696
+ }
697
+
698
+ export function createTiptapComposerExtensions(
699
+ getPlaceholder: () => string | undefined,
700
+ ) {
701
+ return [
702
+ StarterKit.configure({
703
+ heading: false,
704
+ horizontalRule: false,
705
+ bulletList: false,
706
+ orderedList: false,
707
+ listItem: false,
708
+ listKeymap: false,
709
+ blockquote: false,
710
+ codeBlock: false,
711
+ strike: false,
712
+ italic: false,
713
+ bold: false,
714
+ code: false,
715
+ dropcursor: false,
716
+ gapcursor: false,
717
+ link: false,
718
+ trailingNode: false,
719
+ underline: false,
720
+ }),
721
+ Placeholder.configure({
722
+ placeholder: () => getPlaceholder() ?? "",
723
+ emptyEditorClass: "is-editor-empty",
724
+ showOnlyCurrent: false,
725
+ }),
726
+ FileReference,
727
+ SkillReference,
728
+ MentionReference,
729
+ ];
730
+ }
731
+
732
+ function ModeSelector({
733
+ mode,
734
+ onChange,
735
+ planModeDisabled = false,
736
+ planModeDisabledReason = "Open Agent Native Desktop to use Plan mode.",
737
+ }: {
738
+ mode: ExecMode;
739
+ onChange: (mode: ExecMode) => void;
740
+ planModeDisabled?: boolean;
741
+ planModeDisabledReason?: string;
742
+ }) {
743
+ const [open, setOpen] = useState(false);
744
+
745
+ return (
746
+ <Popover open={open} onOpenChange={setOpen}>
747
+ <PopoverTrigger asChild>
748
+ <button
749
+ type="button"
750
+ aria-label={mode === "build" ? "Act mode" : "Plan mode"}
751
+ data-agent-composer-slot="mode-button"
752
+ className="agent-composer-mode-button shrink-0 flex items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground"
753
+ >
754
+ {mode === "build" ? "Act" : "Plan"}
755
+ <IconChevronDown className="h-3 w-3 opacity-60" />
756
+ </button>
757
+ </PopoverTrigger>
758
+ <PopoverContent
759
+ side="top"
760
+ align="end"
761
+ sideOffset={6}
762
+ collisionPadding={8}
763
+ data-agent-native-composer-popover="true"
764
+ className="z-[260] w-60 rounded-lg border-border p-0 py-1 shadow-lg"
765
+ style={{ fontSize: 13 }}
766
+ >
767
+ <button
768
+ type="button"
769
+ onClick={() => {
770
+ onChange("build");
771
+ setOpen(false);
772
+ }}
773
+ className="flex w-full items-center gap-3 px-3 py-2 hover:bg-accent/50 text-start"
774
+ >
775
+ <IconPencil className="h-4 w-4 shrink-0 text-muted-foreground" />
776
+ <div className="flex-1 min-w-0">
777
+ <span className="font-medium text-foreground text-[13px]">Act</span>
778
+ <p className="text-[11px] text-muted-foreground mt-0.5">
779
+ Use tools and make approved changes
780
+ </p>
781
+ </div>
782
+ {mode === "build" && (
783
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
784
+ )}
785
+ </button>
786
+ <button
787
+ type="button"
788
+ disabled={planModeDisabled}
789
+ title={planModeDisabled ? planModeDisabledReason : undefined}
790
+ onClick={() => {
791
+ if (planModeDisabled) return;
792
+ onChange("plan");
793
+ setOpen(false);
794
+ }}
795
+ className={`flex w-full items-center gap-3 px-3 py-2 text-start ${
796
+ planModeDisabled
797
+ ? "cursor-not-allowed opacity-60"
798
+ : "hover:bg-accent/50"
799
+ }`}
800
+ >
801
+ <IconClipboardList className="h-4 w-4 shrink-0 text-muted-foreground" />
802
+ <div className="flex-1 min-w-0">
803
+ <span className="font-medium text-foreground text-[13px]">
804
+ Plan
805
+ </span>
806
+ <p className="text-[11px] text-muted-foreground mt-0.5">
807
+ {planModeDisabled
808
+ ? planModeDisabledReason
809
+ : "Read-only research and approval first"}
810
+ </p>
811
+ </div>
812
+ {mode === "plan" && !planModeDisabled && (
813
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
814
+ )}
815
+ </button>
816
+ </PopoverContent>
817
+ </Popover>
818
+ );
819
+ }
820
+
821
+ const FRIENDLY_MODEL_NAMES: Record<string, string> = {
822
+ auto: "Default model",
823
+ "codex-cli": "ChatGPT subscription",
824
+ "claude-fable-5": "Fable 5",
825
+ "kimi-k2-5": "Kimi K2.5",
826
+ "deepseek-v3-1": "DeepSeek v3.1",
827
+ "z-ai/glm-5.2": "GLM 5.2",
828
+ };
829
+
830
+ export const MODEL_SELECTOR_POPOVER_STYLE = {
831
+ fontSize: 13,
832
+ maxHeight: "min(500px, var(--radix-popover-content-available-height, 500px))",
833
+ } satisfies React.CSSProperties;
834
+
835
+ export function shouldShowModelSelectorSkeleton(
836
+ isLoading: boolean,
837
+ engineCount: number,
838
+ ): boolean {
839
+ return isLoading && engineCount === 0;
840
+ }
841
+
842
+ function friendlyModelName(model: string): string {
843
+ if (FRIENDLY_MODEL_NAMES[model]) return FRIENDLY_MODEL_NAMES[model];
844
+ // Claude: claude-{tier}-{major}[-minor][-dateYYYYMMDD] → Tier Major[.Minor]
845
+ const claude = model.match(
846
+ /^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d+))?(?:-\d{8,})?$/,
847
+ );
848
+ if (claude) {
849
+ const tier = claude[1][0].toUpperCase() + claude[1].slice(1);
850
+ return `${tier} ${claude[2]}${claude[3] ? `.${claude[3]}` : ""}`;
851
+ }
852
+ // GPT: gpt-{major}-{minor}[-suffix] or gpt-{major}.{minor}[-suffix]
853
+ if (model.startsWith("gpt-")) {
854
+ const rest = model.slice(4);
855
+ const gpt = rest.match(/^(\d+)[.-](\d+)(?:[.-](.+))?$/);
856
+ if (gpt) {
857
+ const suffix = gpt[3]
858
+ ? " " +
859
+ gpt[3]
860
+ .split("-")
861
+ .map((s) => s[0].toUpperCase() + s.slice(1))
862
+ .join(" ")
863
+ : "";
864
+ return `GPT-${gpt[1]}.${gpt[2]}${suffix}`;
865
+ }
866
+ return `GPT-${rest}`;
867
+ }
868
+ if (/^o\d/.test(model)) return model;
869
+ // Gemini: gemini-{major}-{minor}-{variant}[-preview] → Gemini Major.Minor Variant
870
+ const geminiVersioned = model.match(
871
+ /^gemini-(\d+)-(\d+)-(.+?)(?:-preview)?$/,
872
+ );
873
+ if (geminiVersioned) {
874
+ const variant = geminiVersioned[3]
875
+ .split("-")
876
+ .map((s) => s[0].toUpperCase() + s.slice(1))
877
+ .join(" ");
878
+ return `Gemini ${geminiVersioned[1]}.${geminiVersioned[2]} ${variant}`;
879
+ }
880
+ // Gemini: gemini-{version.parts}[-preview] → Gemini Version Parts
881
+ const gemini = model.match(/^gemini-(.+?)(?:-preview)?$/);
882
+ if (gemini) {
883
+ const parts = gemini[1]
884
+ .split("-")
885
+ .map((s) => s[0].toUpperCase() + s.slice(1))
886
+ .join(" ");
887
+ return `Gemini ${parts}`;
888
+ }
889
+ return model;
890
+ }
891
+
892
+ export function compactComposerModelName(model: string): string {
893
+ const gpt56Variant = model.match(
894
+ /^(?:openai\/)?gpt-5[.-]6[.-](sol|terra|luna)$/i,
895
+ )?.[1];
896
+ if (gpt56Variant) {
897
+ return gpt56Variant[0].toUpperCase() + gpt56Variant.slice(1).toLowerCase();
898
+ }
899
+ return friendlyModelName(model);
900
+ }
901
+
902
+ export function compactComposerReasoningEffortLabel(
903
+ effort: ReasoningEffort,
904
+ ): string {
905
+ if (effort === "medium" || effort === "auto") return "Med";
906
+ if (effort === "minimal") return "Min";
907
+ if (effort === "xhigh") return "XHigh";
908
+ return reasoningEffortLabel(effort);
909
+ }
910
+
911
+ /**
912
+ * Deduplicate models to only the latest version per family.
913
+ * e.g. [opus-4-7, opus-4-6, opus-4-5] → [opus-4-7]
914
+ */
915
+ function latestModelsOnly(models: string[]): string[] {
916
+ const seen = new Set<string>();
917
+ return models.filter((m) => {
918
+ // Claude: family = tier (opus/sonnet/haiku)
919
+ const claude = m.match(/^claude-(opus|sonnet|haiku)-/);
920
+ if (claude) {
921
+ if (seen.has(claude[1])) return false;
922
+ seen.add(claude[1]);
923
+ return true;
924
+ }
925
+ // GPT: family = gpt-{major} (e.g. gpt-5.6-sol and gpt-5.6-luna are different)
926
+ // OpenAI reasoning: each is its own family
927
+ // Gemini: family = gemini-{major} + variant
928
+ const gemini = m.match(/^gemini-(\d+(?:\.\d+)?)-(.+?)(?:-preview)?$/);
929
+ if (gemini) {
930
+ const family = gemini[2]; // flash, pro, etc.
931
+ if (seen.has(`gemini-${family}`)) return false;
932
+ seen.add(`gemini-${family}`);
933
+ return true;
934
+ }
935
+ return true;
936
+ });
937
+ }
938
+
939
+ /**
940
+ * Optional secondary model menu for apps that drive a separate generation model
941
+ * alongside the chat LLM (e.g. the Assets app's image-generation model). When
942
+ * provided, the model picker renders an extra collapsible section so the user
943
+ * can see and pick both "what reasons about my request" (the chat model) and
944
+ * "what produces the output" (this model). Opt-in — omit it and nothing changes.
945
+ */
946
+ export interface ComposerImageModelMenu {
947
+ /** Currently-selected model id for this secondary menu. */
948
+ value: string;
949
+ /** Selectable options (stable id + human label). */
950
+ options: Array<{ value: string; label: string }>;
951
+ /** Invoked when the user picks a different option. */
952
+ onChange: (value: string) => void;
953
+ /** Section header. Defaults to "Image model". */
954
+ label?: string;
955
+ }
956
+
957
+ export function getComposerReasoningEffortOptions(
958
+ model: string,
959
+ ): ReasoningEffort[] {
960
+ return model === "auto"
961
+ ? ["low", "medium", "high", "xhigh", "max"]
962
+ : getReasoningEffortOptionsForModel(model);
963
+ }
964
+
965
+ function ModelSelector({
966
+ model,
967
+ effort,
968
+ engines,
969
+ modelListLoading = false,
970
+ onChange,
971
+ onEffortChange,
972
+ providerConnectStatusEnabled = true,
973
+ onConnectProvider,
974
+ onConnectLocalRuntime,
975
+ imageModel,
976
+ }: {
977
+ model: string;
978
+ effort?: ReasoningEffort;
979
+ engines: Array<{
980
+ engine: string;
981
+ label: string;
982
+ models: string[];
983
+ configured: boolean;
984
+ }>;
985
+ modelListLoading?: boolean;
986
+ onChange: (model: string, engine: string) => void;
987
+ onEffortChange?: (effort: ReasoningEffort) => void;
988
+ providerConnectStatusEnabled?: boolean;
989
+ onConnectProvider?: () => void;
990
+ onConnectLocalRuntime?: (engine: string) => void;
991
+ imageModel?: ComposerImageModelMenu;
992
+ }) {
993
+ const adapters = useComposerRuntimeAdapters();
994
+ const t = adapters.translate!;
995
+ const reasoning = adapters.models?.reasoning;
996
+ const defaultEffort = reasoning?.defaultEffort ?? DEFAULT_REASONING_EFFORT;
997
+ const [open, setOpen] = useState(false);
998
+ const autoModelGroup = engines.find((group) => group.models.includes("auto"));
999
+ const providerGroups = useMemo(
1000
+ () =>
1001
+ engines
1002
+ .map((group) => ({
1003
+ ...group,
1004
+ models: group.models.filter((candidate) => candidate !== "auto"),
1005
+ }))
1006
+ .filter((group) => group.models.length > 0),
1007
+ [engines],
1008
+ );
1009
+ const effortOptions =
1010
+ reasoning?.getOptionsForModel?.(model) ??
1011
+ getComposerReasoningEffortOptions(model);
1012
+ const selectedEffort =
1013
+ reasoning?.resolve?.(model, effort) ??
1014
+ resolveReasoningEffortSelection(model, effort ?? defaultEffort);
1015
+ const effortLabel = reasoning?.label ?? reasoningEffortLabel;
1016
+
1017
+ // Collapse non-selected families by default. The family containing the
1018
+ // currently-selected model stays expanded so the user sees their pick at
1019
+ // a glance; clicking another family's header expands it inline.
1020
+ const selectedGroupKey = useMemo(() => {
1021
+ const found = providerGroups.find((g) => g.models.includes(model));
1022
+ return found ? `${found.engine}:${found.label}` : null;
1023
+ }, [model, providerGroups]);
1024
+
1025
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
1026
+ () => new Set(selectedGroupKey ? [selectedGroupKey] : []),
1027
+ );
1028
+
1029
+ // Reset expansion when the popover re-opens so the picker always lands
1030
+ // on the "selected family expanded, others collapsed" view.
1031
+ useEffect(() => {
1032
+ if (open) {
1033
+ setExpandedGroups(new Set(selectedGroupKey ? [selectedGroupKey] : []));
1034
+ }
1035
+ }, [open, selectedGroupKey]);
1036
+
1037
+ const toggleGroup = useCallback((key: string) => {
1038
+ setExpandedGroups((prev) => {
1039
+ const next = new Set(prev);
1040
+ if (next.has(key)) next.delete(key);
1041
+ else next.add(key);
1042
+ return next;
1043
+ });
1044
+ }, []);
1045
+
1046
+ // The reasoning effort list is collapsed by default — it's a secondary
1047
+ // control most users don't touch, so it stays tucked behind a header that
1048
+ // reveals the current effort at a glance. Reset to collapsed on each open.
1049
+ const [reasoningExpanded, setReasoningExpanded] = useState(false);
1050
+ useEffect(() => {
1051
+ if (open) setReasoningExpanded(false);
1052
+ }, [open]);
1053
+
1054
+ // The optional image-model section follows the same collapsed-by-default
1055
+ // pattern; the current selection shows next to the header.
1056
+ const [imageExpanded, setImageExpanded] = useState(false);
1057
+ useEffect(() => {
1058
+ if (open) setImageExpanded(false);
1059
+ }, [open]);
1060
+ const imageModelLabel =
1061
+ imageModel?.options.find((option) => option.value === imageModel.value)
1062
+ ?.label ?? imageModel?.value;
1063
+ const showModelListSkeleton = shouldShowModelSelectorSkeleton(
1064
+ modelListLoading,
1065
+ engines.length,
1066
+ );
1067
+
1068
+ // When Builder.io isn't connected, surface a one-click connect path —
1069
+ // it unlocks every model family (Claude, OpenAI, Gemini) without the
1070
+ // user having to paste individual API keys.
1071
+ const builderFlow = adapters.builder!.useConnectFlow!({
1072
+ enabled: providerConnectStatusEnabled,
1073
+ trackingSource: "composer_builder_cta",
1074
+ });
1075
+ const hasConfiguredBuilderModels = providerGroups.some(
1076
+ (group) => group.engine === "builder" && group.configured,
1077
+ );
1078
+ const showBuilderCta =
1079
+ (builderFlow.hasFetchedStatus ||
1080
+ (!providerConnectStatusEnabled && !!onConnectProvider)) &&
1081
+ !builderFlow.configured &&
1082
+ !builderFlow.envManaged &&
1083
+ !hasConfiguredBuilderModels;
1084
+ const openLlmSettings = useCallback(() => {
1085
+ try {
1086
+ window.location.hash = "llm";
1087
+ } catch {}
1088
+ window.dispatchEvent(new CustomEvent("agent-panel:open-settings"));
1089
+ setOpen(false);
1090
+ }, []);
1091
+ const connectLocalRuntime = useCallback(
1092
+ (engine: string) => {
1093
+ onConnectLocalRuntime?.(engine);
1094
+ setOpen(false);
1095
+ },
1096
+ [onConnectLocalRuntime],
1097
+ );
1098
+
1099
+ return (
1100
+ <Popover open={open} onOpenChange={setOpen}>
1101
+ <PopoverTrigger asChild>
1102
+ <button
1103
+ type="button"
1104
+ data-agent-composer-slot="model-button"
1105
+ aria-label={`Model: ${friendlyModelName(model)}${
1106
+ effortOptions.length > 0
1107
+ ? `. Reasoning: ${effortLabel(selectedEffort)}`
1108
+ : ""
1109
+ }`}
1110
+ className="agent-composer-model-button flex min-w-0 max-w-[10.5rem] shrink items-center gap-1 rounded-md px-2 py-1 text-[12px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground"
1111
+ >
1112
+ <span className="min-w-0 truncate">
1113
+ {compactComposerModelName(model)}
1114
+ </span>
1115
+ {effortOptions.length > 0 && (
1116
+ <span className="agent-composer-model-effort min-w-0 shrink truncate text-muted-foreground/70">
1117
+ · {compactComposerReasoningEffortLabel(selectedEffort)}
1118
+ </span>
1119
+ )}
1120
+ <IconChevronDown className="h-3 w-3 shrink-0 opacity-60" />
1121
+ </button>
1122
+ </PopoverTrigger>
1123
+ <PopoverContent
1124
+ side="top"
1125
+ align="end"
1126
+ sideOffset={6}
1127
+ collisionPadding={8}
1128
+ data-agent-native-composer-popover="true"
1129
+ className="z-[260] box-border w-72 overflow-y-auto rounded-lg border-border p-0 py-1 shadow-lg"
1130
+ style={MODEL_SELECTOR_POPOVER_STYLE}
1131
+ >
1132
+ {showBuilderCta && (
1133
+ <>
1134
+ <button
1135
+ type="button"
1136
+ onClick={() => {
1137
+ if (onConnectProvider) {
1138
+ onConnectProvider();
1139
+ } else {
1140
+ builderFlow.start();
1141
+ }
1142
+ }}
1143
+ disabled={!onConnectProvider && builderFlow.connecting}
1144
+ className="flex w-full items-start gap-2 px-3 py-2 text-start hover:bg-accent/50 disabled:opacity-60"
1145
+ >
1146
+ <IconPlugConnected className="h-4 w-4 shrink-0 mt-0.5 text-blue-500" />
1147
+ <span className="flex-1 min-w-0">
1148
+ <span className="block text-[12px] font-medium text-foreground">
1149
+ {!onConnectProvider && builderFlow.connecting
1150
+ ? t("agentPanel.connectingBuilder", {
1151
+ defaultValue: "Connecting Builder.io…",
1152
+ })
1153
+ : t("agentPanel.connectBuilderIo", {
1154
+ defaultValue: "Connect Builder.io",
1155
+ })}
1156
+ </span>
1157
+ <span className="block text-[11px] text-muted-foreground">
1158
+ {t("agentPanel.builderModelCredits", {
1159
+ defaultValue: "Free credits for Claude, OpenAI & Gemini",
1160
+ })}
1161
+ </span>
1162
+ </span>
1163
+ </button>
1164
+ <button
1165
+ type="button"
1166
+ onClick={openLlmSettings}
1167
+ className="flex w-full items-start gap-2 px-3 py-2 text-start hover:bg-accent/50"
1168
+ >
1169
+ <IconKey className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
1170
+ <span className="min-w-0 flex-1">
1171
+ <span className="block text-[12px] font-medium text-foreground">
1172
+ {t("agentPanel.addOwnKeys", {
1173
+ defaultValue: "Add your own keys",
1174
+ })}
1175
+ </span>
1176
+ <span className="block text-[11px] text-muted-foreground">
1177
+ {t("agentPanel.configureProviderKeys", {
1178
+ defaultValue:
1179
+ "Configure Anthropic, OpenAI, or another provider",
1180
+ })}
1181
+ </span>
1182
+ </span>
1183
+ </button>
1184
+ <div className="my-1 border-t border-border" />
1185
+ </>
1186
+ )}
1187
+ {imageModel && imageModel.options.length > 0 && (
1188
+ <>
1189
+ <div className="flex items-center hover:bg-accent/30">
1190
+ <button
1191
+ type="button"
1192
+ aria-expanded={imageExpanded}
1193
+ onClick={() => setImageExpanded((prev) => !prev)}
1194
+ className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1195
+ >
1196
+ {imageExpanded ? (
1197
+ <IconChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
1198
+ ) : (
1199
+ <IconChevronRight className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1200
+ )}
1201
+ <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1202
+ {imageModel.label ?? "Image model"}
1203
+ </span>
1204
+ {!imageExpanded && imageModelLabel && (
1205
+ <span className="text-[11px] text-muted-foreground/80 truncate">
1206
+ {imageModelLabel}
1207
+ </span>
1208
+ )}
1209
+ </button>
1210
+ </div>
1211
+ {imageExpanded &&
1212
+ imageModel.options.map((option) => (
1213
+ <button
1214
+ key={option.value}
1215
+ type="button"
1216
+ onClick={() => {
1217
+ imageModel.onChange(option.value);
1218
+ setImageExpanded(false);
1219
+ }}
1220
+ className="flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start hover:bg-accent/50"
1221
+ >
1222
+ <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1223
+ {option.label}
1224
+ </span>
1225
+ {option.value === imageModel.value && (
1226
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1227
+ )}
1228
+ </button>
1229
+ ))}
1230
+ <div className="my-1 border-t border-border" />
1231
+ </>
1232
+ )}
1233
+ {showModelListSkeleton && <ModelSelectorSkeleton />}
1234
+ {autoModelGroup && (
1235
+ <button
1236
+ type="button"
1237
+ onClick={() => {
1238
+ onChange("auto", autoModelGroup.engine);
1239
+ setOpen(false);
1240
+ }}
1241
+ className="flex w-full items-center gap-3 px-3 py-1.5 text-start hover:bg-accent/50"
1242
+ >
1243
+ <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1244
+ Auto
1245
+ </span>
1246
+ {model === "auto" && (
1247
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1248
+ )}
1249
+ </button>
1250
+ )}
1251
+ {autoModelGroup && providerGroups.length > 0 && (
1252
+ <div className="my-1 border-t border-border" />
1253
+ )}
1254
+ {providerGroups.map((group) => {
1255
+ const models = latestModelsOnly(group.models);
1256
+ const groupKey = `${group.engine}:${group.label}`;
1257
+ const isExpanded = expandedGroups.has(groupKey);
1258
+ const ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;
1259
+ return (
1260
+ <div key={groupKey}>
1261
+ <div className="flex items-center hover:bg-accent/30">
1262
+ <button
1263
+ type="button"
1264
+ aria-expanded={isExpanded}
1265
+ onClick={() => toggleGroup(groupKey)}
1266
+ className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1267
+ >
1268
+ <ChevronIcon className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1269
+ <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1270
+ {group.label}
1271
+ </span>
1272
+ {!isExpanded && groupKey === selectedGroupKey && (
1273
+ <span className="text-[11px] text-muted-foreground/80 truncate">
1274
+ {friendlyModelName(model)}
1275
+ </span>
1276
+ )}
1277
+ </button>
1278
+ {!group.configured && (
1279
+ <button
1280
+ type="button"
1281
+ className="text-[10px] text-muted-foreground/60 hover:text-foreground cursor-pointer pe-3 py-1.5"
1282
+ onClick={() =>
1283
+ group.engine === "codex-cli" && onConnectLocalRuntime
1284
+ ? connectLocalRuntime(group.engine)
1285
+ : openLlmSettings()
1286
+ }
1287
+ >
1288
+ {group.engine === "codex-cli" && onConnectLocalRuntime
1289
+ ? "sign in"
1290
+ : "needs API key"}
1291
+ </button>
1292
+ )}
1293
+ </div>
1294
+ {isExpanded &&
1295
+ models.map((m) => (
1296
+ <button
1297
+ key={m}
1298
+ type="button"
1299
+ onClick={() => {
1300
+ if (!group.configured) {
1301
+ if (
1302
+ group.engine === "codex-cli" &&
1303
+ onConnectLocalRuntime
1304
+ ) {
1305
+ connectLocalRuntime(group.engine);
1306
+ } else {
1307
+ openLlmSettings();
1308
+ }
1309
+ return;
1310
+ }
1311
+ onChange(m, group.engine);
1312
+ const nextOptions = getReasoningEffortOptionsForModel(m);
1313
+ if (
1314
+ nextOptions.length > 0 &&
1315
+ !nextOptions.includes(selectedEffort)
1316
+ ) {
1317
+ onEffortChange?.(defaultEffort);
1318
+ }
1319
+ setOpen(false);
1320
+ }}
1321
+ className={`flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start ${
1322
+ group.configured
1323
+ ? "hover:bg-accent/50"
1324
+ : "opacity-40 cursor-default"
1325
+ }`}
1326
+ >
1327
+ <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1328
+ {friendlyModelName(m)}
1329
+ </span>
1330
+ {m === model && group.configured && (
1331
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1332
+ )}
1333
+ </button>
1334
+ ))}
1335
+ </div>
1336
+ );
1337
+ })}
1338
+ {!showModelListSkeleton && effortOptions.length > 0 && (
1339
+ <>
1340
+ <div className="my-1 border-t border-border" />
1341
+ <div className="flex items-center hover:bg-accent/30">
1342
+ <button
1343
+ type="button"
1344
+ aria-expanded={reasoningExpanded}
1345
+ onClick={() => setReasoningExpanded((prev) => !prev)}
1346
+ className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1347
+ >
1348
+ {reasoningExpanded ? (
1349
+ <IconChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
1350
+ ) : (
1351
+ <IconChevronRight className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1352
+ )}
1353
+ <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1354
+ Reasoning
1355
+ </span>
1356
+ {!reasoningExpanded && (
1357
+ <span className="text-[11px] text-muted-foreground/80 truncate">
1358
+ {effortLabel(selectedEffort)}
1359
+ </span>
1360
+ )}
1361
+ </button>
1362
+ </div>
1363
+ {reasoningExpanded &&
1364
+ effortOptions.map((option) => (
1365
+ <button
1366
+ key={option}
1367
+ type="button"
1368
+ onClick={() => onEffortChange?.(option)}
1369
+ className="flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start hover:bg-accent/50"
1370
+ >
1371
+ <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1372
+ {effortLabel(option)}
1373
+ </span>
1374
+ {option === selectedEffort && (
1375
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1376
+ )}
1377
+ </button>
1378
+ ))}
1379
+ </>
1380
+ )}
1381
+ </PopoverContent>
1382
+ </Popover>
1383
+ );
1384
+ }
1385
+
1386
+ function ModelSelectorSkeleton() {
1387
+ return (
1388
+ <div
1389
+ className="space-y-1 px-2 py-2"
1390
+ role="status"
1391
+ aria-label="Loading models"
1392
+ >
1393
+ <span className="sr-only">Loading models…</span>
1394
+ {["w-24", "w-32", "w-20", "w-28"].map((width, index) => (
1395
+ <div key={index} className="flex items-center gap-1.5 px-1 py-1.5">
1396
+ <Skeleton className="size-3 rounded-sm" />
1397
+ <Skeleton className={`h-3 ${width}`} />
1398
+ </div>
1399
+ ))}
1400
+ </div>
1401
+ );
1402
+ }
1403
+
1404
+ type PopoverState = {
1405
+ type: "@" | "/";
1406
+ position: { top: number; left: number };
1407
+ startPos: number;
1408
+ query: string;
1409
+ } | null;
1410
+
1411
+ export function TiptapComposer({
1412
+ placeholder = "Message agent...",
1413
+ disabled = false,
1414
+ focusRef,
1415
+ initialText,
1416
+ initialTextKey,
1417
+ onSubmit,
1418
+ onBeforeSubmit,
1419
+ clearOnSubmit = true,
1420
+ onTextChange,
1421
+ actionButton,
1422
+ extraActionButton,
1423
+ stopButton,
1424
+ attachButton,
1425
+ modeControl,
1426
+ toolbarSlot,
1427
+ layoutVariant = "default",
1428
+ slashCommands = [],
1429
+ slashSkills = [],
1430
+ includeDefaultSlashCommands = true,
1431
+ includeDefaultSlashSkills = true,
1432
+ onSlashCommand,
1433
+ execMode,
1434
+ onExecModeChange,
1435
+ planModeDisabled = false,
1436
+ planModeDisabledReason,
1437
+ voiceEnabled = DEFAULT_VOICE_DICTATION_ENABLED,
1438
+ selectedModel,
1439
+ selectedEffort,
1440
+ availableModels,
1441
+ modelListLoading,
1442
+ onModelChange,
1443
+ onEffortChange,
1444
+ providerConnectStatusEnabled,
1445
+ onConnectProvider,
1446
+ onConnectLocalRuntime,
1447
+ imageModelMenu,
1448
+ draftScope,
1449
+ contextItems = [],
1450
+ onRemoveContextItem,
1451
+ plusMenuMode = "full",
1452
+ interceptBuildRequestsForBuilder = false,
1453
+ onAttachmentError,
1454
+ }: TiptapComposerProps) {
1455
+ const adapters = useComposerRuntimeAdapters();
1456
+ const t = adapters.translate!;
1457
+ const [popover, setPopover] = useState<PopoverState>(null);
1458
+ const popoverRef = useRef<MentionPopoverRef>(null);
1459
+ const composerRuntime = useComposerRuntime();
1460
+ const lastComposerRuntimeSyncRef = useRef<{
1461
+ text: string;
1462
+ runConfigSignature: string;
1463
+ } | null>(null);
1464
+ const submitInFlightRef = useRef(false);
1465
+ const [editorHasText, setEditorHasText] = useState(false);
1466
+ const [slotReferences, setSlotReferences] = useState<
1467
+ AgentComposerReference[]
1468
+ >([]);
1469
+ const [selectedContextItemKey, setSelectedContextItemKey] = useState<
1470
+ string | null
1471
+ >(null);
1472
+ const composerText = useComposer((state) => state.text);
1473
+ const composerAttachments = useComposer((state) => state.attachments);
1474
+ const canSend = canSubmitComposerContent({
1475
+ hasEditorContent: editorHasText || slotReferences.length > 0,
1476
+ attachmentCount: composerAttachments.length,
1477
+ disabled,
1478
+ });
1479
+ const primaryAction = resolveComposerPrimaryAction({
1480
+ canSubmit: canSend,
1481
+ hasStopButton: Boolean(stopButton),
1482
+ });
1483
+ const hasContextRows = contextItems.length > 0 || slotReferences.length > 0;
1484
+ const [composerMode, setComposerMode] = useState<ComposerMode | null>(null);
1485
+ const composerModeRef = useRef<ComposerMode | null>(null);
1486
+ const isMac =
1487
+ typeof navigator !== "undefined" &&
1488
+ /Mac|iPhone|iPad/.test(navigator.userAgent);
1489
+
1490
+ // Refs for values accessed in handleKeyDown (ProseMirror doesn't re-bind)
1491
+ const popoverStateRef = useRef<PopoverState>(null);
1492
+ const onAttachmentErrorRef = useRef(onAttachmentError);
1493
+ onAttachmentErrorRef.current = onAttachmentError;
1494
+ const execModeRef = useRef(execMode);
1495
+ execModeRef.current = execMode;
1496
+ const onExecModeChangeRef = useRef(onExecModeChange);
1497
+ onExecModeChangeRef.current = onExecModeChange;
1498
+ const planModeDisabledRef = useRef(planModeDisabled);
1499
+ planModeDisabledRef.current = planModeDisabled;
1500
+
1501
+ const { items: mentionItems, isLoading: mentionsLoading } = useMentionSearch(
1502
+ popover?.type === "@" ? popover.query : "",
1503
+ popover?.type === "@",
1504
+ );
1505
+ const filteredMentionItems = useMemo(
1506
+ () => filterMentionItemsForSlots(mentionItems, slotReferences),
1507
+ [mentionItems, slotReferences],
1508
+ );
1509
+
1510
+ const {
1511
+ skills,
1512
+ hint,
1513
+ isLoading: skillsLoading,
1514
+ } = useSkills(includeDefaultSlashSkills && popover?.type === "/");
1515
+
1516
+ const allSlashCommands = useMemo(
1517
+ () =>
1518
+ mergeSlashCommands([
1519
+ ...(includeDefaultSlashCommands ? BUILT_IN_COMMANDS : []),
1520
+ ...slashCommands,
1521
+ ]),
1522
+ [includeDefaultSlashCommands, slashCommands],
1523
+ );
1524
+
1525
+ const allSlashSkills = useMemo(
1526
+ () =>
1527
+ mergeSlashSkills([
1528
+ ...(includeDefaultSlashSkills ? skills : []),
1529
+ ...slashSkills,
1530
+ ]),
1531
+ [includeDefaultSlashSkills, skills, slashSkills],
1532
+ );
1533
+
1534
+ const filteredCommands = useMemo(() => {
1535
+ if (!popover || popover.type !== "/") return allSlashCommands;
1536
+ const q = popover.query.toLowerCase();
1537
+ if (!q) return allSlashCommands;
1538
+ return allSlashCommands.filter(
1539
+ (c) =>
1540
+ c.name.toLowerCase().includes(q) ||
1541
+ c.description.toLowerCase().includes(q),
1542
+ );
1543
+ }, [allSlashCommands, popover]);
1544
+
1545
+ const filteredSkills = useMemo(() => {
1546
+ if (!popover || popover.type !== "/") return allSlashSkills;
1547
+ const q = popover.query.toLowerCase();
1548
+ if (!q) return allSlashSkills;
1549
+ return allSlashSkills.filter(
1550
+ (s) =>
1551
+ s.name.toLowerCase().includes(q) ||
1552
+ s.description?.toLowerCase().includes(q),
1553
+ );
1554
+ }, [allSlashSkills, popover]);
1555
+
1556
+ // Keep refs in sync with state
1557
+ const mentionItemsRef = useRef(filteredMentionItems);
1558
+ mentionItemsRef.current = filteredMentionItems;
1559
+ const filteredCommandsRef = useRef(filteredCommands);
1560
+ filteredCommandsRef.current = filteredCommands;
1561
+ const filteredSkillsRef = useRef(filteredSkills);
1562
+ filteredSkillsRef.current = filteredSkills;
1563
+ const onSlashCommandRef = useRef(onSlashCommand);
1564
+ onSlashCommandRef.current = onSlashCommand;
1565
+ const onTextChangeRef = useRef(onTextChange);
1566
+ onTextChangeRef.current = onTextChange;
1567
+ const contextItemsRef = useRef(contextItems);
1568
+ contextItemsRef.current = contextItems;
1569
+ const onRemoveContextItemRef = useRef(onRemoveContextItem);
1570
+ onRemoveContextItemRef.current = onRemoveContextItem;
1571
+ const selectedContextItemKeyRef = useRef<string | null>(null);
1572
+ selectedContextItemKeyRef.current = selectedContextItemKey;
1573
+ const initialTextKeyRef = useRef<string | number | undefined>(undefined);
1574
+ const seenReferenceInsertIdsRef = useRef<Set<string>>(new Set());
1575
+
1576
+ useEffect(() => {
1577
+ if (
1578
+ selectedContextItemKey &&
1579
+ !contextItems.some((item) => item.key === selectedContextItemKey)
1580
+ ) {
1581
+ selectedContextItemKeyRef.current = null;
1582
+ setSelectedContextItemKey(null);
1583
+ }
1584
+ }, [contextItems, selectedContextItemKey]);
1585
+
1586
+ const closePopover = useCallback(() => {
1587
+ setPopover(null);
1588
+ popoverStateRef.current = null;
1589
+ }, []);
1590
+
1591
+ // Persist draft to localStorage so hot-reloads don't lose the prompt
1592
+ const draftKey = getComposerDraftKey(draftScope);
1593
+ const draftTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
1594
+ useEffect(() => {
1595
+ return () => {
1596
+ clearTimeout(draftTimerRef.current);
1597
+ };
1598
+ }, []);
1599
+ useEffect(() => {
1600
+ lastComposerRuntimeSyncRef.current = null;
1601
+ }, [composerRuntime]);
1602
+ // Tiptap reads extension config once at init; ref keeps runtime prop
1603
+ // changes visible to Placeholder's function form.
1604
+ const placeholderRef = useRef(placeholder);
1605
+ useEffect(() => {
1606
+ placeholderRef.current = composerMode
1607
+ ? COMPOSER_MODE_CONFIGS[composerMode].placeholder
1608
+ : placeholder;
1609
+ }, [placeholder, composerMode]);
1610
+
1611
+ const editor = useEditor({
1612
+ extensions: createTiptapComposerExtensions(() => placeholderRef.current),
1613
+ editable: !disabled,
1614
+ onCreate: ({ editor: ed }) => {
1615
+ // Restore draft on mount
1616
+ try {
1617
+ if (initialText !== undefined) {
1618
+ ed.commands.setContent(plainTextToDoc(initialText));
1619
+ ed.commands.focus("end");
1620
+ setEditorHasText(composerDocumentHasContent(ed.state.doc));
1621
+ initialTextKeyRef.current = initialTextKey ?? initialText;
1622
+ } else {
1623
+ const saved = localStorage.getItem(draftKey);
1624
+ if (saved) {
1625
+ ed.commands.setContent(saved);
1626
+ ed.commands.focus("end");
1627
+ setEditorHasText(composerDocumentHasContent(ed.state.doc));
1628
+ }
1629
+ }
1630
+ onTextChangeRef.current?.(ed.state.doc.textContent.trim());
1631
+ } catch {}
1632
+ },
1633
+ onUpdate: ({ editor: ed }) => {
1634
+ // Drive the send button's enabled state from the actual editor contents;
1635
+ // the composer runtime is only synced on submit, so its isEmpty lags.
1636
+ setEditorHasText(composerDocumentHasContent(ed.state.doc));
1637
+ onTextChangeRef.current?.(ed.state.doc.textContent.trim());
1638
+
1639
+ // Debounce-save draft to localStorage
1640
+ clearTimeout(draftTimerRef.current);
1641
+ draftTimerRef.current = setTimeout(() => {
1642
+ try {
1643
+ const html = ed.getHTML();
1644
+ const isEmpty = !composerDocumentHasContent(ed.state.doc);
1645
+ if (isEmpty) {
1646
+ localStorage.removeItem(draftKey);
1647
+ } else {
1648
+ localStorage.setItem(draftKey, html);
1649
+ }
1650
+ } catch {}
1651
+ }, 300);
1652
+ },
1653
+ onSelectionUpdate: ({ editor: ed }) => {
1654
+ const { from, to } = ed.state.selection;
1655
+ if (selectedContextItemKeyRef.current && (from !== to || from > 1)) {
1656
+ selectedContextItemKeyRef.current = null;
1657
+ setSelectedContextItemKey(null);
1658
+ }
1659
+ },
1660
+ editorProps: {
1661
+ attributes: {
1662
+ "data-agent-composer-variant": layoutVariant,
1663
+ "data-agent-composer-slot": "editor-input",
1664
+ class:
1665
+ "agent-composer-prosemirror flex-1 resize-none bg-transparent text-sm text-foreground outline-none leading-[1.625rem] min-h-[3.25rem] max-h-[10rem] overflow-y-auto",
1666
+ },
1667
+ handlePaste: (_view, event) => {
1668
+ const paste = readClipboardPaste(event.clipboardData);
1669
+ const pastedText = paste.text;
1670
+ const files = Array.from(event.clipboardData?.files ?? []).filter(
1671
+ (file) => file.type.startsWith("image/"),
1672
+ );
1673
+ if (files.length > 0) {
1674
+ event.preventDefault();
1675
+ const attachments: File[] = files.map((file) => {
1676
+ // SimpleImageAttachmentAdapter uses file.name as the attachment id.
1677
+ // Clipboard images (e.g. screenshots) are typically all named
1678
+ // "image.png", so a second paste would replace the first instead of
1679
+ // appending. Prepend a unique token so each paste gets a distinct id.
1680
+ const uniqueName = `${Date.now()}-${Math.random().toString(36).slice(2)}-${file.name}`;
1681
+ return new File([file], uniqueName, { type: file.type });
1682
+ });
1683
+
1684
+ // Google Docs rich clipboard payloads can contain both embedded
1685
+ // image files and the document text. Since handling files means we
1686
+ // prevent Tiptap's default paste, preserve any text as its own chip
1687
+ // instead of silently dropping the source material.
1688
+ if (pastedText.trim()) {
1689
+ attachments.push(createPastedAttachmentFile(paste));
1690
+ }
1691
+
1692
+ void Promise.all(
1693
+ attachments.map((file) => composerRuntime.addAttachment(file)),
1694
+ ).catch((error) => {
1695
+ const msg =
1696
+ error instanceof Error
1697
+ ? error.message
1698
+ : "Could not attach the pasted image. Try a different format.";
1699
+ onAttachmentErrorRef.current?.(msg);
1700
+ });
1701
+ return true;
1702
+ }
1703
+
1704
+ // Page-sized pastes turn into a `Pasted text` attachment chip so the
1705
+ // prompt stays readable while normal paragraphs and lists stay inline.
1706
+ // When the paste is HTML (e.g. an Alpine.js extension or a document the
1707
+ // user wants hosted), it's stored as a real .html attachment so it
1708
+ // travels the same rail as uploading that file — the agent reads it
1709
+ // verbatim via contentFromAttachment instead of retyping it inline,
1710
+ // which cuts off mid-stream on large files and triggers a spin.
1711
+ if (shouldConvertClipboardToAttachment(paste)) {
1712
+ event.preventDefault();
1713
+ void composerRuntime
1714
+ .addAttachment(createPastedAttachmentFile(paste))
1715
+ .catch((error) => {
1716
+ const msg =
1717
+ error instanceof Error
1718
+ ? error.message
1719
+ : "Could not attach the pasted text.";
1720
+ onAttachmentErrorRef.current?.(msg);
1721
+ });
1722
+ return true;
1723
+ }
1724
+
1725
+ return false;
1726
+ },
1727
+ handleDrop: (_view, event) => {
1728
+ // Drag-and-drop files (decks, images, PDFs, etc.) into the composer.
1729
+ // Mark handled drops as consumed so the chat-wide drop target does not
1730
+ // add the same file a second time.
1731
+ return handleComposerFileDrop({
1732
+ event: event as DragEvent,
1733
+ addAttachment: (file) => composerRuntime.addAttachment(file),
1734
+ onError: (error) => {
1735
+ const msg =
1736
+ error instanceof Error
1737
+ ? error.message
1738
+ : "Could not attach the dropped file. Try a different format.";
1739
+ onAttachmentErrorRef.current?.(msg);
1740
+ },
1741
+ });
1742
+ },
1743
+ handleKeyDown: (view, event) => {
1744
+ const pop = popoverStateRef.current;
1745
+
1746
+ // Handle popover keyboard nav
1747
+ if (pop) {
1748
+ if (event.key === "ArrowUp") {
1749
+ event.preventDefault();
1750
+ popoverRef.current?.moveUp();
1751
+ return true;
1752
+ }
1753
+ if (event.key === "ArrowDown") {
1754
+ event.preventDefault();
1755
+ popoverRef.current?.moveDown();
1756
+ return true;
1757
+ }
1758
+ if (event.key === "Enter") {
1759
+ event.preventDefault();
1760
+ const idx = popoverRef.current?.getSelectedIndex() ?? 0;
1761
+ const currentCommands = filteredCommandsRef.current;
1762
+ const currentSkills = filteredSkillsRef.current;
1763
+ if (pop.type === "@") {
1764
+ const item = popoverRef.current?.getSelectedMention();
1765
+ if (item) selectMention(view, pop, item);
1766
+ } else if (pop.type === "/") {
1767
+ const cmd = popoverRef.current?.getSelectedCommand();
1768
+ if (cmd) {
1769
+ executeCommand(view, pop, cmd);
1770
+ } else {
1771
+ const skillIdx = idx - currentCommands.length;
1772
+ if (currentSkills[skillIdx]) {
1773
+ selectSkill(view, pop, currentSkills[skillIdx]);
1774
+ }
1775
+ }
1776
+ }
1777
+ return true;
1778
+ }
1779
+ if (event.key === "Escape") {
1780
+ event.preventDefault();
1781
+ popoverStateRef.current = null;
1782
+ setPopover(null);
1783
+ return true;
1784
+ }
1785
+ if (event.key === " " && pop.query === "") {
1786
+ popoverStateRef.current = null;
1787
+ setPopover(null);
1788
+ return false;
1789
+ }
1790
+ }
1791
+
1792
+ const { from, to } = view.state.selection;
1793
+ const cursorAtStart = from === to && from <= 1;
1794
+ if (event.key === "Backspace" && onRemoveContextItemRef.current) {
1795
+ const chipAction = resolveContextChipBackspaceAction({
1796
+ contextItemKeys: contextItemsRef.current.map((item) => item.key),
1797
+ selectedKey: selectedContextItemKeyRef.current,
1798
+ cursorAtStart,
1799
+ });
1800
+ if (chipAction) {
1801
+ event.preventDefault();
1802
+ if (chipAction.type === "select") {
1803
+ selectedContextItemKeyRef.current = chipAction.key;
1804
+ setSelectedContextItemKey(chipAction.key);
1805
+ } else {
1806
+ selectedContextItemKeyRef.current = null;
1807
+ setSelectedContextItemKey(null);
1808
+ contextItemsRef.current = contextItemsRef.current.filter(
1809
+ (item) => item.key !== chipAction.key,
1810
+ );
1811
+ onRemoveContextItemRef.current?.(chipAction.key);
1812
+ }
1813
+ return true;
1814
+ }
1815
+ }
1816
+ if (selectedContextItemKeyRef.current) {
1817
+ selectedContextItemKeyRef.current = null;
1818
+ setSelectedContextItemKey(null);
1819
+ }
1820
+
1821
+ // Backspace removes composer mode chip when editor is empty
1822
+ if (event.key === "Backspace" && composerModeRef.current) {
1823
+ if (
1824
+ view.state.doc.textContent.trim() === "" &&
1825
+ from === to &&
1826
+ from <= 1
1827
+ ) {
1828
+ setComposerMode(null);
1829
+ composerModeRef.current = null;
1830
+ return true;
1831
+ }
1832
+ }
1833
+
1834
+ // Keyboard shortcut toggles Act/Plan mode from inside the editor.
1835
+ if (event.key === "Tab" && event.shiftKey) {
1836
+ event.preventDefault();
1837
+ const current = execModeRef.current;
1838
+ const cb = onExecModeChangeRef.current;
1839
+ if (current && cb) {
1840
+ const next = current === "build" ? "plan" : "build";
1841
+ if (next !== "plan" || !planModeDisabledRef.current) {
1842
+ cb(next);
1843
+ }
1844
+ }
1845
+ return true;
1846
+ }
1847
+
1848
+ // Submit on Enter. Shift+Enter inserts a newline and keeps the
1849
+ // composer scrolled to the caret.
1850
+ // Cmd+Enter on macOS / Ctrl+Enter elsewhere marks the submit queued.
1851
+ if (event.key === "Enter" && event.shiftKey) {
1852
+ event.preventDefault();
1853
+ return insertComposerHardBreakAndScrollIntoView(view);
1854
+ }
1855
+
1856
+ const submitIntent = getComposerSubmitIntentForEnterKey(event, isMac);
1857
+ if (submitIntent) {
1858
+ event.preventDefault();
1859
+ void submitComposer(submitIntent);
1860
+ return true;
1861
+ }
1862
+
1863
+ // Detect @ trigger — only when preceded by start-of-text, space, or newline
1864
+ // (not after alphanumeric chars, which would indicate an email address)
1865
+ if (event.key === "@") {
1866
+ const { from } = view.state.selection;
1867
+ const textBefore = view.state.doc.textBetween(
1868
+ Math.max(0, from - 1),
1869
+ from,
1870
+ );
1871
+ if (from === 1 || textBefore === "" || /\s/.test(textBefore)) {
1872
+ const position = getComposerPopoverPosition(view, from);
1873
+ if (!position) return false;
1874
+ setTimeout(() => {
1875
+ const state: PopoverState = {
1876
+ type: "@",
1877
+ position,
1878
+ startPos: view.state.selection.from,
1879
+ query: "",
1880
+ };
1881
+ popoverStateRef.current = state;
1882
+ setPopover(state);
1883
+ }, 0);
1884
+ }
1885
+ return false;
1886
+ }
1887
+
1888
+ // Detect / trigger (only at start of line or after whitespace)
1889
+ if (event.key === "/") {
1890
+ const { from } = view.state.selection;
1891
+ const textBefore = view.state.doc.textBetween(
1892
+ Math.max(0, from - 1),
1893
+ from,
1894
+ );
1895
+ if (from === 1 || textBefore === "" || /\s/.test(textBefore)) {
1896
+ const position = getComposerPopoverPosition(view, from);
1897
+ if (!position) return false;
1898
+ setTimeout(() => {
1899
+ const state: PopoverState = {
1900
+ type: "/",
1901
+ position,
1902
+ startPos: view.state.selection.from,
1903
+ query: "",
1904
+ };
1905
+ popoverStateRef.current = state;
1906
+ setPopover(state);
1907
+ }, 0);
1908
+ }
1909
+ return false;
1910
+ }
1911
+
1912
+ return false;
1913
+ },
1914
+ },
1915
+ });
1916
+
1917
+ // A tab can stay mounted while becoming the active composer later. Publish
1918
+ // its existing draft when the host starts observing it so contextual UI is
1919
+ // correct immediately after a tab switch, not only after the next keystroke.
1920
+ useEffect(() => {
1921
+ if (!isComposerEditorUsable(editor) || !onTextChange) return;
1922
+ onTextChange(editor.state.doc.textContent.trim());
1923
+ }, [editor, onTextChange]);
1924
+
1925
+ const insertReference = useCallback(
1926
+ (ref: AgentComposerReference) => {
1927
+ const normalized = adapters.agentChat!.normalizeReference!(ref) as any;
1928
+ const ed = editor;
1929
+ if (!normalized || !isComposerEditorUsable(ed)) return;
1930
+ if (normalized.slotKey) {
1931
+ setSlotReferences((current) =>
1932
+ applySlotReferenceChanges(current, [normalized]),
1933
+ );
1934
+ ed.commands.focus("end");
1935
+ return;
1936
+ }
1937
+ if (
1938
+ normalized.relatedReferences?.some(
1939
+ (item: AgentComposerReference) => item.slotKey,
1940
+ )
1941
+ ) {
1942
+ setSlotReferences((current) =>
1943
+ applySlotReferenceChanges(
1944
+ current,
1945
+ normalized.relatedReferences ?? [],
1946
+ ),
1947
+ );
1948
+ }
1949
+ ed.chain()
1950
+ .focus()
1951
+ .insertContent({
1952
+ type: "mentionReference",
1953
+ attrs: mentionReferenceAttrs(normalized),
1954
+ })
1955
+ .insertContent(" ")
1956
+ .run();
1957
+ setEditorHasText(true);
1958
+ },
1959
+ [editor],
1960
+ );
1961
+
1962
+ const insertReferenceIfEmpty = useCallback(
1963
+ (payload: AgentComposerReferenceInsertPayload) => {
1964
+ const insertMessageId =
1965
+ typeof payload.insertMessageId === "string"
1966
+ ? payload.insertMessageId
1967
+ : "";
1968
+ if (insertMessageId) {
1969
+ if (seenReferenceInsertIdsRef.current.has(insertMessageId)) return;
1970
+ seenReferenceInsertIdsRef.current.add(insertMessageId);
1971
+ }
1972
+ const ed = editor;
1973
+ if (!isComposerEditorUsable(ed) || disabled || composerModeRef.current)
1974
+ return;
1975
+ const normalized = adapters.agentChat!.normalizeReference!(
1976
+ payload,
1977
+ ) as any;
1978
+ if (normalized?.slotKey) {
1979
+ insertReference(normalized);
1980
+ return;
1981
+ }
1982
+ if (composerAttachments.length > 0) return;
1983
+ if (composerDocumentHasContent(ed.state.doc)) return;
1984
+ insertReference(payload);
1985
+ },
1986
+ [composerAttachments.length, disabled, editor, insertReference],
1987
+ );
1988
+
1989
+ useEffect(() => {
1990
+ if (typeof window === "undefined") return;
1991
+ const handleEvent = (event: Event) => {
1992
+ const payload = (event as CustomEvent).detail;
1993
+ const normalized = adapters.agentChat!.normalizeReference!(
1994
+ payload,
1995
+ ) as any;
1996
+ if (!normalized) return;
1997
+ insertReferenceIfEmpty({
1998
+ ...normalized,
1999
+ insertMessageId:
2000
+ typeof payload?.insertMessageId === "string"
2001
+ ? payload.insertMessageId
2002
+ : "",
2003
+ });
2004
+ };
2005
+ const handleMessage = (event: MessageEvent) => {
2006
+ if (
2007
+ !adapters.builder!.isTrustedFrameMessage!(event) &&
2008
+ !adapters.builder!.isTrustedBuilderMessage!(event)
2009
+ ) {
2010
+ return;
2011
+ }
2012
+ if (event.data?.type !== AGENT_CHAT_INSERT_REFERENCE_MESSAGE_TYPE) {
2013
+ return;
2014
+ }
2015
+ const payload = event.data.data;
2016
+ const normalized = adapters.agentChat!.normalizeReference!(
2017
+ payload,
2018
+ ) as any;
2019
+ if (!normalized) return;
2020
+ insertReferenceIfEmpty({
2021
+ ...normalized,
2022
+ insertMessageId:
2023
+ typeof payload?.insertMessageId === "string"
2024
+ ? payload.insertMessageId
2025
+ : "",
2026
+ });
2027
+ };
2028
+ window.addEventListener(AGENT_CHAT_INSERT_REFERENCE_EVENT, handleEvent);
2029
+ window.addEventListener("message", handleMessage);
2030
+ return () => {
2031
+ window.removeEventListener(
2032
+ AGENT_CHAT_INSERT_REFERENCE_EVENT,
2033
+ handleEvent,
2034
+ );
2035
+ window.removeEventListener("message", handleMessage);
2036
+ };
2037
+ }, [adapters, insertReferenceIfEmpty]);
2038
+
2039
+ useImperativeHandle(focusRef, () => ({
2040
+ focus() {
2041
+ if (isComposerEditorUsable(editor)) editor.commands.focus("end");
2042
+ },
2043
+ setText(text: string) {
2044
+ if (!isComposerEditorUsable(editor)) return;
2045
+ editor.commands.setContent(plainTextToDoc(text));
2046
+ editor.commands.focus("end");
2047
+ const trimmed = editor.state.doc.textContent.trim();
2048
+ setEditorHasText(trimmed.length > 0);
2049
+ setSlotReferences([]);
2050
+ composerRuntime.setText(trimmed);
2051
+ onTextChangeRef.current?.(trimmed);
2052
+ try {
2053
+ if (trimmed) {
2054
+ localStorage.setItem(draftKey, editor.getHTML());
2055
+ } else {
2056
+ localStorage.removeItem(draftKey);
2057
+ }
2058
+ } catch {}
2059
+ },
2060
+ insertReference,
2061
+ }));
2062
+
2063
+ const handleSelectMode = useCallback(
2064
+ (mode: ComposerMode) => {
2065
+ setComposerMode(mode);
2066
+ composerModeRef.current = mode;
2067
+ setTimeout(() => {
2068
+ if (isComposerEditorUsable(editor)) editor.commands.focus("end");
2069
+ }, 50);
2070
+ },
2071
+ [editor],
2072
+ );
2073
+
2074
+ // --- Live voice transcription: text appears in the editor as the user speaks ---
2075
+ const voiceAnchorRef = useRef<number | null>(null);
2076
+ const prevVoiceInsertRef = useRef("");
2077
+
2078
+ const handleLiveUpdate = useCallback(
2079
+ (finalText: string, interimText: string) => {
2080
+ const ed = editor;
2081
+ if (!isComposerEditorUsable(ed)) return;
2082
+
2083
+ if (voiceAnchorRef.current == null) {
2084
+ const { from } = ed.state.selection;
2085
+ const prevChar =
2086
+ from > 1 ? ed.state.doc.textBetween(from - 1, from) : "";
2087
+ if (prevChar && !/\s/.test(prevChar)) {
2088
+ ed.chain().insertContent(" ").run();
2089
+ }
2090
+ voiceAnchorRef.current = ed.state.selection.from;
2091
+ prevVoiceInsertRef.current = "";
2092
+ }
2093
+
2094
+ const anchor = voiceAnchorRef.current;
2095
+ const prevLen = prevVoiceInsertRef.current.length;
2096
+ const newText = finalText + interimText;
2097
+
2098
+ if (newText === prevVoiceInsertRef.current) return;
2099
+
2100
+ ed.chain()
2101
+ .deleteRange({ from: anchor, to: anchor + prevLen })
2102
+ .insertContentAt(anchor, newText)
2103
+ .run();
2104
+
2105
+ prevVoiceInsertRef.current = newText;
2106
+ },
2107
+ [editor],
2108
+ );
2109
+
2110
+ const insertTranscript = useCallback(
2111
+ (text: string) => {
2112
+ const ed = editor;
2113
+ if (!isComposerEditorUsable(ed)) return;
2114
+ const formattedText = formatVoiceTranscriptForComposer(text);
2115
+
2116
+ const anchor = voiceAnchorRef.current;
2117
+ if (anchor != null) {
2118
+ const prevLen = prevVoiceInsertRef.current.length;
2119
+ if (formattedText) {
2120
+ ed.chain()
2121
+ .focus()
2122
+ .deleteRange({ from: anchor, to: anchor + prevLen })
2123
+ .insertContentAt(anchor, formattedText)
2124
+ .run();
2125
+ } else if (prevLen > 0) {
2126
+ ed.chain()
2127
+ .deleteRange({ from: anchor, to: anchor + prevLen })
2128
+ .run();
2129
+ }
2130
+ voiceAnchorRef.current = null;
2131
+ prevVoiceInsertRef.current = "";
2132
+ } else if (formattedText) {
2133
+ const { from } = ed.state.selection;
2134
+ const prevChar =
2135
+ from > 1 ? ed.state.doc.textBetween(from - 1, from) : "";
2136
+ const needsLead = prevChar && !/\s/.test(prevChar);
2137
+ ed.chain()
2138
+ .focus()
2139
+ .insertContent((needsLead ? " " : "") + formattedText)
2140
+ .run();
2141
+ }
2142
+ },
2143
+ [editor],
2144
+ );
2145
+
2146
+ const buildVoiceContextPack = useCallback(():
2147
+ | VoiceContextPack
2148
+ | undefined => {
2149
+ const snippets: Array<{ label: string; value: string }> = [];
2150
+
2151
+ const activeContext = trimVoiceContextValue(
2152
+ adapters.agentChat!.formatContextItems!(contextItems) ||
2153
+ formatPromptContextItems(contextItems),
2154
+ 3200,
2155
+ );
2156
+ if (activeContext) {
2157
+ snippets.push({ label: "Active app context", value: activeContext });
2158
+ }
2159
+
2160
+ const selectedReferences = trimVoiceContextValue(
2161
+ slotReferences.map((ref) => slotReferenceTitle(ref)).join(", "),
2162
+ 1200,
2163
+ );
2164
+ if (selectedReferences) {
2165
+ snippets.push({
2166
+ label: "Selected references",
2167
+ value: selectedReferences,
2168
+ });
2169
+ }
2170
+
2171
+ const draft = isComposerEditorUsable(editor)
2172
+ ? trimVoiceContextValue(editor.state.doc.textContent, 1200)
2173
+ : null;
2174
+ if (draft) snippets.push({ label: "Current draft", value: draft });
2175
+
2176
+ if (typeof document !== "undefined") {
2177
+ const title = trimVoiceContextValue(document.title, 160);
2178
+ if (title) snippets.push({ label: "Page title", value: title });
2179
+ }
2180
+
2181
+ if (typeof window !== "undefined") {
2182
+ const route = trimVoiceContextValue(window.location.pathname, 240);
2183
+ if (route) snippets.push({ label: "Route", value: route });
2184
+ }
2185
+
2186
+ if (snippets.length === 0) return undefined;
2187
+ return {
2188
+ surface: "agent-composer",
2189
+ mode: "dictation",
2190
+ snippets,
2191
+ };
2192
+ }, [adapters, contextItems, editor, slotReferences]);
2193
+
2194
+ const voice = useVoiceDictation({
2195
+ onTranscript: insertTranscript,
2196
+ onLiveUpdate: handleLiveUpdate,
2197
+ contextPack: buildVoiceContextPack,
2198
+ });
2199
+
2200
+ // Clean up live text if voice session ends without a final transcript (cancel/error)
2201
+ useEffect(() => {
2202
+ if (voice.state === "idle" && voiceAnchorRef.current != null) {
2203
+ const anchor = voiceAnchorRef.current;
2204
+ const prevLen = prevVoiceInsertRef.current.length;
2205
+ if (isComposerEditorUsable(editor) && prevLen > 0) {
2206
+ const documentSize = editor.state.doc.content.size;
2207
+ const currentText =
2208
+ anchor >= 1 && anchor + prevLen <= documentSize
2209
+ ? editor.state.doc.textBetween(anchor, anchor + prevLen, "", "\n")
2210
+ : "";
2211
+ if (
2212
+ canRemoveVoicePreview({
2213
+ documentSize,
2214
+ anchor,
2215
+ previewText: prevVoiceInsertRef.current,
2216
+ currentText,
2217
+ })
2218
+ ) {
2219
+ editor
2220
+ .chain()
2221
+ .deleteRange({ from: anchor, to: anchor + prevLen })
2222
+ .run();
2223
+ }
2224
+ }
2225
+ voiceAnchorRef.current = null;
2226
+ prevVoiceInsertRef.current = "";
2227
+ }
2228
+ }, [voice.state, editor]);
2229
+
2230
+ // Global shortcut: Cmd/Ctrl + Shift + M toggles dictation. Escape cancels
2231
+ // while recording. Scoped to avoid firing when focus is outside the app.
2232
+ useEffect(() => {
2233
+ if (!voiceEnabled || !voice.supported) return;
2234
+ const handler = (e: KeyboardEvent) => {
2235
+ // e.key can be undefined on some trusted keydown events (autofill/IME
2236
+ // quirks) — seen crashing in production (AGENT-NATIVE-BROWSER-S).
2237
+ const isToggleCombo =
2238
+ typeof e.key === "string" &&
2239
+ e.key.toLowerCase() === "m" &&
2240
+ e.shiftKey &&
2241
+ (e.metaKey || e.ctrlKey) &&
2242
+ !e.altKey;
2243
+ if (isToggleCombo) {
2244
+ e.preventDefault();
2245
+ if (voice.state === "recording" || voice.state === "starting") {
2246
+ voice.stop();
2247
+ } else if (voice.state !== "transcribing") {
2248
+ void voice.start();
2249
+ }
2250
+ return;
2251
+ }
2252
+ if (
2253
+ e.key === "Escape" &&
2254
+ (voice.state === "recording" || voice.state === "starting")
2255
+ ) {
2256
+ e.preventDefault();
2257
+ voice.cancel();
2258
+ }
2259
+ };
2260
+ window.addEventListener("keydown", handler);
2261
+ return () => window.removeEventListener("keydown", handler);
2262
+ }, [voiceEnabled, voice]);
2263
+
2264
+ const extractComposerPayload = useCallback(() => {
2265
+ const ed = editor;
2266
+ if (!isComposerEditorUsable(ed)) {
2267
+ return {
2268
+ text: slotReferences.map((ref) => slotReferenceTitle(ref)).join(", "),
2269
+ references: slotReferences.map(referenceFromComposerReference),
2270
+ };
2271
+ }
2272
+
2273
+ const references: Reference[] = slotReferences.map(
2274
+ referenceFromComposerReference,
2275
+ );
2276
+
2277
+ // Build text that preserves @mentions (getText() strips them).
2278
+ // Walk the document and reconstruct with @name for mention/file/skill nodes.
2279
+ const textParts: string[] = [];
2280
+ ed.state.doc.descendants((node: any) => {
2281
+ if (node.isText) {
2282
+ textParts.push(node.text);
2283
+ } else if (node.type.name === "mentionReference") {
2284
+ textParts.push(`@[${node.attrs.label}|${node.attrs.icon || "file"}]`);
2285
+ } else if (node.type.name === "fileReference") {
2286
+ const label = node.attrs.path?.split("/").pop() || node.attrs.path;
2287
+ textParts.push(`@[${label}|file]`);
2288
+ } else if (node.type.name === "skillReference") {
2289
+ textParts.push(`/${node.attrs.name}`);
2290
+ } else if (node.type.name === "hardBreak") {
2291
+ textParts.push("\n");
2292
+ } else if (
2293
+ node.type.name === "paragraph" &&
2294
+ textParts.length > 0 &&
2295
+ textParts[textParts.length - 1] !== "\n"
2296
+ ) {
2297
+ textParts.push("\n");
2298
+ }
2299
+ });
2300
+ const rawText = textParts.join("").trim();
2301
+ const text =
2302
+ rawText ||
2303
+ slotReferences.map((ref) => slotReferenceTitle(ref)).join(", ");
2304
+
2305
+ ed.state.doc.descendants((node: any) => {
2306
+ if (node.type.name === "fileReference") {
2307
+ // Legacy support
2308
+ references.push({
2309
+ type: "file",
2310
+ path: node.attrs.path,
2311
+ name: node.attrs.path?.split("/").pop() || node.attrs.path,
2312
+ source: node.attrs.source || "codebase",
2313
+ });
2314
+ } else if (node.type.name === "mentionReference") {
2315
+ const refType = node.attrs.refType;
2316
+ references.push({
2317
+ type:
2318
+ refType === "file"
2319
+ ? "file"
2320
+ : refType === "agent"
2321
+ ? "agent"
2322
+ : refType === "custom-agent"
2323
+ ? "custom-agent"
2324
+ : "mention",
2325
+ path: node.attrs.refPath || "",
2326
+ name: node.attrs.label,
2327
+ source: node.attrs.source,
2328
+ refType: node.attrs.refType,
2329
+ refId: node.attrs.refId,
2330
+ slotKey: node.attrs.slotKey,
2331
+ slotLabel: node.attrs.slotLabel,
2332
+ metadata: node.attrs.metadata,
2333
+ });
2334
+ } else if (node.type.name === "skillReference") {
2335
+ references.push({
2336
+ type: "skill",
2337
+ path: node.attrs.path,
2338
+ name: node.attrs.name,
2339
+ source: node.attrs.source || "codebase",
2340
+ });
2341
+ }
2342
+ });
2343
+
2344
+ return { text, references };
2345
+ }, [editor, slotReferences]);
2346
+
2347
+ const syncComposerRuntimeState = useCallback(
2348
+ (text: string, references: Reference[]) => {
2349
+ const requestMode =
2350
+ execMode === "plan" ? "plan" : execMode === "build" ? "act" : undefined;
2351
+ const custom: {
2352
+ references?: Reference[];
2353
+ requestMode?: "act" | "plan";
2354
+ } = {};
2355
+ if (references.length > 0) {
2356
+ custom.references = references;
2357
+ }
2358
+ if (requestMode) {
2359
+ custom.requestMode = requestMode;
2360
+ }
2361
+ const runConfig = Object.keys(custom).length > 0 ? { custom } : {};
2362
+ const runConfigSignature = JSON.stringify(runConfig);
2363
+ const last = lastComposerRuntimeSyncRef.current;
2364
+ if (
2365
+ last?.text === text &&
2366
+ last.runConfigSignature === runConfigSignature
2367
+ ) {
2368
+ return;
2369
+ }
2370
+ composerRuntime.setText(text);
2371
+ composerRuntime.setRunConfig(runConfig);
2372
+ lastComposerRuntimeSyncRef.current = { text, runConfigSignature };
2373
+ },
2374
+ [composerRuntime, execMode],
2375
+ );
2376
+
2377
+ const resetComposerRuntimeState = useCallback(() => {
2378
+ const runConfigSignature = "{}";
2379
+ const last = lastComposerRuntimeSyncRef.current;
2380
+ if (last?.text === "" && last.runConfigSignature === runConfigSignature) {
2381
+ return;
2382
+ }
2383
+ composerRuntime.setText("");
2384
+ composerRuntime.setRunConfig({});
2385
+ lastComposerRuntimeSyncRef.current = { text: "", runConfigSignature };
2386
+ }, [composerRuntime]);
2387
+
2388
+ const syncComposerState = useCallback(() => {
2389
+ const { text, references } = extractComposerPayload();
2390
+ syncComposerRuntimeState(text, references);
2391
+ return { text, references };
2392
+ }, [extractComposerPayload, syncComposerRuntimeState]);
2393
+
2394
+ const clearEditorAfterSubmit = useCallback(() => {
2395
+ const ed = editor;
2396
+ if (!isComposerEditorUsable(ed)) return;
2397
+ ed.commands.clearContent();
2398
+ setEditorHasText(false);
2399
+ setSlotReferences([]);
2400
+ resetComposerRuntimeState();
2401
+ try {
2402
+ localStorage.removeItem(draftKey);
2403
+ } catch {}
2404
+ closePopover();
2405
+ }, [closePopover, draftKey, editor, resetComposerRuntimeState]);
2406
+
2407
+ const submitComposer = useCallback(
2408
+ async (intent: ComposerSubmitIntent = "immediate") => {
2409
+ const ed = editor;
2410
+ if (!isComposerEditorUsable(ed)) return;
2411
+ if (submitInFlightRef.current) return;
2412
+
2413
+ const { text, references } = syncComposerState();
2414
+ const attachments = composerRuntime.getState().attachments;
2415
+ if (!text.trim() && references.length === 0 && attachments.length === 0)
2416
+ return;
2417
+ const oversizedDocumentError =
2418
+ getOversizedDocumentAttachmentError(attachments);
2419
+ if (oversizedDocumentError) {
2420
+ onAttachmentErrorRef.current?.(oversizedDocumentError);
2421
+ return;
2422
+ }
2423
+ const cancelActiveVoice = () => {
2424
+ if (
2425
+ voice.state === "recording" ||
2426
+ voice.state === "starting" ||
2427
+ voice.state === "transcribing"
2428
+ ) {
2429
+ voice.cancel();
2430
+ }
2431
+ };
2432
+
2433
+ // Intercept slash commands typed directly (e.g. "/clear" + Enter)
2434
+ const trimmed = text.trim();
2435
+ if (trimmed.startsWith("/") && references.length === 0) {
2436
+ const cmdName = normalizeSlashCommandName(trimmed);
2437
+ const matched = allSlashCommands.find((c) => c.name === cmdName);
2438
+ if (matched) {
2439
+ clearEditorAfterSubmit();
2440
+ onSlashCommandRef.current?.(matched.name);
2441
+ return;
2442
+ }
2443
+ }
2444
+
2445
+ // Builder iframe delegation: when this app is mounted inside the
2446
+ // Builder.io webview and the user typed a "build me an app/agent"
2447
+ // prompt, hand it up to the parent Builder chat instead of sending
2448
+ // it to this app's domain agent. Builder is the code-writing agent;
2449
+ // the local agent (dispatch, mail, etc.) cannot scaffold workspace
2450
+ // apps from inside its own iframe.
2451
+ if (
2452
+ !composerMode &&
2453
+ interceptBuildRequestsForBuilder &&
2454
+ adapters.builder!.tryDelegateBuildRequest!(trimmed)
2455
+ ) {
2456
+ cancelActiveVoice();
2457
+ clearEditorAfterSubmit();
2458
+ return;
2459
+ }
2460
+
2461
+ if (onBeforeSubmit) {
2462
+ submitInFlightRef.current = true;
2463
+ try {
2464
+ const shouldSubmit = await onBeforeSubmit();
2465
+ if (!shouldSubmit) return;
2466
+ } finally {
2467
+ submitInFlightRef.current = false;
2468
+ }
2469
+ }
2470
+ if (!isComposerEditorUsable(ed)) return;
2471
+
2472
+ // Composer mode: send with context via agent chat bridge
2473
+ if (composerMode) {
2474
+ const config = COMPOSER_MODE_CONFIGS[composerMode];
2475
+ config.beforeSend?.();
2476
+ const message = displayableComposerModeMessage({
2477
+ messagePrefix: config.messagePrefix,
2478
+ trimmedText: trimmed,
2479
+ attachmentCount: attachments.length,
2480
+ });
2481
+ const modePrompt =
2482
+ trimmed ||
2483
+ (attachments.length > 0 ? "Use the attached context." : "");
2484
+ if (attachments.length > 0) {
2485
+ composerRuntime.setText(
2486
+ `${message}\n\n<context>\n${config.getContext(modePrompt)}\n</context>`,
2487
+ );
2488
+ composerRuntime.send();
2489
+ } else {
2490
+ adapters.agentChat!.sendToAgentChat!({
2491
+ message,
2492
+ context: config.getContext(modePrompt),
2493
+ mode:
2494
+ execMode === "plan"
2495
+ ? "plan"
2496
+ : execMode === "build"
2497
+ ? "act"
2498
+ : undefined,
2499
+ submit: true,
2500
+ });
2501
+ }
2502
+ cancelActiveVoice();
2503
+ if (isComposerEditorUsable(ed)) ed.commands.clearContent();
2504
+ setEditorHasText(false);
2505
+ setSlotReferences([]);
2506
+ setComposerMode(null);
2507
+ composerModeRef.current = null;
2508
+ try {
2509
+ localStorage.removeItem(draftKey);
2510
+ } catch {}
2511
+ closePopover();
2512
+ return;
2513
+ }
2514
+
2515
+ if (onSubmit) {
2516
+ onSubmit(text, references, attachments, { intent });
2517
+ // Clear any pending attachments now that the host has them.
2518
+ void composerRuntime.clearAttachments().catch(() => {});
2519
+ if (!clearOnSubmit) {
2520
+ closePopover();
2521
+ return;
2522
+ }
2523
+ cancelActiveVoice();
2524
+ clearEditorAfterSubmit();
2525
+ return;
2526
+ } else {
2527
+ composerRuntime.send();
2528
+ }
2529
+ cancelActiveVoice();
2530
+ if (isComposerEditorUsable(ed)) ed.commands.clearContent();
2531
+ setEditorHasText(false);
2532
+ setSlotReferences([]);
2533
+ try {
2534
+ localStorage.removeItem(draftKey);
2535
+ } catch {}
2536
+ closePopover();
2537
+ },
2538
+ [
2539
+ closePopover,
2540
+ clearEditorAfterSubmit,
2541
+ composerMode,
2542
+ composerRuntime,
2543
+ draftKey,
2544
+ editor,
2545
+ interceptBuildRequestsForBuilder,
2546
+ clearOnSubmit,
2547
+ onBeforeSubmit,
2548
+ onSubmit,
2549
+ syncComposerState,
2550
+ voice,
2551
+ allSlashCommands,
2552
+ ],
2553
+ );
2554
+
2555
+ // Helper functions that operate on the editor view directly
2556
+ // These are called from handleKeyDown which can't use React state
2557
+ function selectMention(
2558
+ _view: any,
2559
+ pop: NonNullable<PopoverState>,
2560
+ item: MentionItem,
2561
+ ) {
2562
+ const ed = editor;
2563
+ if (!isComposerEditorUsable(ed)) return;
2564
+ const currentPos = ed.state.selection.from;
2565
+ // startPos is after the trigger char, so -1 to include the @ or /
2566
+ const deleteFrom = Math.max(0, pop.startPos - 1);
2567
+ ed.chain().focus().deleteRange({ from: deleteFrom, to: currentPos }).run();
2568
+ insertReference(composerReferenceFromMentionItem(item));
2569
+ popoverStateRef.current = null;
2570
+ setPopover(null);
2571
+ }
2572
+
2573
+ function executeCommand(
2574
+ _view: any,
2575
+ pop: NonNullable<PopoverState>,
2576
+ command: SlashCommand,
2577
+ ) {
2578
+ const ed = editor;
2579
+ if (!isComposerEditorUsable(ed)) return;
2580
+ const currentPos = ed.state.selection.from;
2581
+ const deleteFrom = Math.max(0, pop.startPos - 1);
2582
+ ed.chain().focus().deleteRange({ from: deleteFrom, to: currentPos }).run();
2583
+ popoverStateRef.current = null;
2584
+ setPopover(null);
2585
+ onSlashCommandRef.current?.(command.name);
2586
+ }
2587
+
2588
+ function selectSkill(
2589
+ _view: any,
2590
+ pop: NonNullable<PopoverState>,
2591
+ skill: SkillResult,
2592
+ ) {
2593
+ const ed = editor;
2594
+ if (!isComposerEditorUsable(ed)) return;
2595
+ const currentPos = ed.state.selection.from;
2596
+ const deleteFrom = Math.max(0, pop.startPos - 1);
2597
+ ed.chain()
2598
+ .focus()
2599
+ .deleteRange({ from: deleteFrom, to: currentPos })
2600
+ .insertContent({
2601
+ type: "skillReference",
2602
+ attrs: { name: skill.name, path: skill.path, source: skill.source },
2603
+ })
2604
+ .insertContent(" ")
2605
+ .run();
2606
+ popoverStateRef.current = null;
2607
+ setPopover(null);
2608
+ }
2609
+
2610
+ // Popover select handlers for click-based selection (from MentionPopover)
2611
+ const handleSelectMention = useCallback(
2612
+ (item: MentionItem) => {
2613
+ if (!isComposerEditorUsable(editor) || !popover) return;
2614
+ const currentPos = editor.state.selection.from;
2615
+ const deleteFrom = Math.max(0, popover.startPos - 1);
2616
+ editor
2617
+ .chain()
2618
+ .focus()
2619
+ .deleteRange({ from: deleteFrom, to: currentPos })
2620
+ .run();
2621
+ insertReference(composerReferenceFromMentionItem(item));
2622
+ closePopover();
2623
+ },
2624
+ [editor, popover, closePopover, insertReference],
2625
+ );
2626
+
2627
+ const handleSelectCommand = useCallback(
2628
+ (command: SlashCommand) => {
2629
+ if (!isComposerEditorUsable(editor) || !popover) return;
2630
+ const currentPos = editor.state.selection.from;
2631
+ const deleteFrom = Math.max(0, popover.startPos - 1);
2632
+ editor
2633
+ .chain()
2634
+ .focus()
2635
+ .deleteRange({ from: deleteFrom, to: currentPos })
2636
+ .run();
2637
+ closePopover();
2638
+ onSlashCommand?.(command.name);
2639
+ },
2640
+ [editor, popover, closePopover, onSlashCommand],
2641
+ );
2642
+
2643
+ const handleSelectSkill = useCallback(
2644
+ (skill: SkillResult) => {
2645
+ if (!isComposerEditorUsable(editor) || !popover) return;
2646
+ const currentPos = editor.state.selection.from;
2647
+ const deleteFrom = Math.max(0, popover.startPos - 1);
2648
+ editor
2649
+ .chain()
2650
+ .focus()
2651
+ .deleteRange({ from: deleteFrom, to: currentPos })
2652
+ .insertContent({
2653
+ type: "skillReference",
2654
+ attrs: { name: skill.name, path: skill.path, source: skill.source },
2655
+ })
2656
+ .insertContent(" ")
2657
+ .run();
2658
+ closePopover();
2659
+ },
2660
+ [editor, popover, closePopover],
2661
+ );
2662
+
2663
+ // Track query text as user types after trigger
2664
+ useEffect(() => {
2665
+ if (!isComposerEditorUsable(editor) || !popover) return;
2666
+
2667
+ const updateHandler = () => {
2668
+ const pop = popoverStateRef.current;
2669
+ if (!pop) return;
2670
+ const { from } = editor.state.selection;
2671
+ const { startPos, type } = pop;
2672
+
2673
+ if (from < startPos) {
2674
+ closePopover();
2675
+ return;
2676
+ }
2677
+
2678
+ const text = editor.state.doc.textBetween(startPos, from);
2679
+
2680
+ // Verify the trigger character is still there
2681
+ if (startPos > 0) {
2682
+ const triggerChar = editor.state.doc.textBetween(
2683
+ startPos - 1,
2684
+ startPos,
2685
+ );
2686
+ if (
2687
+ (type === "@" && triggerChar !== "@") ||
2688
+ (type === "/" && triggerChar !== "/")
2689
+ ) {
2690
+ closePopover();
2691
+ return;
2692
+ }
2693
+ }
2694
+
2695
+ if (pop.query === text) return;
2696
+ const updated = { ...pop, query: text };
2697
+ popoverStateRef.current = updated;
2698
+ setPopover(updated);
2699
+ };
2700
+
2701
+ editor.on("update", updateHandler);
2702
+ editor.on("selectionUpdate", updateHandler);
2703
+ return () => {
2704
+ if (isComposerEditorUsable(editor)) {
2705
+ editor.off("update", updateHandler);
2706
+ editor.off("selectionUpdate", updateHandler);
2707
+ }
2708
+ };
2709
+ }, [editor, popover, closePopover]);
2710
+
2711
+ useEffect(() => {
2712
+ if (!isComposerEditorUsable(editor)) return;
2713
+ if (composerText !== "") return;
2714
+ if (editor.isEmpty) return;
2715
+ editor.commands.clearContent();
2716
+ }, [composerText, editor]);
2717
+
2718
+ useEffect(() => {
2719
+ if (!isComposerEditorUsable(editor) || initialText === undefined) return;
2720
+ const key = initialTextKey ?? initialText;
2721
+ if (initialTextKeyRef.current === key) return;
2722
+ initialTextKeyRef.current = key;
2723
+ editor.commands.setContent(plainTextToDoc(initialText));
2724
+ editor.commands.focus("end");
2725
+ const trimmed = editor.state.doc.textContent.trim();
2726
+ setEditorHasText(trimmed.length > 0);
2727
+ composerRuntime.setText(trimmed);
2728
+ onTextChangeRef.current?.(trimmed);
2729
+ try {
2730
+ if (trimmed) {
2731
+ localStorage.setItem(draftKey, editor.getHTML());
2732
+ } else {
2733
+ localStorage.removeItem(draftKey);
2734
+ }
2735
+ } catch {}
2736
+ }, [composerRuntime, draftKey, editor, initialText, initialTextKey]);
2737
+
2738
+ // Tiptap only reads `editable` at init; prop changes need setEditable.
2739
+ useEffect(() => {
2740
+ if (!isComposerEditorUsable(editor)) return;
2741
+ editor.setEditable(!disabled);
2742
+ if (disabled) editor.commands.blur();
2743
+ }, [editor, disabled]);
2744
+
2745
+ return (
2746
+ <RealtimeVoiceModeBoundary>
2747
+ <style>{`
2748
+ .aui-composer .ProseMirror p.is-editor-empty:first-child::before {
2749
+ content: attr(data-placeholder);
2750
+ color: var(--color-muted-foreground);
2751
+ opacity: 0.5;
2752
+ float: left;
2753
+ height: 0;
2754
+ pointer-events: none;
2755
+ }
2756
+ `}</style>
2757
+ {composerMode && (
2758
+ <div
2759
+ data-agent-composer-variant={layoutVariant}
2760
+ data-agent-composer-slot="mode-row"
2761
+ className="agent-composer-mode-row px-2.5 pt-2 pb-0"
2762
+ >
2763
+ <ComposerModeChip
2764
+ mode={composerMode}
2765
+ onRemove={() => {
2766
+ setComposerMode(null);
2767
+ composerModeRef.current = null;
2768
+ if (isComposerEditorUsable(editor)) {
2769
+ editor.commands.focus("end");
2770
+ }
2771
+ }}
2772
+ />
2773
+ </div>
2774
+ )}
2775
+ {hasContextRows && (
2776
+ <div
2777
+ data-agent-composer-variant={layoutVariant}
2778
+ data-agent-composer-slot="context-row"
2779
+ className="agent-composer-context-row flex flex-wrap gap-1.5 px-2.5 pt-2 pb-0"
2780
+ >
2781
+ {slotReferences.map((ref) => (
2782
+ <span
2783
+ key={ref.slotKey}
2784
+ className="inline-flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-foreground shadow-sm"
2785
+ >
2786
+ <IconClipboardList className="h-3 w-3 shrink-0 text-muted-foreground" />
2787
+ {ref.slotLabel && (
2788
+ <span className="shrink-0 text-muted-foreground">
2789
+ {ref.slotLabel}
2790
+ </span>
2791
+ )}
2792
+ <span className="min-w-0 truncate">{ref.label}</span>
2793
+ <button
2794
+ type="button"
2795
+ onClick={() => {
2796
+ setSlotReferences((current) =>
2797
+ removeSlotReference(current, ref),
2798
+ );
2799
+ if (isComposerEditorUsable(editor)) {
2800
+ editor.commands.focus("end");
2801
+ }
2802
+ }}
2803
+ aria-label={`Remove ${slotReferenceTitle(ref)} reference`}
2804
+ className="ms-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
2805
+ >
2806
+ <IconX className="h-3 w-3" />
2807
+ </button>
2808
+ </span>
2809
+ ))}
2810
+ {contextItems.map((item) => (
2811
+ <span
2812
+ key={item.key}
2813
+ data-state={
2814
+ selectedContextItemKey === item.key ? "selected" : undefined
2815
+ }
2816
+ className={`inline-flex max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-medium text-foreground ${
2817
+ selectedContextItemKey === item.key
2818
+ ? "border-ring bg-accent ring-2 ring-ring/40"
2819
+ : "border-border bg-muted/50"
2820
+ }`}
2821
+ >
2822
+ <IconClipboardList className="h-3 w-3 shrink-0 text-muted-foreground" />
2823
+ <span className="min-w-0 truncate">{item.title}</span>
2824
+ <button
2825
+ type="button"
2826
+ onClick={() => {
2827
+ selectedContextItemKeyRef.current = null;
2828
+ setSelectedContextItemKey(null);
2829
+ onRemoveContextItem?.(item.key);
2830
+ }}
2831
+ aria-label={`Remove ${item.title} context`}
2832
+ className="ms-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
2833
+ >
2834
+ <IconX className="h-3 w-3" />
2835
+ </button>
2836
+ </span>
2837
+ ))}
2838
+ </div>
2839
+ )}
2840
+ <div
2841
+ data-agent-composer-variant={layoutVariant}
2842
+ data-agent-composer-slot="editor-wrap"
2843
+ className={`agent-composer-editor-wrap ${
2844
+ composerMode || hasContextRows ? "px-2 pt-1 pb-1" : "px-2 pt-2 pb-1"
2845
+ }`}
2846
+ >
2847
+ <EditorContent
2848
+ editor={editor}
2849
+ data-agent-composer-variant={layoutVariant}
2850
+ data-agent-composer-slot="editor"
2851
+ className="agent-composer-editor aui-composer flex-1 min-w-0 [&_.ProseMirror]:outline-none [&_.ProseMirror_p]:m-0 px-0.5"
2852
+ />
2853
+ </div>
2854
+ {voiceEnabled && <VoiceRecordingOverlay voice={voice} />}
2855
+ <div
2856
+ data-agent-composer-variant={layoutVariant}
2857
+ data-agent-composer-slot="toolbar"
2858
+ className="agent-composer-toolbar flex items-center gap-1 px-2 py-1.5"
2859
+ >
2860
+ {attachButton ??
2861
+ (plusMenuMode === "hidden" ? null : (
2862
+ <ComposerPlusMenu
2863
+ onSelectMode={handleSelectMode}
2864
+ mode={plusMenuMode}
2865
+ onAttachmentError={onAttachmentError}
2866
+ />
2867
+ ))}
2868
+ {toolbarSlot ?? modeControl}
2869
+ <div data-agent-composer-slot="toolbar-spacer" className="flex-1" />
2870
+ {selectedModel && availableModels && onModelChange && (
2871
+ <ModelSelector
2872
+ model={selectedModel}
2873
+ effort={selectedEffort}
2874
+ engines={availableModels}
2875
+ modelListLoading={modelListLoading}
2876
+ onChange={onModelChange}
2877
+ onEffortChange={onEffortChange}
2878
+ providerConnectStatusEnabled={providerConnectStatusEnabled}
2879
+ onConnectProvider={onConnectProvider}
2880
+ onConnectLocalRuntime={onConnectLocalRuntime}
2881
+ imageModel={imageModelMenu}
2882
+ />
2883
+ )}
2884
+ {execMode && onExecModeChange && (
2885
+ <ModeSelector
2886
+ mode={execMode}
2887
+ onChange={onExecModeChange}
2888
+ planModeDisabled={planModeDisabled}
2889
+ planModeDisabledReason={planModeDisabledReason}
2890
+ />
2891
+ )}
2892
+ {actionButton ?? (
2893
+ <>
2894
+ {extraActionButton}
2895
+ {voiceEnabled && (
2896
+ <VoiceButton voice={voice} isMac={isMac} disabled={disabled} />
2897
+ )}
2898
+ {primaryAction === "stop" ? (
2899
+ stopButton
2900
+ ) : (
2901
+ <Tooltip>
2902
+ <TooltipTrigger asChild>
2903
+ <button
2904
+ type="button"
2905
+ onClick={() => void submitComposer("immediate")}
2906
+ disabled={!canSend}
2907
+ data-agent-composer-slot="send-button"
2908
+ className="agent-composer-send-button shrink-0 flex h-7 w-7 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90 transition-[opacity,transform] duration-150 active:scale-[0.97] disabled:opacity-30 disabled:cursor-not-allowed"
2909
+ >
2910
+ <IconArrowUp className="h-3.5 w-3.5" />
2911
+ </button>
2912
+ </TooltipTrigger>
2913
+ <TooltipContent>Send message</TooltipContent>
2914
+ </Tooltip>
2915
+ )}
2916
+ </>
2917
+ )}
2918
+ </div>
2919
+ <MentionPopover
2920
+ ref={popoverRef}
2921
+ type={popover?.type ?? "@"}
2922
+ position={popover?.position ?? null}
2923
+ mentionItems={filteredMentionItems}
2924
+ skills={filteredSkills}
2925
+ commands={filteredCommands}
2926
+ hint={hint}
2927
+ isLoading={popover?.type === "@" ? mentionsLoading : skillsLoading}
2928
+ query={popover?.query ?? ""}
2929
+ onSelectMention={handleSelectMention}
2930
+ onSelectSkill={handleSelectSkill}
2931
+ onSelectCommand={handleSelectCommand}
2932
+ onClose={closePopover}
2933
+ />
2934
+ </RealtimeVoiceModeBoundary>
2935
+ );
2936
+ }