@adecore/agents-react 0.23.1 → 0.24.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 (341) hide show
  1. package/README.md +14 -11
  2. package/dist/agents/AccountDot.js +3 -1
  3. package/dist/agents/accounts.d.ts +3 -2
  4. package/dist/agents/accounts.js +5 -6
  5. package/dist/chat/actions.d.ts +2 -2
  6. package/dist/chat/actions.js +8 -5
  7. package/dist/chat/bookmarks.d.ts +5 -3
  8. package/dist/chat/bookmarks.js +13 -14
  9. package/dist/chat/chat-client.d.ts +1 -0
  10. package/dist/chat/chat-client.js +7 -0
  11. package/dist/chat/composer-send.d.ts +3 -0
  12. package/dist/chat/composer-send.js +22 -0
  13. package/dist/chat/forks.js +22 -5
  14. package/dist/chat/index.d.ts +31 -0
  15. package/dist/chat/index.js +28 -0
  16. package/dist/chat/logic/embedded-code.d.ts +8 -0
  17. package/dist/chat/logic/embedded-code.js +371 -0
  18. package/dist/chat/logic/fork.js +1 -0
  19. package/dist/chat/logic/goal.d.ts +6 -0
  20. package/dist/chat/logic/goal.js +26 -0
  21. package/dist/chat/logic/scrubber.d.ts +1 -1
  22. package/dist/chat/logic/scrubber.js +6 -5
  23. package/dist/chat/logic/thought.d.ts +3 -0
  24. package/dist/chat/logic/thought.js +35 -0
  25. package/dist/chat/logic/thread-cards.js +2 -0
  26. package/dist/chat/logic/timeline.d.ts +5 -0
  27. package/dist/chat/logic/timeline.js +22 -1
  28. package/dist/chat/logic/tool-catalog.d.ts +1 -0
  29. package/dist/chat/logic/tool-catalog.js +3 -1
  30. package/dist/chat/logic/tools.d.ts +14 -0
  31. package/dist/chat/logic/tools.js +57 -0
  32. package/dist/chat/preferences.d.ts +6 -0
  33. package/dist/chat/preferences.js +19 -3
  34. package/dist/chat/reading-width.d.ts +9 -0
  35. package/dist/chat/reading-width.js +27 -0
  36. package/dist/chat/send-key.d.ts +10 -0
  37. package/dist/chat/send-key.js +24 -0
  38. package/dist/chat/ui/BookmarkMarker.js +4 -2
  39. package/dist/chat/ui/BookmarkSubmenu.js +4 -3
  40. package/dist/chat/ui/ChatActivity.js +14 -10
  41. package/dist/chat/ui/ChatReferenceChip.js +3 -4
  42. package/dist/chat/ui/CodeBlock.d.ts +2 -1
  43. package/dist/chat/ui/CodeBlock.js +14 -96
  44. package/dist/chat/ui/Composer.d.ts +1 -1
  45. package/dist/chat/ui/Composer.js +117 -906
  46. package/dist/chat/ui/ComposerInput.js +5 -4
  47. package/dist/chat/ui/EditDiff.js +3 -2
  48. package/dist/chat/ui/GoalDock.d.ts +9 -0
  49. package/dist/chat/ui/GoalDock.js +63 -0
  50. package/dist/chat/ui/LimitState.js +5 -3
  51. package/dist/chat/ui/Markdown.d.ts +2 -1
  52. package/dist/chat/ui/Markdown.js +70 -31
  53. package/dist/chat/ui/MermaidBlock.d.ts +3 -0
  54. package/dist/chat/ui/MermaidBlock.js +73 -0
  55. package/dist/chat/ui/MessageActions.js +12 -7
  56. package/dist/chat/ui/Pickers.js +6 -5
  57. package/dist/chat/ui/PromptComposer.js +4 -3
  58. package/dist/chat/ui/RowTime.d.ts +3 -0
  59. package/dist/chat/ui/RowTime.js +12 -0
  60. package/dist/chat/ui/RunSettings.js +5 -4
  61. package/dist/chat/ui/ShellCommand.d.ts +3 -0
  62. package/dist/chat/ui/ShellCommand.js +110 -0
  63. package/dist/chat/ui/SubagentInfo.js +4 -3
  64. package/dist/chat/ui/SubagentStopButton.js +4 -3
  65. package/dist/chat/ui/SubagentTimeline.js +4 -1
  66. package/dist/chat/ui/Timeline.js +70 -40
  67. package/dist/chat/ui/TimelineMenu.d.ts +2 -1
  68. package/dist/chat/ui/TimelineMenu.js +18 -8
  69. package/dist/chat/ui/UnifiedDiff.js +3 -2
  70. package/dist/chat/ui/VisualFrame.js +6 -7
  71. package/dist/chat/ui/code-theme.d.ts +2 -1
  72. package/dist/chat/ui/code-theme.js +4 -4
  73. package/dist/chat/ui/composer/ComposerControls.d.ts +27 -0
  74. package/dist/chat/ui/composer/ComposerControls.js +34 -0
  75. package/dist/chat/ui/composer/ComposerMenu.d.ts +4 -0
  76. package/dist/chat/ui/composer/ComposerMenu.js +46 -0
  77. package/dist/chat/ui/composer/DraftParts.d.ts +18 -0
  78. package/dist/chat/ui/composer/DraftParts.js +25 -0
  79. package/dist/chat/ui/composer/QueuedMessages.d.ts +10 -0
  80. package/dist/chat/ui/composer/QueuedMessages.js +20 -0
  81. package/dist/chat/ui/composer/key-handler.d.ts +20 -0
  82. package/dist/chat/ui/composer/key-handler.js +150 -0
  83. package/dist/chat/ui/composer/keys.d.ts +7 -2
  84. package/dist/chat/ui/composer/keys.js +19 -9
  85. package/dist/chat/ui/composer/placeholder.d.ts +9 -0
  86. package/dist/chat/ui/composer/placeholder.js +38 -0
  87. package/dist/chat/ui/composer/use-chat-controls.d.ts +28 -0
  88. package/dist/chat/ui/composer/use-chat-controls.js +112 -0
  89. package/dist/chat/ui/composer/use-compaction-offer.d.ts +2 -0
  90. package/dist/chat/ui/composer/use-compaction-offer.js +23 -0
  91. package/dist/chat/ui/composer/use-composer-draft.d.ts +14 -0
  92. package/dist/chat/ui/composer/use-composer-draft.js +39 -0
  93. package/dist/chat/ui/composer/use-composer-menus.d.ts +52 -0
  94. package/dist/chat/ui/composer/use-composer-menus.js +232 -0
  95. package/dist/chat/ui/composer/use-composer-submit.d.ts +32 -0
  96. package/dist/chat/ui/composer/use-composer-submit.js +113 -0
  97. package/dist/chat/ui/composer/use-draft-files.d.ts +27 -0
  98. package/dist/chat/ui/composer/use-draft-files.js +111 -0
  99. package/dist/chat/ui/composer/use-notice.d.ts +6 -0
  100. package/dist/chat/ui/composer/use-notice.js +18 -0
  101. package/dist/chat/ui/composer/use-pending-requests.d.ts +2 -0
  102. package/dist/chat/ui/composer/use-pending-requests.js +22 -0
  103. package/dist/chat/ui/composer/use-prompt-history.d.ts +8 -0
  104. package/dist/chat/ui/composer/use-prompt-history.js +57 -0
  105. package/dist/chat/ui/composer/use-prompt-hold.d.ts +6 -0
  106. package/dist/chat/ui/composer/use-prompt-hold.js +48 -0
  107. package/dist/chat/ui/composer/use-take-back.d.ts +13 -0
  108. package/dist/chat/ui/composer/use-take-back.js +82 -0
  109. package/dist/chat/ui/diff/EditorEditDiff.js +4 -3
  110. package/dist/chat/ui/diff/LegacyDiffPool.js +3 -2
  111. package/dist/chat/ui/diff/LegacyEditDiff.js +3 -2
  112. package/dist/chat/ui/diff/LegacyUnifiedDiff.js +3 -2
  113. package/dist/chat/ui/diff/syntax-runtime.d.ts +2 -1
  114. package/dist/chat/ui/diff/syntax-runtime.js +9 -5
  115. package/dist/chat/ui/diff-syntax.d.ts +2 -2
  116. package/dist/chat/ui/diff-syntax.js +1 -2
  117. package/dist/chat/ui/diff-theme.js +10 -9
  118. package/dist/chat/ui/file-links.d.ts +1 -1
  119. package/dist/chat/ui/file-links.js +7 -5
  120. package/dist/chat/ui/highlighter.d.ts +27 -0
  121. package/dist/chat/ui/highlighter.js +151 -0
  122. package/dist/chat/ui/index.d.ts +67 -0
  123. package/dist/chat/ui/index.js +64 -0
  124. package/dist/chat/ui/intelligent-ui/UiReply.js +3 -2
  125. package/dist/chat/ui/intelligent-ui/renderers/content.js +3 -2
  126. package/dist/chat/ui/intelligent-ui/use-ui-links.js +2 -2
  127. package/dist/chat/ui/intelligent-ui/use-ui-queries.js +4 -3
  128. package/dist/chat/ui/markdown-blocks.d.ts +8 -0
  129. package/dist/chat/ui/markdown-blocks.js +260 -12
  130. package/dist/chat/ui/mermaid.d.ts +23 -0
  131. package/dist/chat/ui/mermaid.js +93 -0
  132. package/dist/chat/ui/rows/ForksRow.js +5 -3
  133. package/dist/chat/ui/rows/GeneratedImageRow.js +6 -4
  134. package/dist/chat/ui/rows/MessageRows.d.ts +4 -0
  135. package/dist/chat/ui/rows/MessageRows.js +36 -13
  136. package/dist/chat/ui/rows/Rows.js +3 -1
  137. package/dist/chat/ui/rows/SubagentRow.js +3 -2
  138. package/dist/chat/ui/rows/VisualRow.js +3 -2
  139. package/dist/chat/ui/rows/WorkRows.d.ts +6 -3
  140. package/dist/chat/ui/rows/WorkRows.js +57 -20
  141. package/dist/chat/ui/rows/WorkflowRow.js +3 -2
  142. package/dist/chat/ui/timestamp-format.d.ts +1 -0
  143. package/dist/chat/ui/timestamp-format.js +2 -0
  144. package/dist/chat/ui/use-diff-source-tokens.js +6 -3
  145. package/dist/chat/ui/visual-theme.js +3 -2
  146. package/dist/host.d.ts +9 -6
  147. package/dist/host.js +19 -8
  148. package/dist/index.d.ts +8 -0
  149. package/dist/index.js +9 -0
  150. package/dist/locales/en/agent-chat.d.ts +658 -0
  151. package/dist/locales/en/agent-chat.js +657 -0
  152. package/dist/locales/en/agent-prompts.d.ts +44 -0
  153. package/dist/locales/en/agent-prompts.js +43 -0
  154. package/dist/locales/en/agent-providers.d.ts +132 -0
  155. package/dist/locales/en/agent-providers.js +131 -0
  156. package/dist/locales/en/agent-usage.d.ts +132 -0
  157. package/dist/locales/en/agent-usage.js +131 -0
  158. package/dist/locales/nl/agent-chat.d.ts +658 -0
  159. package/dist/locales/nl/agent-chat.js +657 -0
  160. package/dist/locales/nl/agent-prompts.d.ts +44 -0
  161. package/dist/locales/nl/agent-prompts.js +43 -0
  162. package/dist/locales/nl/agent-providers.d.ts +132 -0
  163. package/dist/locales/nl/agent-providers.js +131 -0
  164. package/dist/locales/nl/agent-usage.d.ts +132 -0
  165. package/dist/locales/nl/agent-usage.js +131 -0
  166. package/dist/locales.js +9 -8
  167. package/dist/prompts/index.d.ts +11 -0
  168. package/dist/prompts/index.js +12 -0
  169. package/dist/prompts/logic/subjects.d.ts +2 -1
  170. package/dist/prompts/logic/subjects.js +6 -4
  171. package/dist/prompts/ui/ApprovalBody.js +6 -4
  172. package/dist/prompts/ui/PromptCard.js +5 -4
  173. package/dist/prompts/ui/PromptView.js +3 -2
  174. package/dist/prompts/ui/QuestionBody.js +3 -2
  175. package/dist/providers/AccountColors.js +2 -2
  176. package/dist/providers/AccountDetail.js +7 -6
  177. package/dist/providers/AccountVariables.js +3 -1
  178. package/dist/providers/AddAccountForm.js +3 -2
  179. package/dist/providers/ProvidersPane.js +4 -3
  180. package/dist/providers/account-actions.d.ts +4 -3
  181. package/dist/providers/account-actions.js +6 -7
  182. package/dist/providers/index.d.ts +16 -0
  183. package/dist/providers/index.js +17 -0
  184. package/dist/providers/provider-abilities.d.ts +2 -1
  185. package/dist/providers/provider-abilities.js +4 -0
  186. package/dist/state/chats.js +4 -2
  187. package/dist/state/index.d.ts +4 -0
  188. package/dist/state/index.js +5 -0
  189. package/dist/testing/index.d.ts +1 -0
  190. package/dist/testing/index.js +2 -0
  191. package/dist/{prompts/logic/prompts.fixtures.d.ts → testing/prompts.d.ts} +1 -1
  192. package/dist/theme.css +13 -3
  193. package/dist/usage/UsageBreakdown.js +3 -2
  194. package/dist/usage/UsageLimits.js +4 -3
  195. package/dist/usage/UsagePage.js +3 -2
  196. package/dist/usage/index.d.ts +15 -0
  197. package/dist/usage/index.js +15 -0
  198. package/package.json +53 -1031
  199. package/src/agents/AccountDot.tsx +3 -1
  200. package/src/agents/accounts.ts +6 -6
  201. package/src/chat/actions.ts +8 -5
  202. package/src/chat/bookmarks.ts +23 -14
  203. package/src/chat/chat-client.ts +8 -0
  204. package/src/chat/composer-send.ts +27 -0
  205. package/src/chat/forks.ts +27 -6
  206. package/src/chat/index.ts +98 -0
  207. package/src/chat/logic/embedded-code.ts +409 -0
  208. package/src/chat/logic/fork.ts +1 -0
  209. package/src/chat/logic/goal.ts +33 -0
  210. package/src/chat/logic/scrubber.ts +6 -5
  211. package/src/chat/logic/thought.ts +40 -0
  212. package/src/chat/logic/thread-cards.ts +2 -0
  213. package/src/chat/logic/timeline.ts +25 -1
  214. package/src/chat/logic/tool-catalog.ts +5 -1
  215. package/src/chat/logic/tools.ts +76 -0
  216. package/src/chat/preferences.ts +26 -3
  217. package/src/chat/reading-width.ts +39 -0
  218. package/src/chat/send-key.ts +38 -0
  219. package/src/chat/ui/BookmarkMarker.tsx +4 -2
  220. package/src/chat/ui/BookmarkSubmenu.tsx +4 -3
  221. package/src/chat/ui/ChatActivity.tsx +14 -10
  222. package/src/chat/ui/ChatReferenceChip.tsx +3 -4
  223. package/src/chat/ui/CodeBlock.tsx +33 -124
  224. package/src/chat/ui/Composer.tsx +162 -1324
  225. package/src/chat/ui/ComposerInput.tsx +5 -4
  226. package/src/chat/ui/EditDiff.tsx +3 -2
  227. package/src/chat/ui/GoalDock.tsx +104 -0
  228. package/src/chat/ui/LimitState.tsx +5 -3
  229. package/src/chat/ui/Markdown.tsx +113 -39
  230. package/src/chat/ui/MermaidBlock.tsx +119 -0
  231. package/src/chat/ui/MessageActions.tsx +13 -8
  232. package/src/chat/ui/Pickers.tsx +8 -7
  233. package/src/chat/ui/PromptComposer.tsx +4 -3
  234. package/src/chat/ui/RowTime.tsx +21 -0
  235. package/src/chat/ui/RunSettings.tsx +9 -4
  236. package/src/chat/ui/ShellCommand.tsx +134 -0
  237. package/src/chat/ui/SubagentInfo.tsx +4 -3
  238. package/src/chat/ui/SubagentStopButton.tsx +4 -3
  239. package/src/chat/ui/SubagentTimeline.tsx +5 -1
  240. package/src/chat/ui/Timeline.tsx +167 -105
  241. package/src/chat/ui/TimelineMenu.tsx +42 -7
  242. package/src/chat/ui/UnifiedDiff.tsx +3 -2
  243. package/src/chat/ui/VisualFrame.tsx +6 -7
  244. package/src/chat/ui/code-theme.ts +4 -4
  245. package/src/chat/ui/composer/ComposerControls.tsx +147 -0
  246. package/src/chat/ui/composer/ComposerMenu.tsx +138 -0
  247. package/src/chat/ui/composer/DraftParts.tsx +86 -0
  248. package/src/chat/ui/composer/QueuedMessages.tsx +64 -0
  249. package/src/chat/ui/composer/key-handler.ts +174 -0
  250. package/src/chat/ui/composer/keys.ts +32 -10
  251. package/src/chat/ui/composer/placeholder.ts +48 -0
  252. package/src/chat/ui/composer/use-chat-controls.ts +134 -0
  253. package/src/chat/ui/composer/use-compaction-offer.ts +25 -0
  254. package/src/chat/ui/composer/use-composer-draft.ts +71 -0
  255. package/src/chat/ui/composer/use-composer-menus.ts +289 -0
  256. package/src/chat/ui/composer/use-composer-submit.ts +163 -0
  257. package/src/chat/ui/composer/use-draft-files.ts +135 -0
  258. package/src/chat/ui/composer/use-notice.ts +28 -0
  259. package/src/chat/ui/composer/use-pending-requests.ts +23 -0
  260. package/src/chat/ui/composer/use-prompt-history.ts +76 -0
  261. package/src/chat/ui/composer/use-prompt-hold.ts +58 -0
  262. package/src/chat/ui/composer/use-take-back.ts +91 -0
  263. package/src/chat/ui/diff/DiffFallbackBoundary.tsx +2 -2
  264. package/src/chat/ui/diff/EditorEditDiff.tsx +4 -3
  265. package/src/chat/ui/diff/LegacyDiffPool.tsx +3 -2
  266. package/src/chat/ui/diff/LegacyEditDiff.tsx +3 -2
  267. package/src/chat/ui/diff/LegacyUnifiedDiff.tsx +3 -2
  268. package/src/chat/ui/diff/syntax-runtime.ts +10 -5
  269. package/src/chat/ui/diff-syntax.ts +2 -2
  270. package/src/chat/ui/diff-theme.ts +10 -9
  271. package/src/chat/ui/file-links.ts +7 -5
  272. package/src/chat/ui/highlighter.ts +185 -0
  273. package/src/chat/ui/index.ts +109 -0
  274. package/src/chat/ui/intelligent-ui/UiReply.tsx +5 -4
  275. package/src/chat/ui/intelligent-ui/renderers/content.tsx +3 -2
  276. package/src/chat/ui/intelligent-ui/use-ui-links.ts +2 -2
  277. package/src/chat/ui/intelligent-ui/use-ui-queries.ts +4 -3
  278. package/src/chat/ui/markdown-blocks.ts +300 -12
  279. package/src/chat/ui/mermaid.ts +118 -0
  280. package/src/chat/ui/rows/ForksRow.tsx +5 -3
  281. package/src/chat/ui/rows/GeneratedImageRow.tsx +6 -4
  282. package/src/chat/ui/rows/MessageRows.tsx +73 -18
  283. package/src/chat/ui/rows/Rows.tsx +14 -1
  284. package/src/chat/ui/rows/SubagentRow.tsx +3 -2
  285. package/src/chat/ui/rows/VisualRow.tsx +3 -2
  286. package/src/chat/ui/rows/WorkRows.tsx +148 -47
  287. package/src/chat/ui/rows/WorkflowRow.tsx +3 -2
  288. package/src/chat/ui/timestamp-format.ts +2 -0
  289. package/src/chat/ui/use-diff-source-tokens.ts +10 -4
  290. package/src/chat/ui/visual-theme.ts +3 -2
  291. package/src/host.ts +31 -15
  292. package/src/index.ts +49 -0
  293. package/src/locales/en/agent-chat.ts +657 -0
  294. package/src/locales/en/agent-prompts.ts +43 -0
  295. package/src/locales/en/agent-providers.ts +131 -0
  296. package/src/locales/en/agent-usage.ts +131 -0
  297. package/src/locales/nl/agent-chat.ts +657 -0
  298. package/src/locales/nl/agent-prompts.ts +43 -0
  299. package/src/locales/nl/agent-providers.ts +131 -0
  300. package/src/locales/nl/agent-usage.ts +132 -0
  301. package/src/locales.ts +9 -8
  302. package/src/prompts/index.ts +35 -0
  303. package/src/prompts/logic/subjects.ts +12 -4
  304. package/src/prompts/ui/ApprovalBody.tsx +6 -4
  305. package/src/prompts/ui/PromptCard.tsx +11 -4
  306. package/src/prompts/ui/PromptView.tsx +3 -2
  307. package/src/prompts/ui/QuestionBody.tsx +3 -2
  308. package/src/providers/AccountColors.tsx +2 -2
  309. package/src/providers/AccountDetail.tsx +13 -7
  310. package/src/providers/AccountVariables.tsx +3 -1
  311. package/src/providers/AddAccountForm.tsx +3 -2
  312. package/src/providers/ProvidersPane.tsx +4 -3
  313. package/src/providers/account-actions.ts +7 -7
  314. package/src/providers/index.ts +17 -0
  315. package/src/providers/provider-abilities.ts +6 -1
  316. package/src/state/chats.ts +4 -2
  317. package/src/state/index.ts +39 -0
  318. package/src/testing/index.ts +2 -0
  319. package/src/{prompts/logic/prompts.fixtures.ts → testing/prompts.ts} +1 -1
  320. package/src/theme.css +13 -3
  321. package/src/usage/UsageBreakdown.tsx +3 -2
  322. package/src/usage/UsageLimits.tsx +4 -3
  323. package/src/usage/UsagePage.tsx +3 -2
  324. package/src/usage/index.ts +16 -0
  325. package/dist/locales/en/agent-chat.json +0 -597
  326. package/dist/locales/en/agent-prompts.json +0 -43
  327. package/dist/locales/en/agent-providers.json +0 -131
  328. package/dist/locales/en/agent-usage.json +0 -131
  329. package/dist/locales/nl/agent-chat.json +0 -597
  330. package/dist/locales/nl/agent-prompts.json +0 -43
  331. package/dist/locales/nl/agent-providers.json +0 -131
  332. package/dist/locales/nl/agent-usage.json +0 -131
  333. package/src/locales/en/agent-chat.json +0 -597
  334. package/src/locales/en/agent-prompts.json +0 -43
  335. package/src/locales/en/agent-providers.json +0 -131
  336. package/src/locales/en/agent-usage.json +0 -131
  337. package/src/locales/nl/agent-chat.json +0 -597
  338. package/src/locales/nl/agent-prompts.json +0 -43
  339. package/src/locales/nl/agent-providers.json +0 -131
  340. package/src/locales/nl/agent-usage.json +0 -131
  341. /package/dist/{prompts/logic/prompts.fixtures.js → testing/prompts.js} +0 -0
@@ -1,109 +1,46 @@
1
- import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from 'react';
1
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
2
2
  import { useTranslation } from 'react-i18next';
3
- import { indentLess, indentMore, insertNewline } from '@codemirror/commands';
4
- import { ensureSyntaxTree, syntaxTree } from '@codemirror/language';
5
- import type { EditorState } from '@codemirror/state';
6
- import { EditorView } from '@codemirror/view';
7
3
  import clsx from 'clsx';
8
- import { ArrowUp, ChevronDown, Clock, Copy, FastForward, MessageSquare, Paperclip, Pencil, Plus, Square, SquareSlash, X, Zap } from 'lucide-react';
9
- import type {
10
- AgentKind,
11
- ChatApprovalItem,
12
- ChatAttachmentUpload,
13
- ChatInfo,
14
- ChatItem,
15
- ChatQueuedMessage,
16
- ChatQuestionItem,
17
- ChatSkill,
18
- ModelInfo,
19
- ModelSelection,
20
- RuntimeMode
21
- } from '@adecore/agent-contracts';
22
- import { chatHost } from '../../host';
4
+ import { ChevronDown } from 'lucide-react';
5
+ import type { AgentKind, ChatInfo, ModelSelection } from '@adecore/agent-contracts';
6
+ import { Icon, PromptDialog, Surface, Tooltip } from '@adecore/ui';
7
+ import { formatNumber } from '@adecore/ui/format';
8
+ import { useChatHost } from '../../host';
9
+ import { canSteer } from '../../providers/provider-abilities';
23
10
  import { useChatScope } from '../../scope';
24
- import { errorCode } from '../../transport';
25
- import { useAccountChoice } from '../account-choice';
26
- import { useChatActions } from '../actions';
11
+ import { useProviders } from '../../state/providers';
27
12
  import type { ChatSendExtras } from '../chat-client';
28
- import { checkAttachmentLimits, filesOf, formatBytes, isImageAttachment, readAttachments, readStoredAttachments, uploadBytes } from '../attachments';
29
- import { chatSuggestions } from '../chat-references';
30
- import { EMPTY_DRAFT, isEmptyDraft, joinDraftText, readDraft, takeBackIntoDraft, takeDraftOffers, writeDraft, type ChatDraft } from '../drafts';
31
- import {
32
- carriesMentions,
33
- droppedMentions,
34
- NO_MENTION_SEARCH,
35
- dropQuery,
36
- findMentionQuery,
37
- findSkillQuery,
38
- insertMention,
39
- insertSkill,
40
- mentionPick,
41
- pastedMentions,
42
- presentMentions,
43
- presentSkills,
44
- type MentionQuery,
45
- type MentionSearch,
46
- type TextRange
47
- } from '../mentions';
48
- import { RESUME_COMPACTION_TOKENS, resumeCompactionOffer } from '../logic/resume-compaction';
49
- import { dismissResumeCompaction, useResumeCompactionDismissal } from '../resume-compaction-dismissals';
50
- import { composerStopLabel, composerStopOf } from '../subagent-list';
51
- import { PROMPT_MAX_CHARS, pasteBecomesAttachment, pastedTextName, promptGuard, usableSlashCommands } from '../guards';
13
+ import { isEmptyDraft } from '../drafts';
14
+ import { PROMPT_MAX_CHARS, promptGuard } from '../guards';
15
+ import { useChatPreferences } from '../preferences';
52
16
  import { withQuote } from '../quote';
53
- import { rememberChatAccount, rememberChatPreferences, rememberChatSelection } from '../preferences';
54
- import { STASH_SHORTCUT, stashDraft, type StashedPrompt, useStash } from '../stash';
55
- import { jumpToTimelineItem, pageTimeline, scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
17
+ import { dismissResumeCompaction } from '../resume-compaction-dismissals';
18
+ import { sendShortcut } from '../send-key';
19
+ import { scrollTimelineToEnd, subscribeTimelineEnd, timelineAtEnd } from '../timeline-scroll';
56
20
  import { ChatActivity } from './ChatActivity';
57
- import { ChatReferenceChip } from './ChatReferenceChip';
58
- import { chipDecorations } from './composer/chips';
59
- import { clampMenuIndex, composerMenuAria, optionIdOf } from './composer/menu-aria';
60
- import { enterAction, inCode, inFenceBody, inOpenFence, listItemAt, recallDirection, tabSpaces } from './composer/keys';
61
21
  import { ComposerInput, type ComposerInputHandle } from './ComposerInput';
62
- import { holdLiftsIn, type HoldRelease } from '../prompt-hold';
63
- import { PromptComposer, type PromptHold } from './PromptComposer';
64
- import { QuoteTakerContext } from './quote-selection';
22
+ import { GoalDock } from './GoalDock';
65
23
  import { LimitDock } from './LimitState';
24
+ import { PromptComposer } from './PromptComposer';
66
25
  import { ResumeCompactionDock } from './ResumeCompactionDock';
67
- import { StashPicker } from './Pickers';
68
- import { RunSettings } from './RunSettings';
69
- import { UploadThumb } from './UploadThumb';
70
- import { formatNumber } from '@adecore/ui/format';
71
- import {
72
- ButtonGroup,
73
- ContextMenu,
74
- copyText,
75
- ErrorBoundary,
76
- FileIcon,
77
- Icon,
78
- IconButton,
79
- isModHeld,
80
- KEY_SHORTCUTS,
81
- matchesShortcut,
82
- Menu,
83
- PromptDialog,
84
- Surface,
85
- Tooltip,
86
- useNow
87
- } from '@adecore/ui';
88
- import { useChatRow, waitingRequestsOf } from '../../state/chats';
89
- import { useProviders } from '../../state/providers';
90
-
91
- const SEARCH_DEBOUNCE_MS = 80;
92
- // What fits above the composer without turning the picker into a file tree.
93
- const MENTION_RESULTS = 8;
94
- // The chats go above the files, so a few leave the files in view.
95
- const CHAT_RESULTS = 4;
96
- const NOTICE_MS = 4000;
97
- // How many sent prompts the arrow keys walk back through, per chat.
98
- const PROMPT_HISTORY = 50;
99
-
100
- // Commands the composer handles itself; the CLI's own ones are sent through as text.
101
- const LOCAL_COMMANDS = ['model', 'compact', 'clear'];
102
-
103
- /* The card turns dropped files and paths into attachments and chips; CodeMirror would also paste their text at the drop point. */
104
- const LEAVE_DROPS_TO_THE_CARD = EditorView.domEventHandlers({
105
- drop: (event) => event.dataTransfer !== null && (event.dataTransfer.types.includes('Files') || carriesMentions(event.dataTransfer.types))
106
- });
26
+ import { chipDecorations } from './composer/chips';
27
+ import { ComposerControls } from './composer/ComposerControls';
28
+ import { ComposerMenu } from './composer/ComposerMenu';
29
+ import { DraftAttachments, DraftChats, DraftQuote } from './composer/DraftParts';
30
+ import { composerKeyHandler } from './composer/key-handler';
31
+ import { useHintedPlaceholder } from './composer/placeholder';
32
+ import { QueuedMessages } from './composer/QueuedMessages';
33
+ import { useChatControls } from './composer/use-chat-controls';
34
+ import { useCompactionOffer } from './composer/use-compaction-offer';
35
+ import { useComposerDraft } from './composer/use-composer-draft';
36
+ import { useComposerMenus } from './composer/use-composer-menus';
37
+ import { useComposerSubmit } from './composer/use-composer-submit';
38
+ import { LEAVE_DROPS_TO_THE_CARD, useDraftFiles } from './composer/use-draft-files';
39
+ import { useNotice } from './composer/use-notice';
40
+ import { usePendingRequests } from './composer/use-pending-requests';
41
+ import { usePromptHistory } from './composer/use-prompt-history';
42
+ import { usePromptHold } from './composer/use-prompt-hold';
43
+ import { useTakeBack } from './composer/use-take-back';
107
44
 
108
45
  interface ComposerProps {
109
46
  chatId: string;
@@ -119,916 +56,103 @@ interface ComposerProps {
119
56
  readOnly?: boolean;
120
57
  /* Opened for one CLI from a menu. The model is the remembered one and there is nothing to pick. */
121
58
  providerFixed: boolean;
122
- onSend(text: string, extras: ChatSendExtras): void;
59
+ /* A promise says whether the host took the message, which a send that also opens a new chat waits for. */
60
+ onSend(text: string, extras: ChatSendExtras): void | Promise<boolean>;
123
61
  /* Another provider's model was picked before the first message; whatever holds the chat has to follow. */
124
62
  onRetarget(provider: AgentKind, selection: ModelSelection): Promise<void>;
125
63
  }
126
64
 
127
- function usePendingRequests(chatId: string) {
128
- // The structure, which a delta leaves alone. The prompts and the requests are items of their own.
129
- const structure = useChatRow(chatId, (row) => row?.structure);
130
- const order = useChatRow(chatId, (row) => row?.order);
131
- const waitingBefore = useChatRow(chatId, (row) => row?.waitingBefore);
132
- return useMemo(() => {
133
- const approvals: ChatApprovalItem[] = [];
134
- const questions: ChatQuestionItem[] = [];
135
- for (const item of waitingRequestsOf(structure && order ? { structure, order, waitingBefore } : undefined)) {
136
- if (item.kind === 'approval') {
137
- approvals.push(item);
138
- } else {
139
- questions.push(item);
140
- }
141
- }
142
- return { approvals, questions };
143
- }, [structure, order, waitingBefore]);
144
- }
145
-
146
- /* The empty box names its sigils as key caps, so they read as keys to press rather than as punctuation. */
147
- function hintedPlaceholder(lead: string, joiner: string, hints: ReadonlyArray<{ key: string; label: string }>): HTMLElement {
148
- const root = document.createElement('span');
149
- root.className = 'composer-hints';
150
- const extra = document.createElement('span');
151
- extra.className = 'composer-hints-extra';
152
- extra.append(joiner);
153
- for (const hint of hints) {
154
- const cap = document.createElement('kbd');
155
- cap.textContent = hint.key;
156
- extra.append(cap, hint.label);
157
- }
158
- root.append(lead, extra);
159
- return root;
160
- }
161
-
162
- function sendLabelKey(busy: boolean, canSteer: boolean, delivery: 'queue' | 'steer'): string {
65
+ function sendLabelKey(busy: boolean, steerable: boolean, delivery: 'queue' | 'steer'): string {
163
66
  if (!busy) {
164
67
  return 'composer.send';
165
68
  }
166
- if (!canSteer) {
69
+ if (!steerable) {
167
70
  return 'composer.queueButton';
168
71
  }
169
72
  return delivery === 'steer' ? 'composer.steerButton' : 'composer.queueWithSteerButton';
170
73
  }
171
74
 
172
- function sameQuery(left: MentionQuery | null, right: MentionQuery | null): boolean {
173
- return left?.start === right?.start && left?.query === right?.query;
174
- }
175
-
176
- function splitPath(path: string): { name: string; dir: string } {
177
- const slash = path.lastIndexOf('/');
178
- return slash < 0 ? { name: path, dir: '' } : { name: path.slice(slash + 1), dir: path.slice(0, slash) };
179
- }
180
-
181
- // Keep the editor mounted while a pending request takes over, so its selection and draft survive.
182
- /*
183
- * Keeps a waiting prompt card behind the draft while the person types. Typing, pasting and dictating
184
- * all change the draft, so `noteInput` is called from its change handler; the hold lifts after a pause.
185
- */
186
- function usePromptHold(): PromptHold & { noteInput(): void; setComposing(composing: boolean): void } {
187
- const typedAt = useRef<number | null>(null);
188
- const composing = useRef(false);
189
- const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
190
- const [state, setState] = useState<{ held: boolean; releasedBy: HoldRelease | null }>({ held: false, releasedBy: null });
191
-
192
- const clearTimer = (): void => {
193
- if (timer.current !== null) {
194
- clearTimeout(timer.current);
195
- timer.current = null;
196
- }
197
- };
198
-
199
- const release = (reason: HoldRelease): void => {
200
- clearTimer();
201
- typedAt.current = null;
202
- composing.current = false;
203
- setState((current) => (current.held ? { held: false, releasedBy: reason } : current));
204
- };
205
-
206
- const schedule = (): void => {
207
- clearTimer();
208
- const wait = holdLiftsIn({ typedAt: typedAt.current, composing: composing.current }, Date.now());
209
- if (wait !== null) {
210
- timer.current = setTimeout(() => release('pause'), wait);
211
- }
212
- };
213
-
214
- const noteInput = (): void => {
215
- typedAt.current = Date.now();
216
- setState({ held: true, releasedBy: null });
217
- schedule();
218
- };
219
-
220
- const setComposing = (next: boolean): void => {
221
- composing.current = next;
222
- if (next) {
223
- setState({ held: true, releasedBy: null });
224
- } else {
225
- typedAt.current = Date.now();
226
- }
227
- schedule();
228
- };
229
-
230
- useEffect(() => clearTimer, []);
231
-
232
- return { held: state.held, releasedBy: state.releasedBy, release, noteInput, setComposing };
233
- }
234
-
235
75
  export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabled, readOnly = false, providerFixed, onSend, onRetarget }: ComposerProps) {
236
76
  const { t } = useTranslation('agent-chat');
237
- const sendDelivery = chatHost().useSendDelivery();
238
- const canSteer = info.provider === 'claude' || info.provider === 'codex';
239
- const [draft, setDraft] = useState<ChatDraft>(() => readDraft(chatId));
240
- const [historyIndex, setHistoryIndex] = useState<number | null>(null);
241
- const [menuIndex, setMenuIndex] = useState(0);
242
- const [mention, setMention] = useState<MentionQuery | null>(null);
243
- const [skillQuery, setSkillQuery] = useState<MentionQuery | null>(null);
244
- const [skills, setSkills] = useState<ChatSkill[]>([]);
245
- const [searched, setSearched] = useState<MentionSearch>(NO_MENTION_SEARCH);
246
- const configuring = useRef(false);
247
- const [configurationPending, setConfigurationPending] = useState(false);
248
- const [notice, setNotice] = useState<string | null>(null);
249
- const [dragging, setDragging] = useState(false);
250
- const [modelPickerOpen, setModelPickerOpen] = useState(false);
251
- const [confirmClear, setConfirmClear] = useState(false);
252
- const [takingBack, setTakingBack] = useState<string | null>(null);
77
+ const host = useChatHost();
253
78
  const scope = useChatScope();
254
- const actions = useChatActions();
255
- const { isApplePlatform, notify } = chatHost();
256
- const registerQuoteTaker = useContext(QuoteTakerContext);
257
- const inputRef = useRef<ComposerInputHandle>(null);
258
- const hold = usePromptHold();
259
- // Taking a queued message back waits on the machine, and what was typed meanwhile is what it merges with.
260
- const draftRef = useRef(draft);
261
- const fileInputRef = useRef<HTMLInputElement>(null);
262
- const [dictationToolbar, setDictationToolbar] = useState<HTMLDivElement | null>(null);
263
- // A paste event says nothing about the keys behind it, so the key that asked for text inline is remembered here.
264
- const pasteInlineRef = useRef(false);
265
- const providers = useProviders((s) => s.providers);
266
- const { approvals, questions } = usePendingRequests(chatId);
267
- const order = useChatRow(chatId, (row) => row?.order);
268
- const items = useChatRow(chatId, (row) => row?.structure);
269
- const chats = chatHost().useReferableChats();
270
- const placeholderLead = chatHost().useComposerPlaceholder(scope.id, chatId);
271
-
272
- const pending = useMemo(() => [...approvals, ...questions], [approvals, questions]);
273
- const writable = !disabled && !readOnly;
274
- const provider = providers.find((entry) => entry.kind === info.provider);
79
+ const sendDelivery = host.useSendDelivery();
80
+ const sendKey = useChatPreferences((preferences) => preferences.sendKey);
81
+ const provider = useProviders((row) => row.providers.find((entry) => entry.kind === info.provider));
275
82
  // Absent until the host answered `provider.list`, so only an explicit false hides anything.
276
83
  const capabilities = provider?.capabilities;
277
- const compaction = capabilities?.compaction;
278
- const dismissedTurnId = useResumeCompactionDismissal(chatId);
279
- // The cheap half of the offer, so a chat that can never make one keeps no timer.
280
- const couldOfferCompaction = compaction !== undefined && compaction !== 'none' && info.usage.contextTokens >= RESUME_COMPACTION_TOKENS;
281
- const now = useNow(60_000, couldOfferCompaction);
282
- const compactionOffer = !couldOfferCompaction
283
- ? null
284
- : resumeCompactionOffer({
285
- info,
286
- compaction,
287
- items: (order ?? []).map((id) => items?.[id]).filter((item): item is ChatItem => item !== undefined),
288
- dismissedTurnId,
289
- now
290
- });
291
- const models: ModelInfo[] = provider?.models ?? [];
292
- const model = models.find((entry) => entry.slug === info.selection.model);
84
+ const steerable = canSteer(capabilities);
85
+ const writable = !disabled && !readOnly;
293
86
  const busy = info.activeTurnId !== null;
294
- const queue = info.queue ?? [];
295
- const text = draft.text;
296
- const quote = draft.quote;
297
- const quoteSource = draft.quoteSource;
298
- // The CLI announces its session on the first message, so anything before that is still a blank chat.
299
- const started = info.agentSessionId !== null || info.usage.turns > 0;
300
- const accountChoice = useAccountChoice(info.provider, info.account);
301
- // A chat that has started keeps its provider (the CLI holds the thread), and so does one opened for a
302
- // named CLI; its models stay switchable, since the CLI takes a new one on the restart the next send does.
303
- const pickable =
304
- started || providerFixed
305
- ? providers.filter((entry) => entry.kind === info.provider)
306
- : providers.filter((entry) => entry.installed && entry.capabilities.chat);
307
-
308
- useEffect(() => {
309
- if (focused) {
87
+ const inputRef = useRef<ComposerInputHandle>(null);
88
+ const [dictationToolbar, setDictationToolbar] = useState<HTMLDivElement | null>(null);
89
+ const hold = usePromptHold();
90
+ const box = useComposerDraft(chatId, inputRef);
91
+ const { draft } = box;
92
+ const notice = useNotice();
93
+ const history = usePromptHistory(chatId, box.setText);
94
+ const menus = useComposerMenus({ chatId, info, capabilities, providerName: provider?.name, draft: box, inputRef });
95
+ const files = useDraftFiles({ capabilities, providerName: provider?.name, writable, draft: box, inputRef, setNotice: notice.setNotice });
96
+ const controls = useChatControls({ chatId, info, notice, onRetarget });
97
+ const guard = promptGuard(withQuote(draft.quote, draft.text));
98
+ const { submit, restoreStashed, toggleStash } = useComposerSubmit({
99
+ chatId,
100
+ writable,
101
+ guard,
102
+ steerable,
103
+ sendDelivery,
104
+ draft: box,
105
+ menus,
106
+ controls,
107
+ history,
108
+ hold,
109
+ inputRef,
110
+ setNotice: notice.setNotice,
111
+ onSend
112
+ });
113
+ const { takingBack, takeBack } = useTakeBack({
114
+ chatId,
115
+ draft: box,
116
+ setNotice: notice.setNotice,
117
+ onTaken: () => {
118
+ history.forget();
310
119
  inputRef.current?.focus();
311
120
  }
312
- }, [focused]);
313
-
314
- useEffect(() => {
315
- draftRef.current = draft;
316
- writeDraft(chatId, draft);
317
- }, [chatId, draft]);
318
-
319
- useEffect(
320
- () =>
321
- takeDraftOffers(chatId, (offered) => {
322
- setDraft((current) => ({ ...current, text: joinDraftText(current.text, offered) }));
323
- inputRef.current?.focus();
324
- }),
325
- [chatId]
326
- );
327
-
328
- useEffect(
329
- () =>
330
- registerQuoteTaker?.((selected, itemId) => {
331
- setDraft((current) => ({ ...current, quote: selected, quoteSource: itemId }));
332
- inputRef.current?.focus();
333
- }),
334
- [registerQuoteTaker]
335
- );
336
-
337
- useEffect(() => {
338
- if (!notice) {
339
- return;
340
- }
341
- const timer = setTimeout(() => setNotice(null), NOTICE_MS);
342
- return () => clearTimeout(timer);
343
- }, [notice]);
344
-
345
- // Every keystroke inside an `@word` searches again; a slow answer must not overwrite a newer one.
346
- useEffect(() => {
347
- if (mention === null) {
348
- return;
349
- }
350
- let stale = false;
351
- const search = (): void => {
352
- const searchFiles = chatHost().searchFiles;
353
- if (searchFiles === null) {
354
- return;
355
- }
356
- searchFiles(scope.id, info.cwd, mention.query, MENTION_RESULTS)
357
- .then((files) => {
358
- if (!stale) {
359
- setSearched({ query: mention.query, files });
360
- setMenuIndex(0);
361
- }
362
- })
363
- .catch(() => undefined);
364
- };
365
- // A bare `@` is not a keystroke in a query. It opens the list of what is there, at once.
366
- if (mention.query === '') {
367
- search();
368
- return () => {
369
- stale = true;
370
- };
371
- }
372
- const timer = setTimeout(search, SEARCH_DEBOUNCE_MS);
373
- return () => {
374
- stale = true;
375
- clearTimeout(timer);
376
- };
377
- }, [scope, info.cwd, mention]);
378
-
379
- /* The host's skill list, once per chat and again once the CLI announced its own after the first message. */
380
- useEffect(() => {
381
- let stale = false;
382
- scope.chats
383
- .listSkills(chatId)
384
- .then((found) => {
385
- if (!stale) {
386
- setSkills(found);
387
- }
388
- })
389
- .catch(() => undefined);
390
- return () => {
391
- stale = true;
392
- };
393
- }, [scope, chatId, info.skills]);
394
-
395
- const history = useMemo(() => {
396
- const prompts: Array<{ text: string; mentions: string[] }> = [];
397
- for (const id of order ?? []) {
398
- const item = items?.[id];
399
- if (item?.kind === 'user') {
400
- prompts.push({ text: item.text, mentions: item.mentions ?? [] });
401
- }
402
- }
403
- // Walking back further than this is faster with the timeline than with the arrow key.
404
- return prompts.slice(-PROMPT_HISTORY);
405
- }, [order, items]);
406
-
407
- const commandQuery = text.startsWith('/') && !text.includes('\n') ? text.slice(1).trim().toLowerCase() : null;
408
- const commands = useMemo(() => {
409
- if (commandQuery === null) {
410
- return [];
411
- }
412
- const known = new Set(skills.map((skill) => skill.name));
413
- const own = LOCAL_COMMANDS.map((name) => ({ name, hint: t(`composer.commands.${name}`), local: true, skill: false }));
414
- const cli = usableSlashCommands(info.slashCommands)
415
- .filter((name) => !LOCAL_COMMANDS.includes(name))
416
- .map((name) => ({
417
- name,
418
- hint: known.has(name) ? t('composer.commands.skill') : t('composer.commands.cli', { cli: provider?.name ?? t('composer.commands.anyCli') }),
419
- local: false,
420
- skill: known.has(name)
421
- }));
422
- return [...own, ...cli].filter((command) => command.name.startsWith(commandQuery)).slice(0, 8);
423
- }, [commandQuery, info.slashCommands, provider?.name, skills, t]);
424
-
425
- const skillMatches = useMemo(() => {
426
- if (skillQuery === null) {
427
- return [];
428
- }
429
- const query = skillQuery.query.toLowerCase();
430
- return skills.filter((skill) => skill.name.toLowerCase().includes(query)).slice(0, 8);
431
- }, [skillQuery, skills]);
432
-
433
- const commandMenuOpen = commandQuery !== null && commands.length > 0;
434
- const skillMenuOpen = !commandMenuOpen && skillQuery !== null && skillMatches.length > 0;
435
- const mentionMenuOpen = !commandMenuOpen && !skillMenuOpen && mention !== null;
436
- // Results belong to the query that asked for them; a closed picker shows none while the next answer is on its way.
437
- const files = mention === null ? [] : searched.files;
438
- const chatMatches = useMemo(
439
- () => (mention === null ? [] : chatSuggestions(chats, mention.query, [chatId, ...draft.chats], CHAT_RESULTS)),
440
- [chatId, chats, draft.chats, mention]
441
- );
442
- const mentionCount = chatMatches.length + files.length;
443
- // One menu is open at a time, so they share an id.
444
- const listId = useId();
445
- const mentionsLabelId = `${listId}-chats`;
446
- const filesLabelId = `${listId}-files`;
447
- const filesLabelShown = mention !== null && (mention.query === '' || chatMatches.length > 0);
448
- const menuOpen = commandMenuOpen || skillMenuOpen || mentionMenuOpen;
449
- const menuCount = commandMenuOpen ? commands.length : skillMenuOpen ? skillMatches.length : mentionCount;
450
- // The list can shrink under a stale index; every reader takes the one that still points at an option.
451
- const activeIndex = clampMenuIndex(menuIndex, menuCount);
452
- const inputAria = useMemo(
453
- () =>
454
- composerMenuAria({
455
- label: t('composer.inputLabel'),
456
- listId,
457
- open: menuOpen,
458
- activeIndex,
459
- count: menuCount
460
- }),
461
- [t, listId, menuOpen, activeIndex, menuCount]
462
- );
121
+ });
122
+ const pending = usePendingRequests(chatId);
123
+ const compactionOffer = useCompactionOffer(chatId, info, capabilities?.compaction);
124
+ const placeholder = useHintedPlaceholder({ chatId, disabled, readOnly, mentionable: capabilities?.mentions !== false, hasSkills: menus.hasSkills });
463
125
  const editorExtensions = useMemo(
464
126
  () => [chipDecorations({ mentions: draft.mentions, skills: draft.skills }), LEAVE_DROPS_TO_THE_CARD],
465
127
  [draft.mentions, draft.skills]
466
128
  );
467
- const guard = promptGuard(withQuote(quote, text));
468
-
469
- const setText = (next: string, mentions = draft.mentions, chosen = draft.skills): void => {
470
- setDraft((current) => ({ ...current, text: next, mentions, skills: chosen }));
471
- };
472
-
473
- const clearDraft = (): void => {
474
- setDraft(EMPTY_DRAFT);
475
- };
476
-
477
- const removeQuote = (): void => {
478
- setDraft((current) => ({ ...current, quote: '', quoteSource: null }));
479
- inputRef.current?.focus();
480
- };
481
-
482
- const showError = (error: unknown): void => {
483
- setNotice(error instanceof Error ? error.message : String(error));
484
- };
485
-
486
- /* One configuration at a time; a second pick while one is on its way is dropped. */
487
- const runConfiguration = (work: () => Promise<void>): void => {
488
- if (configuring.current) {
489
- return;
490
- }
491
- configuring.current = true;
492
- setConfigurationPending(true);
493
- void work()
494
- .catch(showError)
495
- .finally(() => {
496
- configuring.current = false;
497
- setConfigurationPending(false);
498
- });
499
- };
500
-
501
- const configure = (patch: { selection?: ModelSelection; runtimeMode?: RuntimeMode; account?: string }): void => {
502
- runConfiguration(() =>
503
- actions.configure(chatId, patch).then(() => {
504
- if (patch.selection) {
505
- rememberChatSelection(info.provider, patch.selection);
506
- }
507
- if (patch.runtimeMode) {
508
- rememberChatPreferences({ runtimeMode: patch.runtimeMode });
509
- }
510
- if (patch.account) {
511
- rememberChatAccount(scope.id, info.provider, patch.account);
512
- }
513
- })
514
- );
515
- };
516
-
517
- const chooseAccount = (account: string): void => configure({ account });
518
-
519
- /* A model of another CLI re-points the whole chat; one of this CLI's own is a configure. */
520
- const chooseModel = (provider: AgentKind, slug: string): void => {
521
- const selection: ModelSelection = { model: slug, options: {} };
522
- if (provider !== info.provider) {
523
- runConfiguration(() => onRetarget(provider, selection).then(() => rememberChatSelection(provider, selection)));
524
- return;
525
- }
526
- configure({ selection });
527
- };
528
-
529
- const stop = (shiftKey: boolean): void => {
530
- if (composerStopOf(shiftKey) === 'turn') {
531
- void actions.stopTurn(chatId, false).catch(showError);
532
- return;
533
- }
534
- chatHost().confirm.stopSubagents(scope.id, chatId, () => void actions.stopTurn(chatId, true).catch(showError));
535
- };
536
-
537
- /* The host decides whether a turn is in the way; only its refusal asks the person first. */
538
- const clearThread = async (): Promise<void> => {
539
- try {
540
- await actions.clear(chatId, false);
541
- } catch (e) {
542
- if (errorCode(e) === 'chat-busy') {
543
- setConfirmClear(true);
544
- return;
545
- }
546
- setNotice(t('composer.notice.clearFailed'));
547
- }
548
- };
549
-
550
- const forceClearThread = async (): Promise<void> => {
551
- await actions.clear(chatId, true);
552
- setConfirmClear(false);
553
- };
554
-
555
- const compact = (): void => {
556
- void actions.compact(chatId).catch(() => setNotice(t('composer.notice.compactFailed')));
557
- };
558
-
559
- const runCommand = (name: string): boolean => {
560
- switch (name) {
561
- case 'model':
562
- setModelPickerOpen(true);
563
- return true;
564
- case 'compact':
565
- void actions.compact(chatId).catch(() => undefined);
566
- return true;
567
- case 'clear':
568
- void clearThread();
569
- return true;
570
- default:
571
- return false;
572
- }
573
- };
574
-
575
- /* Which picker the caret opens: `@` for a file, `$` for a skill, or neither. In code a sigil is just text. */
576
- const trackTriggers = (value: string, selection: TextRange, state: EditorState): void => {
577
- const empty = selection.from === selection.to;
578
- const tree = empty ? (ensureSyntaxTree(state, selection.from, 50) ?? syntaxTree(state)) : null;
579
- const caret = tree !== null && !inCode(tree, value, selection.from) ? selection.from : null;
580
- const skill = caret === null ? null : findSkillQuery(value, caret);
581
- setSkillQuery((current) => (sameQuery(current, skill) ? current : skill));
582
- // A CLI that does not expand `@path` gets the text as it is, so the picker stays out of the way.
583
- const next = caret === null || capabilities?.mentions === false ? null : findMentionQuery(value, caret);
584
- setMention((current) => (sameQuery(current, next) ? current : next));
585
- };
586
-
587
- // The inserted text reaches the editor with the render it causes, so the caret waits a frame for it.
588
- const moveCaret = (caret: number): void => {
589
- requestAnimationFrame(() => inputRef.current?.setCaret(caret));
590
- };
591
-
592
- const chooseMention = (path: string): void => {
593
- if (!mention) {
594
- return;
595
- }
596
- const result = insertMention(text, mention, path);
597
- setText(result.text, draft.mentions.includes(path) ? draft.mentions : [...draft.mentions, path]);
598
- setMention(null);
599
- moveCaret(result.caret);
600
- };
601
-
602
- /* A picked chat sits beside the text as a chip, so the `@query` that found it goes. */
603
- const chooseChat = (id: string): void => {
604
- if (!mention) {
605
- return;
606
- }
607
- const result = dropQuery(text, mention);
608
- setDraft((current) => ({ ...current, text: result.text, chats: current.chats.includes(id) ? current.chats : [...current.chats, id] }));
609
- setMention(null);
610
- moveCaret(result.caret);
611
- };
612
-
613
- const chooseMentionAt = (index: number): void => {
614
- const pick = mention === null ? null : mentionPick(index, chatMatches, searched, mention.query);
615
- if (pick?.kind === 'chat') {
616
- chooseChat(pick.chat.id);
617
- } else if (pick?.kind === 'file') {
618
- chooseMention(pick.path);
619
- }
620
- };
621
-
622
- const removeChat = (id: string): void => {
623
- setDraft((current) => ({ ...current, chats: current.chats.filter((entry) => entry !== id) }));
624
- inputRef.current?.focus();
625
- };
626
-
627
- /*
628
- * A picked skill becomes a `$name` chip in the text and a name on the send, which is what
629
- * dispatches it. The `/` menu lands here too, so both spellings end up on the same path.
630
- */
631
- const chooseSkill = (name: string): void => {
632
- const result = skillQuery ? insertSkill(text, skillQuery, name) : { text: `$${name} `, caret: name.length + 2 };
633
- setText(result.text, draft.mentions, draft.skills.includes(name) ? draft.skills : [...draft.skills, name]);
634
- setSkillQuery(null);
635
- moveCaret(result.caret);
636
- };
637
-
638
- /* Paths dragged in from a list of files. With no mention query to insert into, they land at the end of the prompt. */
639
- const addMentions = (paths: string[]): void => {
640
- const fresh = paths.filter((path) => path !== '');
641
- if (fresh.length === 0 || capabilities?.mentions === false) {
642
- return;
643
- }
644
- const appended = fresh.map((path) => `@${path}`).join(' ');
645
- const before = text.replace(/\s+$/, '');
646
- setText(before === '' ? `${appended} ` : `${before} ${appended} `, [...draft.mentions, ...fresh.filter((path) => !draft.mentions.includes(path))]);
647
- };
648
-
649
- const addFiles = (incoming: File[]): void => {
650
- if (incoming.length === 0) {
651
- return;
652
- }
653
- if (capabilities?.attachments === false) {
654
- setNotice(t('composer.notice.noAttachments', { agent: provider?.name ?? t('composer.notice.thisAgent') }));
655
- return;
656
- }
657
- const checked = checkAttachmentLimits(
658
- draft.attachments.length,
659
- incoming.map((file) => ({ name: file.name, mime: file.type, bytes: file.size, file })),
660
- draft.attachments.reduce((bytes, attachment) => bytes + uploadBytes(attachment), 0)
661
- );
662
- if (checked.rejected[0]) {
663
- setNotice(t('composer.notice.rejected', { name: checked.rejected[0].name || t('composer.notice.thatFile'), reason: checked.rejected[0].reason }));
664
- }
665
- if (checked.accepted.length === 0) {
666
- return;
667
- }
668
- readAttachments(checked.accepted.map((entry) => entry.file))
669
- .then((attachments) => setDraft((current) => ({ ...current, attachments: [...current.attachments, ...attachments] })))
670
- .catch(() => setNotice(t('composer.notice.unreadable')));
671
- };
672
-
673
- const insertIntoDraft = (added: string): void => {
674
- if (writable) {
675
- inputRef.current?.insert(added);
676
- }
677
- };
678
-
679
- const removeAttachment = (index: number): void => {
680
- setDraft((current) => ({ ...current, attachments: current.attachments.filter((_, i) => i !== index) }));
681
- };
682
-
683
- const submit = (alternate = false): void => {
684
- const delivery = canSteer && (alternate ? sendDelivery === 'queue' : sendDelivery === 'steer') ? 'steer' : 'queue';
685
- const trimmed = text.trim();
686
- if (isEmptyDraft(draft) || !writable || guard.tooLong) {
687
- return;
688
- }
689
- // Recheck restored drafts and files that finished reading concurrently.
690
- const rejected = checkAttachmentLimits(
691
- 0,
692
- draft.attachments.map((attachment) => ({ ...attachment, bytes: uploadBytes(attachment) }))
693
- ).rejected[0];
694
- if (rejected) {
695
- setNotice(`${rejected.name}: ${rejected.reason}`);
696
- return;
697
- }
698
- hold.release('send');
699
- const chosen = commands[activeIndex];
700
- if (commandQuery !== null && chosen?.local && runCommand(chosen.name)) {
701
- clearDraft();
702
- return;
703
- }
704
- if (commandQuery !== null && chosen?.skill) {
705
- chooseSkill(chosen.name);
706
- return;
707
- }
708
- // Whoever sends wants to see what comes back, wherever they were reading.
709
- scrollTimelineToEnd(chatId, 'instant');
710
- if (commandQuery !== null && chosen && !chosen.local) {
711
- onSend(`/${chosen.name}`, {});
712
- clearDraft();
713
- return;
714
- }
715
- onSend(withQuote(quote, trimmed), {
716
- delivery,
717
- mentions: presentMentions(trimmed, draft.mentions),
718
- skills: presentSkills(trimmed, draft.skills),
719
- chats: draft.chats,
720
- quoteSource: quote !== '' && quoteSource !== null ? { itemId: quoteSource } : undefined,
721
- attachments: draft.attachments
722
- });
723
- clearDraft();
724
- setMention(null);
725
- setSkillQuery(null);
726
- setHistoryIndex(null);
727
- };
728
-
729
- /*
730
- * A stashed prompt comes back as text, mentions and skills; its files were never kept. Its quote
731
- * keeps leading to its reply in the chat it came from, and is plain text in any other.
732
- */
733
- const restoreStashed = (prompt: StashedPrompt): void => {
734
- const stashedQuote = prompt.quote;
735
- const here = stashedQuote !== undefined && stashedQuote.chatId === chatId;
736
- setDraft((current) => ({
737
- ...current,
738
- text: stashedQuote === undefined || here ? prompt.text : withQuote(stashedQuote.text, prompt.text),
739
- mentions: [...prompt.mentions],
740
- skills: [...prompt.skills],
741
- ...(here ? { quote: stashedQuote.text, quoteSource: stashedQuote.itemId } : {})
742
- }));
743
- setHistoryIndex(null);
744
- inputRef.current?.focus();
745
- };
746
-
747
- /*
748
- * The files come back from the machine before the message leaves the queue, since the machine only
749
- * serves a file some message still holds. The machine's refusal is what says it already went out.
750
- */
751
- const takeBack = async (message: ChatQueuedMessage): Promise<void> => {
752
- if (takingBack !== null) {
753
- return;
754
- }
755
- const failed = (e: unknown): void => {
756
- notify({ kind: 'error', title: t('composer.queue.editFailed'), description: e instanceof Error ? e.message : String(e) });
757
- };
758
- const readFiles = async (): Promise<ChatAttachmentUpload[]> => {
759
- const stored = message.attachments ?? [];
760
- if (stored.length === 0) {
761
- return [];
762
- }
763
- const files = chatHost().attachments;
764
- return readStoredAttachments((attachment) => files.read(scope.id, chatId, attachment.id), stored);
765
- };
766
- setTakingBack(message.id);
767
- try {
768
- let uploads: ChatAttachmentUpload[];
769
- try {
770
- uploads = await readFiles();
771
- } catch (e) {
772
- failed(e);
773
- return;
774
- }
775
- const taken: Omit<ChatDraft, 'quote' | 'quoteSource'> = {
776
- text: message.text,
777
- mentions: message.mentions ?? [],
778
- skills: message.skills ?? [],
779
- chats: message.chats ?? [],
780
- attachments: uploads
781
- };
782
- // A file that would not fit is refused here, while the message still holds it on the machine.
783
- const wouldReject = takeBackIntoDraft(draftRef.current, taken).rejected[0];
784
- if (wouldReject) {
785
- setNotice(t('composer.notice.queuedDoesNotFit', { name: wouldReject.name || t('composer.notice.thatFile'), reason: wouldReject.reason }));
786
- return;
787
- }
788
- try {
789
- await actions.unqueue(chatId, message.id);
790
- } catch (e) {
791
- if (errorCode(e) === 'request-not-found') {
792
- notify({ kind: 'error', title: t('composer.queue.alreadySent') });
793
- } else {
794
- failed(e);
795
- }
796
- return;
797
- }
798
- const merged = takeBackIntoDraft(draftRef.current, taken);
799
- setDraft(merged.draft);
800
- if (merged.rejected[0]) {
801
- setNotice(t('composer.notice.rejected', { name: merged.rejected[0].name, reason: merged.rejected[0].reason }));
802
- }
803
- setHistoryIndex(null);
804
- inputRef.current?.focus();
805
- } finally {
806
- setTakingBack(null);
807
- }
808
- };
809
-
810
- /* Cmd+S puts the draft away and clears the box; on an empty box the same key takes the last one back. */
811
- const toggleStash = (): void => {
812
- if (isEmptyDraft(draft)) {
813
- const newest = useStash.getState().prompts[0];
814
- if (newest) {
815
- restoreStashed(newest);
816
- }
817
- return;
818
- }
819
- if (stashDraft(draft, chatId)) {
820
- clearDraft();
821
- setMention(null);
822
- setSkillQuery(null);
823
- }
824
- };
825
-
826
- const recall = (direction: -1 | 1): boolean => {
827
- if (history.length === 0) {
828
- return false;
829
- }
830
- const next = historyIndex === null ? (direction === -1 ? history.length - 1 : null) : historyIndex + direction;
831
- if (next === null || next >= history.length) {
832
- setHistoryIndex(null);
833
- setText('', [], []);
834
- return true;
835
- }
836
- if (next < 0) {
837
- return true;
838
- }
839
- setHistoryIndex(next);
840
- setText(history[next]!.text, history[next]!.mentions);
841
- return true;
842
- };
843
-
844
- /* The arrows walk an open picker round; true when the key was one of them. */
845
- const moveInMenu = (key: string, count: number): boolean => {
846
- if (key === 'ArrowDown') {
847
- setMenuIndex((i) => (i + 1) % count);
848
- return true;
849
- }
850
- if (key === 'ArrowUp') {
851
- setMenuIndex((i) => (i - 1 + count) % count);
852
- return true;
853
- }
854
- return false;
855
- };
856
-
857
- /* Runs before the editor's own keymap; true means the key is handled and its default prevented. */
858
- const onKeyDown = (e: KeyboardEvent, view: EditorView): boolean => {
859
- // Shift with the paste shortcut pastes a large text inline after all; any other key forgets it.
860
- pasteInlineRef.current = e.shiftKey && e.key.toLowerCase() === 'v' && (isApplePlatform() ? e.metaKey : e.ctrlKey);
861
- // Escape leaves the node, unless it first has a recalled prompt to put back.
862
- if (e.key === 'Escape' && !e.isComposing) {
863
- if (historyIndex === null) {
864
- return false;
865
- }
866
- e.stopPropagation();
867
- setHistoryIndex(null);
868
- setText('', [], []);
869
- return true;
870
- }
871
- // The app's own shortcuts stay the app's while you type: Mod+Shift+Enter would otherwise send as well.
872
- if (chatHost().isAppShortcut(e)) {
873
- return false;
874
- }
875
- e.stopPropagation();
876
- // A key inside an IME composition belongs to the composition, Enter and the arrows included.
877
- if (e.isComposing) {
878
- return false;
879
- }
880
- // Reading back through a long answer should not mean leaving the box you are typing in.
881
- if (e.key === 'PageUp' || e.key === 'PageDown') {
882
- return pageTimeline(chatId, e.key === 'PageUp' ? -1 : 1);
883
- }
884
- if (matchesShortcut(STASH_SHORTCUT, e, isApplePlatform())) {
885
- toggleStash();
886
- return true;
887
- }
888
- if (commandMenuOpen) {
889
- if (moveInMenu(e.key, commands.length)) {
890
- return true;
891
- }
892
- if (e.key === 'Tab') {
893
- const chosen = commands[activeIndex]!;
894
- if (chosen.skill) {
895
- chooseSkill(chosen.name);
896
- } else {
897
- setText(`/${chosen.name} `);
898
- }
899
- return true;
900
- }
901
- }
902
- if (skillMenuOpen) {
903
- if (moveInMenu(e.key, skillMatches.length)) {
904
- return true;
905
- }
906
- if (e.key === 'Tab' || e.key === 'Enter') {
907
- chooseSkill((skillMatches[activeIndex] ?? skillMatches[0]!).name);
908
- return true;
909
- }
910
- }
911
- if (mentionMenuOpen && mentionCount > 0) {
912
- if (moveInMenu(e.key, mentionCount)) {
913
- return true;
914
- }
915
- if (e.key === 'Tab' || e.key === 'Enter') {
916
- chooseMentionAt(activeIndex);
917
- return true;
918
- }
919
- }
920
- // In a fence Tab indents like a code editor; anywhere else it keeps moving the focus.
921
- if (e.key === 'Tab' && !e.altKey && !e.metaKey && !e.ctrlKey) {
922
- const { state } = view;
923
- const { from, to, head } = state.selection.main;
924
- const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state);
925
- if (inFenceBody(tree, state.doc.toString(), head)) {
926
- // Handled even when there is nothing to outdent, so Shift+Tab never throws the focus out of a block.
927
- if (e.shiftKey) {
928
- indentLess(view);
929
- } else if (from !== to) {
930
- indentMore(view);
931
- } else {
932
- view.dispatch(state.replaceSelection(tabSpaces(head - state.doc.lineAt(head).from)), { scrollIntoView: true, userEvent: 'input' });
933
- }
934
- return true;
935
- }
936
- }
937
- if (e.key === 'Enter') {
938
- const { state } = view;
939
- const { from, to, head } = state.selection.main;
940
- // Parsed to the end, since the fence that closes the block can sit after the caret.
941
- const tree = ensureSyntaxTree(state, state.doc.length, 50) ?? syntaxTree(state);
942
- const line = state.doc.lineAt(head);
943
- const list = from === to && !inCode(tree, state.doc.toString(), head) ? listItemAt(line.text) : null;
944
- const action = enterAction(
945
- { shift: e.shiftKey, mod: isModHeld(e, isApplePlatform()) },
946
- { inOpenFence: inOpenFence(tree, head), list, column: head - line.from }
947
- );
948
- if (action === 'newline') {
949
- return insertNewline(view);
950
- }
951
- if (action === 'continue-list' && list) {
952
- view.dispatch({
953
- changes: { from: head, insert: `\n${list.next}` },
954
- selection: { anchor: head + 1 + list.next.length },
955
- scrollIntoView: true,
956
- userEvent: 'input'
957
- });
958
- return true;
959
- }
960
- if (action === 'leave-list') {
961
- view.dispatch({ changes: { from: line.from, to: line.to }, selection: { anchor: line.from }, userEvent: 'delete' });
962
- return true;
963
- }
964
- submit(e.altKey);
965
- return true;
966
- }
967
- const { from, to } = view.state.selection.main;
968
- const recalled = historyIndex !== null && text === history[historyIndex]?.text;
969
- const direction = recallDirection({ key: e.key, text, from, to, recalled });
970
- return direction !== null && recall(direction);
971
- };
972
-
973
- /* Files become attachments and a long text a file of its own; anything else the editor pastes as plain text. */
974
- const onPaste = (e: ClipboardEvent, view: EditorView): boolean => {
975
- const data = e.clipboardData;
976
- if (!data) {
977
- return false;
978
- }
979
- const pasted = filesOf(data);
980
- if (pasted.length > 0) {
981
- addFiles(pasted);
982
- return true;
983
- }
984
- const inline = pasteInlineRef.current;
985
- pasteInlineRef.current = false;
986
- const clip = data.getData('text/plain');
987
- const mentions = capabilities?.mentions === false ? [] : pastedMentions(clip);
988
- if (mentions.length > 0) {
989
- // The editor's own change reads the draft as it was rendered, so the mentions join it after that change.
990
- view.dispatch(view.state.replaceSelection(clip), { scrollIntoView: true, userEvent: 'input.paste' });
991
- setDraft((current) => ({ ...current, mentions: [...current.mentions, ...mentions.filter((path) => !current.mentions.includes(path))] }));
992
- return true;
993
- }
994
- // Without attachments there is nowhere else for the text to go, so it pastes as it always did.
995
- if (inline || capabilities?.attachments === false || !pasteBecomesAttachment(clip)) {
996
- return false;
997
- }
998
- // The paste still replaces what was selected; it just puts nothing in its place.
999
- const { from, to } = view.state.selection.main;
1000
- if (from !== to) {
1001
- view.dispatch({ changes: { from, to }, userEvent: 'delete' });
1002
- }
1003
- const name = pastedTextName(draft.attachments.map((attachment) => attachment.name));
1004
- addFiles([new File([clip], name, { type: 'text/plain' })]);
1005
- return true;
1006
- };
1007
-
1008
129
  /* The timeline owns the scroller; this is the one bit of it the composer needs to know. */
1009
130
  const atEnd = useSyncExternalStore(
1010
131
  subscribeTimelineEnd,
1011
132
  () => timelineAtEnd(chatId),
1012
133
  () => true
1013
134
  );
135
+ const queue = info.queue ?? [];
136
+ const empty = isEmptyDraft(draft);
1014
137
 
1015
- const mentionable = capabilities?.mentions !== false;
1016
- const hasSkills = skills.length > 0;
1017
- const placeholder = useMemo(() => {
1018
- if (disabled) {
1019
- return t('composer.placeholder.disconnected');
1020
- }
1021
- if (readOnly) {
1022
- return t('composer.placeholder.readOnly');
138
+ useEffect(() => {
139
+ if (focused) {
140
+ inputRef.current?.focus();
1023
141
  }
1024
- return hintedPlaceholder(placeholderLead ?? t('composer.placeholder.lead'), t('composer.placeholder.joiner'), [
1025
- { key: '/', label: t('composer.placeholder.commands') },
1026
- ...(mentionable ? [{ key: '@', label: t('composer.placeholder.files') }] : []),
1027
- ...(hasSkills ? [{ key: '$', label: t('composer.placeholder.skills') }] : [])
1028
- ]);
1029
- }, [disabled, readOnly, placeholderLead, mentionable, hasSkills, t]);
1030
- const attachable = capabilities?.attachments !== false;
1031
- const Slot = chatHost().ComposerSlot;
142
+ }, [focused]);
143
+
144
+ const onKeyDown = composerKeyHandler({
145
+ chatId,
146
+ host,
147
+ sendKey,
148
+ text: draft.text,
149
+ menus,
150
+ history,
151
+ noteKey: files.noteKey,
152
+ toggleStash,
153
+ setText: (next) => box.setText(next),
154
+ submit
155
+ });
1032
156
 
1033
157
  return (
1034
158
  <div className="chat-composer-content pointer-events-none relative z-10 w-full">
@@ -1050,32 +174,11 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1050
174
  <div
1051
175
  className={clsx(
1052
176
  '@container/composer pointer-events-auto flex flex-col overflow-hidden rounded-2xl border shadow-float backdrop-blur-[14px] focus-ring-within',
1053
- dragging
177
+ files.dragging
1054
178
  ? 'border-accent bg-[color-mix(in_srgb,var(--accent-soft)_60%,var(--surface-raised))]'
1055
179
  : 'border-border bg-[color-mix(in_srgb,var(--surface-raised)_92%,transparent)]'
1056
180
  )}
1057
- /* The whole card takes a dropped file, so a layout around it that takes drops of its own leaves it alone. */
1058
- data-takes-drop="all"
1059
- onDragOver={(e) => {
1060
- if (writable && (e.dataTransfer.types.includes('Files') || carriesMentions(e.dataTransfer.types))) {
1061
- e.preventDefault();
1062
- e.stopPropagation();
1063
- setDragging(true);
1064
- }
1065
- }}
1066
- onDragLeave={(e) => {
1067
- if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
1068
- setDragging(false);
1069
- }
1070
- }}
1071
- onDrop={(e) => {
1072
- e.preventDefault();
1073
- e.stopPropagation();
1074
- setDragging(false);
1075
- addMentions(droppedMentions(e.dataTransfer));
1076
- addFiles(filesOf(e.dataTransfer));
1077
- inputRef.current?.focus();
1078
- }}
181
+ {...files.dropTarget}
1079
182
  >
1080
183
  <PromptComposer
1081
184
  chatId={chatId}
@@ -1083,285 +186,70 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1083
186
  focused={focused}
1084
187
  onShowElsewhere={answerPromptsElsewhere}
1085
188
  disabled={disabled}
1086
- hasDraft={!isEmptyDraft(draft)}
189
+ hasDraft={!empty}
1087
190
  denyReason={capabilities?.denyReason === true}
1088
191
  onAllAnswered={() => inputRef.current?.focus()}
1089
192
  hold={hold}
1090
193
  >
1091
194
  <LimitDock chatId={chatId} info={info} />
195
+ <GoalDock chatId={chatId} info={info} goals={capabilities?.goals} />
1092
196
  {compactionOffer && (
1093
197
  <ResumeCompactionDock
1094
198
  tokens={compactionOffer.tokens}
1095
- onCompact={compact}
199
+ onCompact={controls.compact}
1096
200
  onDismiss={() => dismissResumeCompaction(scope, chatId, compactionOffer.turnId)}
1097
201
  />
1098
202
  )}
1099
- {commandMenuOpen && (
1100
- <div id={listId} role="listbox" aria-label={t('composer.menu.commands')} className="border-b border-border px-1.5 py-1.5">
1101
- {commands.map((command, index) => (
1102
- <button
1103
- key={command.name}
1104
- id={optionIdOf(listId, index)}
1105
- role="option"
1106
- aria-selected={index === activeIndex}
1107
- tabIndex={-1}
1108
- data-active={index === activeIndex}
1109
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1110
- onMouseEnter={() => setMenuIndex(index)}
1111
- onMouseDown={(e) => e.preventDefault()}
1112
- onClick={() => {
1113
- setMenuIndex(index);
1114
- if (command.skill) {
1115
- chooseSkill(command.name);
1116
- return;
1117
- }
1118
- setText(`/${command.name}`);
1119
- inputRef.current?.focus();
1120
- }}
1121
- >
1122
- <Icon
1123
- icon={command.skill ? Zap : SquareSlash}
1124
- size={12}
1125
- className={clsx('shrink-0', command.skill ? 'text-skill' : 'text-text-faint')}
1126
- />
1127
- <span className="font-mono text-text">/{command.name}</span>
1128
- <span className="text-text-faint">{command.hint}</span>
1129
- </button>
1130
- ))}
1131
- </div>
1132
- )}
1133
- {skillMenuOpen && (
1134
- <div id={listId} role="listbox" aria-label={t('composer.menu.skills')} className="border-b border-border px-1.5 py-1.5">
1135
- {skillMatches.map((skill, index) => (
1136
- <button
1137
- key={skill.name}
1138
- id={optionIdOf(listId, index)}
1139
- role="option"
1140
- aria-selected={index === activeIndex}
1141
- tabIndex={-1}
1142
- data-active={index === activeIndex}
1143
- className="cursor-row flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1144
- onMouseEnter={() => setMenuIndex(index)}
1145
- onMouseDown={(e) => e.preventDefault()}
1146
- onClick={() => chooseSkill(skill.name)}
1147
- >
1148
- <Icon icon={Zap} size={12} className="mt-0.5 shrink-0 text-skill" />
1149
- <span className="flex min-w-0 flex-col">
1150
- <span className="truncate font-mono text-text">${skill.name}</span>
1151
- {skill.description !== '' && <span className="line-clamp-1 text-text-faint">{skill.description}</span>}
1152
- </span>
1153
- </button>
1154
- ))}
1155
- </div>
1156
- )}
1157
- {mentionMenuOpen && (
1158
- <div className="border-b border-border px-1.5 py-1.5">
1159
- {mentionCount > 0 && (
1160
- <div id={listId} role="listbox" aria-label={t('composer.menu.mentions')}>
1161
- {chatMatches.length > 0 && (
1162
- <div role="group" aria-labelledby={mentionsLabelId}>
1163
- <Menu.Label id={mentionsLabelId}>{t('composer.mentions.chats')}</Menu.Label>
1164
- {chatMatches.map((chat, index) => (
1165
- <button
1166
- key={chat.id}
1167
- id={optionIdOf(listId, index)}
1168
- role="option"
1169
- aria-selected={index === activeIndex}
1170
- tabIndex={-1}
1171
- data-active={index === activeIndex}
1172
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1173
- onMouseEnter={() => setMenuIndex(index)}
1174
- onMouseDown={(e) => e.preventDefault()}
1175
- onClick={() => chooseChat(chat.id)}
1176
- >
1177
- <Icon icon={MessageSquare} size={14} className="shrink-0 text-text-faint" />
1178
- <span className="truncate text-text">{chat.title || t('chatReference.untitled')}</span>
1179
- </button>
1180
- ))}
1181
- </div>
1182
- )}
1183
- {files.length > 0 && (
1184
- <div
1185
- role="group"
1186
- {...(filesLabelShown ? { 'aria-labelledby': filesLabelId } : { 'aria-label': t('composer.mentions.files') })}
1187
- >
1188
- {filesLabelShown && (
1189
- <Menu.Label id={filesLabelId}>
1190
- {mention.query === '' ? t('composer.mentions.here') : t('composer.mentions.files')}
1191
- </Menu.Label>
1192
- )}
1193
- {files.map((path, index) => {
1194
- const { name, dir } = splitPath(path);
1195
- return (
1196
- <button
1197
- key={path}
1198
- id={optionIdOf(listId, chatMatches.length + index)}
1199
- role="option"
1200
- aria-selected={chatMatches.length + index === activeIndex}
1201
- tabIndex={-1}
1202
- data-active={chatMatches.length + index === activeIndex}
1203
- className="cursor-row flex w-full items-center gap-2 rounded-md px-2 py-1 text-left text-xs text-text-muted"
1204
- onMouseEnter={() => setMenuIndex(chatMatches.length + index)}
1205
- onMouseDown={(e) => e.preventDefault()}
1206
- onClick={() => chooseMention(path)}
1207
- >
1208
- <FileIcon path={path} size={14} />
1209
- <span className="truncate font-mono text-text">{name}</span>
1210
- {dir && <span className="min-w-0 truncate text-text-faint">{dir}</span>}
1211
- </button>
1212
- );
1213
- })}
1214
- </div>
1215
- )}
1216
- </div>
1217
- )}
1218
- {mentionCount === 0 && (
1219
- <div className="px-2 py-1 text-xs text-text-faint">
1220
- {mention.query ? t('composer.mentions.noMatch') : t('composer.mentions.empty')}
1221
- </div>
1222
- )}
1223
- </div>
1224
- )}
203
+ <ComposerMenu menus={menus} />
1225
204
  {queue.length > 0 && (
1226
- <div className="flex flex-col gap-1 border-b border-border px-2 py-1.5">
1227
- {info.queuePaused === true && <p className="px-1.5 text-xs text-text-muted">{t('composer.queue.paused')}</p>}
1228
- {queue.map((message) => {
1229
- const sendNow = (): void => {
1230
- scrollTimelineToEnd(chatId, 'instant');
1231
- void actions.sendNow(chatId, message.id).catch(() => undefined);
1232
- };
1233
- const unqueue = (): void => void actions.unqueue(chatId, message.id).catch(() => undefined);
1234
- const edit = (): void => void takeBack(message);
1235
- return (
1236
- <ContextMenu.Root key={message.id}>
1237
- <ContextMenu.Trigger className="group/queued flex items-center gap-2 rounded-md px-1.5 py-1 text-xs text-text-muted">
1238
- <Icon icon={Clock} size={12} className="shrink-0 text-text-faint" />
1239
- <span className="min-w-0 grow truncate">
1240
- {message.text || t('composer.queue.attachments', { count: message.attachments?.length ?? 0 })}
1241
- </span>
1242
- <ButtonGroup
1243
- render={<span />}
1244
- className="opacity-0 transition-opacity group-hover/queued:opacity-100 focus-within:opacity-100"
1245
- >
1246
- <IconButton icon={FastForward} size="2xs" label={t('composer.queue.sendNow')} onClick={sendNow} />
1247
- <IconButton
1248
- icon={Pencil}
1249
- size="2xs"
1250
- label={t('common.action.edit')}
1251
- disabled={takingBack !== null}
1252
- onClick={edit}
1253
- />
1254
- <IconButton icon={X} size="2xs" label={t('common.action.remove')} onClick={unqueue} />
1255
- </ButtonGroup>
1256
- </ContextMenu.Trigger>
1257
- <ContextMenu.Popup>
1258
- <ContextMenu.Item onClick={sendNow}>
1259
- <Icon icon={FastForward} size={14} /> {t('composer.queue.sendNow')}
1260
- </ContextMenu.Item>
1261
- <ContextMenu.Item disabled={takingBack !== null} onClick={edit}>
1262
- <Icon icon={Pencil} size={14} /> {t('common.action.edit')}
1263
- </ContextMenu.Item>
1264
- <ContextMenu.Item disabled={message.text === ''} onClick={() => copyText(message.text)}>
1265
- <Icon icon={Copy} size={14} /> {t('common.action.copy')}
1266
- </ContextMenu.Item>
1267
- <ContextMenu.Separator />
1268
- <ContextMenu.Item className="text-status-error" onClick={unqueue}>
1269
- <Icon icon={X} size={14} /> {t('common.action.remove')}
1270
- </ContextMenu.Item>
1271
- </ContextMenu.Popup>
1272
- </ContextMenu.Root>
1273
- );
1274
- })}
1275
- </div>
1276
- )}
1277
- {quote !== '' && (
1278
- <div className="flex items-center gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
1279
- {quoteSource === null ? (
1280
- <span className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-xs text-text-muted">{quote}</span>
1281
- ) : (
1282
- <Tooltip label={t('composer.quote.goToSource')}>
1283
- <button
1284
- type="button"
1285
- className="min-w-0 grow truncate border-l-2 border-border-strong pl-2.5 text-left text-xs text-text-muted hover:text-text"
1286
- onClick={() =>
1287
- jumpToTimelineItem(scope.keyOf(chatId), quoteSource, {
1288
- flash: true,
1289
- onMissing: () => setNotice(t('rows.user.quoteGone'))
1290
- })
1291
- }
1292
- >
1293
- {quote}
1294
- </button>
1295
- </Tooltip>
1296
- )}
1297
- <IconButton icon={X} size="2xs" label={t('composer.quote.remove')} onClick={removeQuote} />
1298
- </div>
1299
- )}
1300
- {draft.chats.length > 0 && (
1301
- <div className="flex flex-wrap gap-1.5 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
1302
- {draft.chats.map((id) => (
1303
- <ChatReferenceChip key={id} chatId={id} onRemove={() => removeChat(id)} />
1304
- ))}
1305
- </div>
205
+ <QueuedMessages
206
+ chatId={chatId}
207
+ queue={queue}
208
+ paused={info.queuePaused === true}
209
+ takingBack={takingBack !== null}
210
+ onEdit={(message) => void takeBack(message)}
211
+ />
1306
212
  )}
1307
- {draft.attachments.length > 0 && (
1308
- <div className="flex flex-wrap gap-2 px-5 pt-4 @max-md/composer:px-3.5 @max-md/composer:pt-3">
1309
- {draft.attachments.map((attachment, index) => (
1310
- <div key={`${attachment.name}-${index}`} className="group/thumb relative">
1311
- {isImageAttachment(attachment.mime) ? (
1312
- <UploadThumb upload={attachment} />
1313
- ) : (
1314
- <span className="flex h-14 w-36 flex-col justify-center gap-0.5 rounded-lg border border-border bg-surface-sunken px-2.5">
1315
- <span className="flex items-center gap-1.5 text-xs text-text">
1316
- <Icon icon={Paperclip} size={12} className="shrink-0 text-text-faint" />
1317
- <span className="truncate">{attachment.name}</span>
1318
- </span>
1319
- <span className="pl-5 text-xs text-text-faint">{formatBytes(uploadBytes(attachment))}</span>
1320
- </span>
1321
- )}
1322
- <IconButton
1323
- icon={X}
1324
- size="2xs"
1325
- label={t('composer.removeAttachment', { name: attachment.name })}
1326
- className="absolute -top-1.5 -right-1.5 border border-border bg-surface-raised opacity-0 transition-opacity group-hover/thumb:opacity-100 focus-visible:opacity-100"
1327
- onClick={() => removeAttachment(index)}
1328
- />
1329
- </div>
1330
- ))}
1331
- </div>
213
+ {draft.quote !== '' && (
214
+ <DraftQuote
215
+ chatId={chatId}
216
+ quote={draft.quote}
217
+ source={draft.quoteSource}
218
+ onRemove={box.removeQuote}
219
+ onSourceGone={() => notice.setNotice(t('rows.user.quoteGone'))}
220
+ />
1332
221
  )}
222
+ {draft.chats.length > 0 && <DraftChats chats={draft.chats} onRemove={box.removeChat} />}
223
+ {draft.attachments.length > 0 && <DraftAttachments attachments={draft.attachments} onRemove={box.removeAttachment} />}
1333
224
  <ComposerInput
1334
225
  ref={inputRef}
1335
226
  dictationToolbar={dictationToolbar}
1336
227
  className="composer-input select-text text-sm leading-normal text-text"
1337
- value={text}
228
+ value={draft.text}
1338
229
  placeholder={placeholder}
1339
230
  disabled={!writable}
1340
231
  tabbable={focused}
1341
232
  extensions={editorExtensions}
1342
- aria={inputAria}
233
+ aria={menus.inputAria}
1343
234
  onChange={(value, selection, state) => {
1344
235
  hold.noteInput();
1345
- setText(value);
1346
- setMenuIndex(0);
1347
- trackTriggers(value, selection, state);
1348
- if (historyIndex !== null && value !== history[historyIndex]?.text) {
1349
- setHistoryIndex(null);
1350
- }
236
+ box.setText(value);
237
+ menus.setMenuIndex(0);
238
+ menus.trackTriggers(value, selection, state);
239
+ history.edited(value);
1351
240
  }}
1352
- onSelectionChange={trackTriggers}
241
+ onSelectionChange={menus.trackTriggers}
1353
242
  onBlur={() => {
1354
- setMention(null);
1355
- setSkillQuery(null);
243
+ menus.close();
1356
244
  hold.release('blur');
1357
245
  }}
1358
246
  onComposing={hold.setComposing}
1359
247
  onKeyDown={onKeyDown}
1360
- onPaste={onPaste}
248
+ onPaste={files.onPaste}
1361
249
  />
1362
- {notice && (
250
+ {notice.notice && (
1363
251
  <div className="px-5 pb-1 text-xs text-status-error @max-md/composer:px-3.5" role="alert">
1364
- {notice}
252
+ {notice.notice}
1365
253
  </div>
1366
254
  )}
1367
255
  {guard.visible && (
@@ -1375,93 +263,43 @@ export function Composer({ chatId, info, focused, answerPromptsElsewhere, disabl
1375
263
  {guard.tooLong && ` ${t('composer.tooLong')}`}
1376
264
  </div>
1377
265
  )}
1378
- {/* The composer's controls are round, from attach to send, so they keep their own shape instead of `.icon-btn`'s. */}
1379
- <div className="flex items-center gap-2 pt-3 pr-3.5 pb-3.5 pl-5 @max-md/composer:gap-1.5 @max-md/composer:p-3 @max-md/composer:pt-2.5">
1380
- {attachable && (
1381
- <Tooltip label={t('composer.attach')} name>
1382
- <button
1383
- type="button"
1384
- className="grid h-8 w-8 shrink-0 place-items-center rounded-full border border-border text-text-muted hover:bg-surface-hover hover:text-text disabled:opacity-50"
1385
- disabled={!writable}
1386
- onClick={() => fileInputRef.current?.click()}
1387
- >
1388
- <Icon icon={Plus} size={16} />
1389
- </button>
1390
- </Tooltip>
1391
- )}
1392
- <input
1393
- ref={fileInputRef}
1394
- type="file"
1395
- multiple
1396
- hidden
1397
- onChange={(e) => {
1398
- addFiles(Array.from(e.target.files ?? []));
1399
- e.target.value = '';
1400
- inputRef.current?.focus();
1401
- }}
1402
- />
1403
- <RunSettings
1404
- disabled={configurationPending || !writable}
1405
- providers={pickable}
1406
- provider={info.provider}
1407
- selection={info.selection}
1408
- model={model}
1409
- runtimeMode={info.runtimeMode}
1410
- effectiveRuntimeMode={info.effectiveRuntimeMode}
1411
- permissionMode={info.permissionMode}
1412
- open={modelPickerOpen}
1413
- onOpenChange={setModelPickerOpen}
1414
- onModel={chooseModel}
1415
- onOption={(id, value) => configure({ selection: { ...info.selection, options: { ...info.selection.options, [id]: value } } })}
1416
- onMode={(runtimeMode) => configure({ runtimeMode })}
1417
- usage={info.usage}
1418
- compactDisabled={busy || !writable}
1419
- onCompact={compact}
1420
- account={accountChoice === null ? null : { choice: accountChoice, started, onPick: chooseAccount }}
1421
- />
1422
- <StashPicker onRestore={restoreStashed} />
1423
- {Slot !== null && (
1424
- <ErrorBoundary label={t('composer.slotFailed')} resetKeys={[chatId]} compact className="relative rounded-lg">
1425
- <Slot scopeId={scope.id} chatId={chatId} disabled={!writable} insert={insertIntoDraft} />
1426
- </ErrorBoundary>
1427
- )}
1428
- <span className="grow" />
1429
- <div className="flex h-9 shrink-0 items-center gap-0.5 rounded-full bg-surface-hover">
1430
- <div ref={setDictationToolbar} className="flex items-center pl-1 empty:hidden [&_.icon-btn]:rounded-full" />
1431
- {busy && (
1432
- <Tooltip label={composerStopLabel()} name>
1433
- <button
1434
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-status-error text-accent-text"
1435
- onClick={(event) => stop(event.shiftKey)}
1436
- >
1437
- <Icon icon={Square} size={16} />
1438
- </button>
1439
- </Tooltip>
1440
- )}
1441
- {(!busy || !isEmptyDraft(draft)) && (
1442
- <Tooltip label={t(sendLabelKey(busy, canSteer, sendDelivery))} kbd={KEY_SHORTCUTS.modEnter} name>
1443
- <button
1444
- className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text hover:brightness-90 disabled:opacity-50 disabled:hover:brightness-100"
1445
- disabled={isEmptyDraft(draft) || !writable || guard.tooLong}
1446
- onClick={(event) => submit(event.altKey)}
1447
- >
1448
- <Icon icon={ArrowUp} size={16} />
1449
- </button>
1450
- </Tooltip>
1451
- )}
1452
- </div>
1453
- </div>
266
+ <ComposerControls
267
+ chatId={chatId}
268
+ info={info}
269
+ providerFixed={providerFixed}
270
+ writable={writable}
271
+ attachable={capabilities?.attachments !== false}
272
+ controls={controls}
273
+ send={{
274
+ visible: !busy || !empty,
275
+ disabled: empty || !writable || guard.tooLong,
276
+ label: t(sendLabelKey(busy, steerable, sendDelivery)),
277
+ kbd: sendShortcut(sendKey, draft.text.includes('\n')),
278
+ onSend: (alternate) => submit(alternate ? 'alternate' : 'default')
279
+ }}
280
+ dictationToolbar={setDictationToolbar}
281
+ onFiles={(picked) => {
282
+ files.addFiles(picked);
283
+ inputRef.current?.focus();
284
+ }}
285
+ onRestore={restoreStashed}
286
+ insert={(added) => {
287
+ if (writable) {
288
+ inputRef.current?.insert(added);
289
+ }
290
+ }}
291
+ />
1454
292
  </PromptComposer>
1455
293
  </div>
1456
294
  <PromptDialog
1457
- open={confirmClear}
295
+ open={controls.confirmClear}
1458
296
  title={t('composer.clear.title')}
1459
297
  description={t('composer.clear.description')}
1460
298
  confirmLabel={t('composer.clear.confirm')}
1461
299
  danger
1462
300
  fallbackMessage={t('composer.notice.clearFailed')}
1463
- onConfirm={forceClearThread}
1464
- onOpenChange={() => setConfirmClear(false)}
301
+ onConfirm={controls.forceClearThread}
302
+ onOpenChange={controls.cancelClear}
1465
303
  />
1466
304
  </div>
1467
305
  );