@schlessera/brain-ui-react 0.4.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 (311) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/components/chat/ask-user-card.d.ts +12 -0
  4. package/dist/components/chat/ask-user-card.d.ts.map +1 -0
  5. package/dist/components/chat/ask-user-card.js +185 -0
  6. package/dist/components/chat/ask-user-card.js.map +1 -0
  7. package/dist/components/chat/brain-markdown.d.ts +35 -0
  8. package/dist/components/chat/brain-markdown.d.ts.map +1 -0
  9. package/dist/components/chat/brain-markdown.js +363 -0
  10. package/dist/components/chat/brain-markdown.js.map +1 -0
  11. package/dist/components/chat/chat-page.d.ts +2 -0
  12. package/dist/components/chat/chat-page.d.ts.map +1 -0
  13. package/dist/components/chat/chat-page.js +442 -0
  14. package/dist/components/chat/chat-page.js.map +1 -0
  15. package/dist/components/chat/command-palette.d.ts +5 -0
  16. package/dist/components/chat/command-palette.d.ts.map +1 -0
  17. package/dist/components/chat/command-palette.js +18 -0
  18. package/dist/components/chat/command-palette.js.map +1 -0
  19. package/dist/components/chat/markdown-content.d.ts +4 -0
  20. package/dist/components/chat/markdown-content.d.ts.map +1 -0
  21. package/dist/components/chat/markdown-content.js +6 -0
  22. package/dist/components/chat/markdown-content.js.map +1 -0
  23. package/dist/components/chat/message-bubble.d.ts +9 -0
  24. package/dist/components/chat/message-bubble.d.ts.map +1 -0
  25. package/dist/components/chat/message-bubble.js +129 -0
  26. package/dist/components/chat/message-bubble.js.map +1 -0
  27. package/dist/components/chat/message-share.d.ts +10 -0
  28. package/dist/components/chat/message-share.d.ts.map +1 -0
  29. package/dist/components/chat/message-share.js +65 -0
  30. package/dist/components/chat/message-share.js.map +1 -0
  31. package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
  32. package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
  33. package/dist/components/chat/renderers/claude-tools.js +38 -0
  34. package/dist/components/chat/renderers/claude-tools.js.map +1 -0
  35. package/dist/components/chat/renderers/generic.d.ts +9 -0
  36. package/dist/components/chat/renderers/generic.d.ts.map +1 -0
  37. package/dist/components/chat/renderers/generic.js +68 -0
  38. package/dist/components/chat/renderers/generic.js.map +1 -0
  39. package/dist/components/chat/renderers/index.d.ts +5 -0
  40. package/dist/components/chat/renderers/index.d.ts.map +1 -0
  41. package/dist/components/chat/renderers/index.js +17 -0
  42. package/dist/components/chat/renderers/index.js.map +1 -0
  43. package/dist/components/chat/risk-hints.d.ts +20 -0
  44. package/dist/components/chat/risk-hints.d.ts.map +1 -0
  45. package/dist/components/chat/risk-hints.js +81 -0
  46. package/dist/components/chat/risk-hints.js.map +1 -0
  47. package/dist/components/chat/session-drawer.d.ts +6 -0
  48. package/dist/components/chat/session-drawer.d.ts.map +1 -0
  49. package/dist/components/chat/session-drawer.js +96 -0
  50. package/dist/components/chat/session-drawer.js.map +1 -0
  51. package/dist/components/chat/share-block.d.ts +9 -0
  52. package/dist/components/chat/share-block.d.ts.map +1 -0
  53. package/dist/components/chat/share-block.js +70 -0
  54. package/dist/components/chat/share-block.js.map +1 -0
  55. package/dist/components/chat/tool-call-timeline.d.ts +8 -0
  56. package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
  57. package/dist/components/chat/tool-call-timeline.js +100 -0
  58. package/dist/components/chat/tool-call-timeline.js.map +1 -0
  59. package/dist/components/chat/tool-views.d.ts +116 -0
  60. package/dist/components/chat/tool-views.d.ts.map +1 -0
  61. package/dist/components/chat/tool-views.js +628 -0
  62. package/dist/components/chat/tool-views.js.map +1 -0
  63. package/dist/components/chat/welcome-state.d.ts +4 -0
  64. package/dist/components/chat/welcome-state.d.ts.map +1 -0
  65. package/dist/components/chat/welcome-state.js +10 -0
  66. package/dist/components/chat/welcome-state.js.map +1 -0
  67. package/dist/components/connectivity/connection-gate.d.ts +5 -0
  68. package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
  69. package/dist/components/connectivity/connection-gate.js +42 -0
  70. package/dist/components/connectivity/connection-gate.js.map +1 -0
  71. package/dist/components/connectivity/login-screen.d.ts +9 -0
  72. package/dist/components/connectivity/login-screen.d.ts.map +1 -0
  73. package/dist/components/connectivity/login-screen.js +99 -0
  74. package/dist/components/connectivity/login-screen.js.map +1 -0
  75. package/dist/components/files/file-panel.d.ts +5 -0
  76. package/dist/components/files/file-panel.d.ts.map +1 -0
  77. package/dist/components/files/file-panel.js +46 -0
  78. package/dist/components/files/file-panel.js.map +1 -0
  79. package/dist/components/files/file-tree.d.ts +2 -0
  80. package/dist/components/files/file-tree.d.ts.map +1 -0
  81. package/dist/components/files/file-tree.js +63 -0
  82. package/dist/components/files/file-tree.js.map +1 -0
  83. package/dist/components/files/file-viewer-binary.d.ts +5 -0
  84. package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
  85. package/dist/components/files/file-viewer-binary.js +20 -0
  86. package/dist/components/files/file-viewer-binary.js.map +1 -0
  87. package/dist/components/files/file-viewer-html.d.ts +4 -0
  88. package/dist/components/files/file-viewer-html.d.ts.map +1 -0
  89. package/dist/components/files/file-viewer-html.js +5 -0
  90. package/dist/components/files/file-viewer-html.js.map +1 -0
  91. package/dist/components/files/file-viewer-markdown.d.ts +4 -0
  92. package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
  93. package/dist/components/files/file-viewer-markdown.js +10 -0
  94. package/dist/components/files/file-viewer-markdown.js.map +1 -0
  95. package/dist/components/files/file-viewer-raw.d.ts +5 -0
  96. package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
  97. package/dist/components/files/file-viewer-raw.js +15 -0
  98. package/dist/components/files/file-viewer-raw.js.map +1 -0
  99. package/dist/components/files/file-viewer.d.ts +2 -0
  100. package/dist/components/files/file-viewer.d.ts.map +1 -0
  101. package/dist/components/files/file-viewer.js +175 -0
  102. package/dist/components/files/file-viewer.js.map +1 -0
  103. package/dist/components/files/frontmatter-panel.d.ts +5 -0
  104. package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
  105. package/dist/components/files/frontmatter-panel.js +12 -0
  106. package/dist/components/files/frontmatter-panel.js.map +1 -0
  107. package/dist/components/layout/app-shell.d.ts +5 -0
  108. package/dist/components/layout/app-shell.d.ts.map +1 -0
  109. package/dist/components/layout/app-shell.js +10 -0
  110. package/dist/components/layout/app-shell.js.map +1 -0
  111. package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
  112. package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
  113. package/dist/components/layout/mobile-tab-bar.js +16 -0
  114. package/dist/components/layout/mobile-tab-bar.js.map +1 -0
  115. package/dist/components/layout/side-rail.d.ts +2 -0
  116. package/dist/components/layout/side-rail.d.ts.map +1 -0
  117. package/dist/components/layout/side-rail.js +26 -0
  118. package/dist/components/layout/side-rail.js.map +1 -0
  119. package/dist/components/layout/slide-panel.d.ts +9 -0
  120. package/dist/components/layout/slide-panel.d.ts.map +1 -0
  121. package/dist/components/layout/slide-panel.js +18 -0
  122. package/dist/components/layout/slide-panel.js.map +1 -0
  123. package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
  124. package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
  125. package/dist/components/quick-actions/streaming-modal.js +92 -0
  126. package/dist/components/quick-actions/streaming-modal.js.map +1 -0
  127. package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
  128. package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
  129. package/dist/components/quick-actions/whatsup-modal.js +82 -0
  130. package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
  131. package/dist/components/settings/passkey-panel.d.ts +10 -0
  132. package/dist/components/settings/passkey-panel.d.ts.map +1 -0
  133. package/dist/components/settings/passkey-panel.js +118 -0
  134. package/dist/components/settings/passkey-panel.js.map +1 -0
  135. package/dist/components/share/share-menu.d.ts +23 -0
  136. package/dist/components/share/share-menu.d.ts.map +1 -0
  137. package/dist/components/share/share-menu.js +58 -0
  138. package/dist/components/share/share-menu.js.map +1 -0
  139. package/dist/components/voice/dictation-sheet.d.ts +6 -0
  140. package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
  141. package/dist/components/voice/dictation-sheet.js +91 -0
  142. package/dist/components/voice/dictation-sheet.js.map +1 -0
  143. package/dist/components/voice/mic-button.d.ts +6 -0
  144. package/dist/components/voice/mic-button.d.ts.map +1 -0
  145. package/dist/components/voice/mic-button.js +9 -0
  146. package/dist/components/voice/mic-button.js.map +1 -0
  147. package/dist/components/voice/review-card.d.ts +8 -0
  148. package/dist/components/voice/review-card.d.ts.map +1 -0
  149. package/dist/components/voice/review-card.js +8 -0
  150. package/dist/components/voice/review-card.js.map +1 -0
  151. package/dist/config.d.ts +19 -0
  152. package/dist/config.d.ts.map +1 -0
  153. package/dist/config.js +10 -0
  154. package/dist/config.js.map +1 -0
  155. package/dist/hooks/use-vpn-status.d.ts +2 -0
  156. package/dist/hooks/use-vpn-status.d.ts.map +1 -0
  157. package/dist/hooks/use-vpn-status.js +71 -0
  158. package/dist/hooks/use-vpn-status.js.map +1 -0
  159. package/dist/hooks/use-websocket.d.ts +8 -0
  160. package/dist/hooks/use-websocket.d.ts.map +1 -0
  161. package/dist/hooks/use-websocket.js +366 -0
  162. package/dist/hooks/use-websocket.js.map +1 -0
  163. package/dist/index.d.ts +25 -0
  164. package/dist/index.d.ts.map +1 -0
  165. package/dist/index.js +31 -0
  166. package/dist/index.js.map +1 -0
  167. package/dist/lib/api-client.d.ts +115 -0
  168. package/dist/lib/api-client.d.ts.map +1 -0
  169. package/dist/lib/api-client.js +70 -0
  170. package/dist/lib/api-client.js.map +1 -0
  171. package/dist/lib/backend.d.ts +7 -0
  172. package/dist/lib/backend.d.ts.map +1 -0
  173. package/dist/lib/backend.js +33 -0
  174. package/dist/lib/backend.js.map +1 -0
  175. package/dist/lib/frontmatter.d.ts +21 -0
  176. package/dist/lib/frontmatter.d.ts.map +1 -0
  177. package/dist/lib/frontmatter.js +88 -0
  178. package/dist/lib/frontmatter.js.map +1 -0
  179. package/dist/lib/image-attachments.d.ts +36 -0
  180. package/dist/lib/image-attachments.d.ts.map +1 -0
  181. package/dist/lib/image-attachments.js +175 -0
  182. package/dist/lib/image-attachments.js.map +1 -0
  183. package/dist/lib/passkeys.d.ts +18 -0
  184. package/dist/lib/passkeys.d.ts.map +1 -0
  185. package/dist/lib/passkeys.js +36 -0
  186. package/dist/lib/passkeys.js.map +1 -0
  187. package/dist/lib/share.d.ts +53 -0
  188. package/dist/lib/share.d.ts.map +1 -0
  189. package/dist/lib/share.js +144 -0
  190. package/dist/lib/share.js.map +1 -0
  191. package/dist/lib/strip-markdown.d.ts +10 -0
  192. package/dist/lib/strip-markdown.d.ts.map +1 -0
  193. package/dist/lib/strip-markdown.js +37 -0
  194. package/dist/lib/strip-markdown.js.map +1 -0
  195. package/dist/lib/tool-names.d.ts +17 -0
  196. package/dist/lib/tool-names.d.ts.map +1 -0
  197. package/dist/lib/tool-names.js +23 -0
  198. package/dist/lib/tool-names.js.map +1 -0
  199. package/dist/lib/utils.d.ts +3 -0
  200. package/dist/lib/utils.d.ts.map +1 -0
  201. package/dist/lib/utils.js +6 -0
  202. package/dist/lib/utils.js.map +1 -0
  203. package/dist/lib/ws-client.d.ts +26 -0
  204. package/dist/lib/ws-client.d.ts.map +1 -0
  205. package/dist/lib/ws-client.js +93 -0
  206. package/dist/lib/ws-client.js.map +1 -0
  207. package/dist/stores/chat-store.d.ts +131 -0
  208. package/dist/stores/chat-store.d.ts.map +1 -0
  209. package/dist/stores/chat-store.js +376 -0
  210. package/dist/stores/chat-store.js.map +1 -0
  211. package/dist/stores/connection-store.d.ts +11 -0
  212. package/dist/stores/connection-store.d.ts.map +1 -0
  213. package/dist/stores/connection-store.js +8 -0
  214. package/dist/stores/connection-store.js.map +1 -0
  215. package/dist/stores/file-store.d.ts +53 -0
  216. package/dist/stores/file-store.d.ts.map +1 -0
  217. package/dist/stores/file-store.js +284 -0
  218. package/dist/stores/file-store.js.map +1 -0
  219. package/dist/stores/provider-store.d.ts +24 -0
  220. package/dist/stores/provider-store.d.ts.map +1 -0
  221. package/dist/stores/provider-store.js +56 -0
  222. package/dist/stores/provider-store.js.map +1 -0
  223. package/dist/stores/ui-store.d.ts +21 -0
  224. package/dist/stores/ui-store.d.ts.map +1 -0
  225. package/dist/stores/ui-store.js +23 -0
  226. package/dist/stores/ui-store.js.map +1 -0
  227. package/dist/styles.css +2 -0
  228. package/dist/voice/asr-clients.d.ts +2 -0
  229. package/dist/voice/asr-clients.d.ts.map +1 -0
  230. package/dist/voice/asr-clients.js +33 -0
  231. package/dist/voice/asr-clients.js.map +1 -0
  232. package/dist/voice/asr-deepgram.d.ts +38 -0
  233. package/dist/voice/asr-deepgram.d.ts.map +1 -0
  234. package/dist/voice/asr-deepgram.js +307 -0
  235. package/dist/voice/asr-deepgram.js.map +1 -0
  236. package/dist/voice/asr-webspeech.d.ts +23 -0
  237. package/dist/voice/asr-webspeech.d.ts.map +1 -0
  238. package/dist/voice/asr-webspeech.js +93 -0
  239. package/dist/voice/asr-webspeech.js.map +1 -0
  240. package/dist/voice/use-dictation.d.ts +6 -0
  241. package/dist/voice/use-dictation.d.ts.map +1 -0
  242. package/dist/voice/use-dictation.js +174 -0
  243. package/dist/voice/use-dictation.js.map +1 -0
  244. package/dist/voice/voice-store.d.ts +26 -0
  245. package/dist/voice/voice-store.d.ts.map +1 -0
  246. package/dist/voice/voice-store.js +27 -0
  247. package/dist/voice/voice-store.js.map +1 -0
  248. package/package.json +71 -0
  249. package/src/components/chat/ask-user-card.tsx +451 -0
  250. package/src/components/chat/brain-markdown.tsx +547 -0
  251. package/src/components/chat/chat-page.tsx +820 -0
  252. package/src/components/chat/command-palette.tsx +68 -0
  253. package/src/components/chat/markdown-content.tsx +5 -0
  254. package/src/components/chat/message-bubble.tsx +362 -0
  255. package/src/components/chat/message-share.ts +78 -0
  256. package/src/components/chat/renderers/claude-tools.tsx +53 -0
  257. package/src/components/chat/renderers/generic.tsx +85 -0
  258. package/src/components/chat/renderers/index.ts +19 -0
  259. package/src/components/chat/risk-hints.ts +95 -0
  260. package/src/components/chat/session-drawer.tsx +213 -0
  261. package/src/components/chat/share-block.tsx +128 -0
  262. package/src/components/chat/tool-call-timeline.tsx +322 -0
  263. package/src/components/chat/tool-views.tsx +898 -0
  264. package/src/components/chat/welcome-state.tsx +105 -0
  265. package/src/components/connectivity/connection-gate.tsx +137 -0
  266. package/src/components/connectivity/login-screen.tsx +169 -0
  267. package/src/components/files/file-panel.tsx +116 -0
  268. package/src/components/files/file-tree.tsx +143 -0
  269. package/src/components/files/file-viewer-binary.tsx +63 -0
  270. package/src/components/files/file-viewer-html.tsx +10 -0
  271. package/src/components/files/file-viewer-markdown.tsx +14 -0
  272. package/src/components/files/file-viewer-raw.tsx +44 -0
  273. package/src/components/files/file-viewer.tsx +290 -0
  274. package/src/components/files/frontmatter-panel.tsx +78 -0
  275. package/src/components/layout/app-shell.tsx +25 -0
  276. package/src/components/layout/mobile-tab-bar.tsx +54 -0
  277. package/src/components/layout/side-rail.tsx +128 -0
  278. package/src/components/layout/slide-panel.tsx +64 -0
  279. package/src/components/quick-actions/streaming-modal.tsx +168 -0
  280. package/src/components/quick-actions/whatsup-modal.tsx +149 -0
  281. package/src/components/settings/passkey-panel.tsx +281 -0
  282. package/src/components/share/share-menu.tsx +121 -0
  283. package/src/components/voice/dictation-sheet.tsx +217 -0
  284. package/src/components/voice/mic-button.tsx +31 -0
  285. package/src/components/voice/review-card.tsx +66 -0
  286. package/src/config.ts +27 -0
  287. package/src/hooks/use-vpn-status.ts +75 -0
  288. package/src/hooks/use-websocket.ts +403 -0
  289. package/src/index.ts +46 -0
  290. package/src/lib/api-client.ts +173 -0
  291. package/src/lib/backend.ts +35 -0
  292. package/src/lib/frontmatter.ts +99 -0
  293. package/src/lib/image-attachments.ts +221 -0
  294. package/src/lib/passkeys.ts +50 -0
  295. package/src/lib/share.ts +167 -0
  296. package/src/lib/strip-markdown.ts +38 -0
  297. package/src/lib/tool-names.ts +27 -0
  298. package/src/lib/utils.ts +6 -0
  299. package/src/lib/ws-client.ts +109 -0
  300. package/src/stores/chat-store.ts +619 -0
  301. package/src/stores/connection-store.ts +23 -0
  302. package/src/stores/file-store.ts +344 -0
  303. package/src/stores/provider-store.ts +85 -0
  304. package/src/stores/ui-store.ts +48 -0
  305. package/src/styles.css +4 -0
  306. package/src/theme.css +425 -0
  307. package/src/voice/asr-clients.ts +45 -0
  308. package/src/voice/asr-deepgram.ts +325 -0
  309. package/src/voice/asr-webspeech.ts +135 -0
  310. package/src/voice/use-dictation.ts +187 -0
  311. package/src/voice/voice-store.ts +55 -0
@@ -0,0 +1,820 @@
1
+ import { useState, useRef, useEffect } from "react";
2
+ import {
3
+ ArrowUp,
4
+ ArrowDown,
5
+ Square,
6
+ CornerLeftUp,
7
+ Paperclip,
8
+ Camera,
9
+ X,
10
+ Check,
11
+ ChevronDown,
12
+ Lock,
13
+ } from "lucide-react";
14
+ import {
15
+ useChatStore,
16
+ activeChat,
17
+ type MessageAttachment,
18
+ } from "../../stores/chat-store.js";
19
+ import type { MessageSource, AskUserAnnotation } from "@schlessera/brain-ui-sdk/protocol";
20
+ import { uiConfig } from "../../config.js";
21
+ import { useConnectionStore } from "../../stores/connection-store.js";
22
+ import { useProviderStore } from "../../stores/provider-store.js";
23
+ import { useUIStore } from "../../stores/ui-store.js";
24
+ import { useWebSocket } from "../../hooks/use-websocket.js";
25
+ import {
26
+ fileToAttachment,
27
+ validateAttachments,
28
+ type PendingAttachment,
29
+ } from "../../lib/image-attachments.js";
30
+ import { MessageBubble } from "./message-bubble.js";
31
+ import { WelcomeState } from "./welcome-state.js";
32
+ import { CommandPalette } from "./command-palette.js";
33
+ import { SessionDrawer } from "./session-drawer.js";
34
+ import { PasskeyPanel } from "../settings/passkey-panel.js";
35
+ import { StreamingPanel } from "../quick-actions/streaming-modal.js";
36
+ import { WhatsupPanel } from "../quick-actions/whatsup-modal.js";
37
+ import { FilePanel } from "../files/file-panel.js";
38
+ import { MicButton } from "../voice/mic-button.js";
39
+ import { DictationSheet } from "../voice/dictation-sheet.js";
40
+ import { ReviewCard } from "../voice/review-card.js";
41
+ import { useDictation } from "../../voice/use-dictation.js";
42
+ import { useVoiceStore } from "../../voice/voice-store.js";
43
+ import { api } from "../../lib/api-client.js";
44
+ import { getBackendUrl } from "../../lib/backend.js";
45
+ import { cn } from "../../lib/utils.js";
46
+
47
+ export function ChatPage() {
48
+ const [input, setInput] = useState("");
49
+ const [lastPrompt, setLastPrompt] = useState("");
50
+ const [showCommandPalette, setShowCommandPalette] = useState(false);
51
+ const scrollRef = useRef<HTMLDivElement>(null);
52
+ const autoScrollRef = useRef(true);
53
+ const [showScrollButton, setShowScrollButton] = useState(false);
54
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
55
+ const { send } = useWebSocket();
56
+
57
+ // Image attachments. `attachmentsRef` mirrors state so async add/merge logic
58
+ // reads the current set synchronously (avoids stale closures / updater races).
59
+ const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
60
+ const attachmentsRef = useRef<PendingAttachment[]>([]);
61
+ const [attachErrors, setAttachErrors] = useState<string[]>([]);
62
+ const libraryInputRef = useRef<HTMLInputElement>(null);
63
+ const cameraInputRef = useRef<HTMLInputElement>(null);
64
+ useEffect(() => {
65
+ attachmentsRef.current = attachments;
66
+ }, [attachments]);
67
+ // Revoke preview URLs of attachments that were never sent (unmount cleanup;
68
+ // sent attachments transfer URL ownership to the message in the chat store).
69
+ useEffect(
70
+ () => () => {
71
+ for (const a of attachmentsRef.current) URL.revokeObjectURL(a.previewUrl);
72
+ },
73
+ []
74
+ );
75
+
76
+ // Provider picker
77
+ const [providerMenuOpen, setProviderMenuOpen] = useState(false);
78
+ const providerMenuRef = useRef<HTMLDivElement>(null);
79
+
80
+ const messages = useChatStore((s) => activeChat(s).messages);
81
+ const isStreaming = useChatStore((s) => activeChat(s).isStreaming);
82
+ const sessionId = useChatStore((s) => s.activeSessionId);
83
+ const clearMessages = useChatStore((s) => s.clearMessages);
84
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
85
+ // Key-bound buffer actions: this page always operates on the buffer in view
86
+ // (the active session, or the draft when no session is bound yet).
87
+ const startAssistantMessage = () =>
88
+ useChatStore.getState().startAssistantMessage(sessionId);
89
+ const addUserMessage = (
90
+ text: string,
91
+ source?: MessageSource,
92
+ atts?: MessageAttachment[]
93
+ ) => useChatStore.getState().addUserMessage(sessionId, text, source, atts);
94
+ const resolveToolApproval = (toolUseId: string, approved: boolean) =>
95
+ useChatStore.getState().resolveToolApproval(sessionId, toolUseId, approved);
96
+ const submitAskUserAnswers = (
97
+ requestId: string,
98
+ answers: Record<string, string>,
99
+ annotations?: Record<string, AskUserAnnotation>
100
+ ) =>
101
+ useChatStore.getState().submitAskUserAnswers(sessionId, requestId, answers, annotations);
102
+ const cancelAskUser = (requestId: string) =>
103
+ useChatStore.getState().cancelAskUser(sessionId, requestId);
104
+ const appendText = (text: string) =>
105
+ useChatStore.getState().appendText(sessionId, text);
106
+ const finishAssistantMessage = () =>
107
+ useChatStore.getState().finishAssistantMessage(sessionId);
108
+ const wsStatus = useConnectionStore((s) => s.wsStatus);
109
+
110
+ const providers = useProviderStore((s) => s.available);
111
+ const selectedProviderId = useProviderStore((s) => s.selectedId);
112
+ const pinnedProviderId = useProviderStore((s) => s.pinnedId);
113
+ const setSelectedProvider = useProviderStore((s) => s.setSelected);
114
+ const loadProviders = useProviderStore((s) => s.loadProviders);
115
+ const backends = useProviderStore((s) => s.backends);
116
+
117
+ const sessionPanelOpen = useUIStore((s) => s.sessionPanelOpen);
118
+ const setSessionPanelOpen = useUIStore((s) => s.setSessionPanelOpen);
119
+ const syncPanelOpen = useUIStore((s) => s.syncPanelOpen);
120
+ const setSyncPanelOpen = useUIStore((s) => s.setSyncPanelOpen);
121
+ const whatsupPanelOpen = useUIStore((s) => s.whatsupPanelOpen);
122
+ const setWhatsupPanelOpen = useUIStore((s) => s.setWhatsupPanelOpen);
123
+ const filePanelOpen = useUIStore((s) => s.filePanelOpen);
124
+ const setFilePanelOpen = useUIStore((s) => s.setFilePanelOpen);
125
+ const securityPanelOpen = useUIStore((s) => s.securityPanelOpen);
126
+ const setSecurityPanelOpen = useUIStore((s) => s.setSecurityPanelOpen);
127
+
128
+ // Voice dictation state
129
+ const voiceMode = useVoiceStore((s) => s.mode);
130
+ const reviewText = useVoiceStore((s) => s.reviewText);
131
+ const clearReview = useVoiceStore((s) => s.clearReview);
132
+ const dictation = useDictation();
133
+
134
+ // Auto-scroll on new content when tailing is active
135
+ useEffect(() => {
136
+ const el = scrollRef.current;
137
+ if (el && autoScrollRef.current) {
138
+ el.scrollTop = el.scrollHeight;
139
+ }
140
+ }, [messages]);
141
+
142
+ // Scroll listener: disable tailing when user scrolls up, re-enable at bottom
143
+ const hasMessages = messages.length > 0;
144
+ useEffect(() => {
145
+ const el = scrollRef.current;
146
+ if (!el) return;
147
+
148
+ const handleScroll = () => {
149
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 20;
150
+ autoScrollRef.current = atBottom;
151
+ setShowScrollButton(!atBottom);
152
+ };
153
+
154
+ el.addEventListener("scroll", handleScroll, { passive: true });
155
+ return () => el.removeEventListener("scroll", handleScroll);
156
+ }, [hasMessages]);
157
+
158
+ // Auto-grow textarea
159
+ useEffect(() => {
160
+ const el = textareaRef.current;
161
+ if (el) {
162
+ el.style.height = "auto";
163
+ el.style.height = Math.min(el.scrollHeight, 200) + "px";
164
+ }
165
+ }, [input]);
166
+
167
+ // Show/hide command palette based on input
168
+ useEffect(() => {
169
+ if (input.startsWith("/")) {
170
+ setShowCommandPalette(true);
171
+ } else {
172
+ setShowCommandPalette(false);
173
+ }
174
+ }, [input]);
175
+
176
+ // Load the available provider combos once on mount.
177
+ useEffect(() => {
178
+ void loadProviders();
179
+ }, [loadProviders]);
180
+
181
+ // Dismiss the provider popover on outside-click / Escape.
182
+ useEffect(() => {
183
+ if (!providerMenuOpen) return;
184
+ const onDocClick = (e: MouseEvent) => {
185
+ if (!providerMenuRef.current?.contains(e.target as Node)) {
186
+ setProviderMenuOpen(false);
187
+ }
188
+ };
189
+ const onKey = (e: KeyboardEvent) => {
190
+ if (e.key === "Escape") setProviderMenuOpen(false);
191
+ };
192
+ document.addEventListener("mousedown", onDocClick);
193
+ document.addEventListener("keydown", onKey);
194
+ return () => {
195
+ document.removeEventListener("mousedown", onDocClick);
196
+ document.removeEventListener("keydown", onKey);
197
+ };
198
+ }, [providerMenuOpen]);
199
+
200
+ /**
201
+ * The draft is whatever is visible above the send button: composer text
202
+ * plus any voice text under review. Both send paths submit the combined
203
+ * draft so nothing is silently dropped.
204
+ */
205
+ function draftText() {
206
+ return [input.trim(), reviewText.trim()].filter(Boolean).join("\n");
207
+ }
208
+
209
+ /**
210
+ * Decode, downscale and validate incoming image files, merging them with any
211
+ * already-pending attachments. Rejected files (bad type / oversize / over the
212
+ * per-message caps) surface as inline error lines; their object URLs are
213
+ * revoked so nothing leaks.
214
+ */
215
+ async function addFiles(files: FileList | File[]) {
216
+ const list = Array.from(files).filter((f) => f.type.startsWith("image/"));
217
+ if (list.length === 0) return;
218
+
219
+ const results = await Promise.all(list.map((f) => fileToAttachment(f)));
220
+ const fresh: PendingAttachment[] = [];
221
+ const errors: string[] = [];
222
+ results.forEach((r, i) => {
223
+ if ("error" in r) {
224
+ errors.push(r.error);
225
+ } else {
226
+ fresh.push({ ...r, name: list[i].name });
227
+ }
228
+ });
229
+
230
+ const { accepted } = validateAttachments([
231
+ ...attachmentsRef.current,
232
+ ...fresh,
233
+ ]);
234
+ // Revoke URLs of freshly-decoded images that didn't make the cut.
235
+ for (const f of fresh) {
236
+ if (!accepted.includes(f)) {
237
+ URL.revokeObjectURL(f.previewUrl);
238
+ errors.push(`${f.name}: not added (message limit reached)`);
239
+ }
240
+ }
241
+ attachmentsRef.current = accepted;
242
+ setAttachments(accepted);
243
+ setAttachErrors(errors);
244
+ }
245
+
246
+ function removeAttachment(index: number) {
247
+ const next = attachmentsRef.current.filter((item, i) => {
248
+ if (i === index) URL.revokeObjectURL(item.previewUrl);
249
+ return i !== index;
250
+ });
251
+ attachmentsRef.current = next;
252
+ setAttachments(next);
253
+ }
254
+
255
+ async function onFilePick(e: React.ChangeEvent<HTMLInputElement>) {
256
+ const files = e.target.files;
257
+ if (files && files.length > 0) await addFiles(files);
258
+ // Reset so picking the same file again still fires onChange.
259
+ e.target.value = "";
260
+ }
261
+
262
+ function handleSubmit() {
263
+ const text = draftText();
264
+ const hasAttachments = attachments.length > 0;
265
+ if ((!text && !hasAttachments) || wsStatus !== "connected") return;
266
+
267
+ if (text) setLastPrompt(text);
268
+ const messageAttachments = attachments.map((a) => ({
269
+ previewUrl: a.previewUrl,
270
+ mediaType: a.attachment.mediaType,
271
+ }));
272
+ addUserMessage(
273
+ text,
274
+ reviewText.trim() ? "voice-dictate" : "typed",
275
+ messageAttachments.length > 0 ? messageAttachments : undefined
276
+ );
277
+ // A send while the session is already streaming is a follow-up — the server
278
+ // queues it or delivers it live; don't pre-start a second assistant bubble
279
+ // (the backend's next frames start it).
280
+ if (!isStreaming) startAssistantMessage();
281
+ send({
282
+ type: "chat_message",
283
+ text,
284
+ sessionId: sessionId ?? undefined,
285
+ // Provider only applies to new conversations; resumed sessions are
286
+ // pinned server-side to their original combo.
287
+ // Only send a provider the server actually offers — a stale persisted
288
+ // id (e.g. key removed server-side) falls back to the server default
289
+ // instead of erroring with PROVIDER_UNAVAILABLE.
290
+ providerId:
291
+ sessionId || !providers.some((p) => p.id === selectedProviderId)
292
+ ? undefined
293
+ : selectedProviderId,
294
+ attachments: hasAttachments
295
+ ? attachments.map((a) => a.attachment)
296
+ : undefined,
297
+ });
298
+ setInput("");
299
+ clearReview();
300
+ // Ownership of the preview URLs transfers to the rendered user message
301
+ // (revoked later by the chat store on clear/resume) — don't revoke here.
302
+ attachmentsRef.current = [];
303
+ setAttachments([]);
304
+ setAttachErrors([]);
305
+ }
306
+
307
+ function handleMicTap() {
308
+ if (voiceMode === "dictate") {
309
+ void dictation.stop(true);
310
+ } else {
311
+ // Defensive: blur composer so the keyboard never fights the mic sheet on Android
312
+ textareaRef.current?.blur();
313
+ // Any existing review text stays put — the new capture appends to it.
314
+ void dictation.start();
315
+ }
316
+ }
317
+
318
+ function handleVoiceSend() {
319
+ if (!draftText() || isStreaming || wsStatus !== "connected") {
320
+ // If we cannot send right now, fall back to editing
321
+ handleVoiceEdit();
322
+ return;
323
+ }
324
+ handleSubmit();
325
+ }
326
+
327
+ function handleVoiceEdit() {
328
+ setInput((prev) => (prev ? `${prev}\n${reviewText}` : reviewText));
329
+ clearReview();
330
+ setTimeout(() => textareaRef.current?.focus(), 50);
331
+ }
332
+
333
+ function handleVoiceAppend() {
334
+ // Review text stays in place; the next capture appends to it on stop.
335
+ void dictation.start();
336
+ }
337
+
338
+ function handleRecall() {
339
+ if (lastPrompt && !input.trim()) {
340
+ setInput(lastPrompt);
341
+ setTimeout(() => textareaRef.current?.focus(), 50);
342
+ }
343
+ }
344
+
345
+ function handleCancel() {
346
+ // Scope the cancel to the session in view so it is unambiguous when several
347
+ // sessions are running.
348
+ send({ type: "cancel", sessionId: sessionId ?? undefined });
349
+ }
350
+
351
+ function handleToolApproval(toolUseId: string, approved: boolean) {
352
+ resolveToolApproval(toolUseId, approved);
353
+ if (approved) {
354
+ send({ type: "tool_approval", toolUseId });
355
+ } else {
356
+ send({ type: "tool_denial", toolUseId, message: "Denied by user" });
357
+ }
358
+ }
359
+
360
+ function handleAskUserSubmit(
361
+ requestId: string,
362
+ answers: Record<string, string>,
363
+ annotations?: Record<string, AskUserAnnotation>
364
+ ) {
365
+ submitAskUserAnswers(requestId, answers, annotations);
366
+ send({ type: "ask_user_response", requestId, answers, annotations });
367
+ }
368
+
369
+ function handleAskUserCancel(requestId: string) {
370
+ cancelAskUser(requestId);
371
+ send({ type: "ask_user_cancel", requestId, reason: "User dismissed" });
372
+ }
373
+
374
+ function handleSessionResume(resumeSessionId: string) {
375
+ clearMessages();
376
+ setActiveSession(resumeSessionId);
377
+ send({ type: "session_resume", sessionId: resumeSessionId });
378
+ }
379
+
380
+ function handleCommand(command: string) {
381
+ setInput("");
382
+ setShowCommandPalette(false);
383
+
384
+ const disabled = wsStatus !== "connected" || isStreaming;
385
+ if (disabled) return;
386
+
387
+ switch (command) {
388
+ case "sync":
389
+ setSyncPanelOpen(true);
390
+ break;
391
+ case "whatsup":
392
+ setWhatsupPanelOpen(true);
393
+ break;
394
+ case "search":
395
+ setInput("");
396
+ // Focus the input with a placeholder hint
397
+ setTimeout(() => textareaRef.current?.focus(), 50);
398
+ break;
399
+ case "add":
400
+ setInput("");
401
+ setTimeout(() => textareaRef.current?.focus(), 50);
402
+ break;
403
+ case "stats":
404
+ runStatsAction();
405
+ break;
406
+ }
407
+ }
408
+
409
+ async function runStatsAction() {
410
+ addUserMessage("Stats");
411
+ startAssistantMessage();
412
+ try {
413
+ const stats = await api.brainStats();
414
+ const result = [
415
+ `**Brain Statistics**`,
416
+ `- Documents: ${stats.documents}`,
417
+ `- Tags: ${stats.tags}`,
418
+ `- Links: ${stats.links}`,
419
+ ``,
420
+ `**By Type:** ${Object.entries(stats.byType)
421
+ .sort(([, a], [, b]) => b - a)
422
+ .map(([t, n]) => `${t} (${n})`)
423
+ .join(", ")}`,
424
+ ``,
425
+ `**By Status:** ${Object.entries(stats.byStatus)
426
+ .map(([s, n]) => `${s} (${n})`)
427
+ .join(", ")}`,
428
+ ].join("\n");
429
+ appendText(result);
430
+ } catch (err) {
431
+ appendText(
432
+ `**Error:** ${err instanceof Error ? err.message : "Action failed"}`
433
+ );
434
+ } finally {
435
+ finishAssistantMessage();
436
+ }
437
+ }
438
+
439
+ function scrollToBottom() {
440
+ const el = scrollRef.current;
441
+ if (el) {
442
+ el.scrollTop = el.scrollHeight;
443
+ autoScrollRef.current = true;
444
+ setShowScrollButton(false);
445
+ }
446
+ }
447
+
448
+ function handleWelcomeAction(action: string) {
449
+ handleCommand(action);
450
+ }
451
+
452
+ const hasDraft = Boolean(
453
+ input.trim() || reviewText.trim() || attachments.length > 0
454
+ );
455
+ // A send while a session is running is a follow-up (not blocked by streaming).
456
+ const canSend = hasDraft && wsStatus === "connected";
457
+
458
+ // Provider picker: locked to the pinned combo once a session is live.
459
+ // Lock while streaming too: the first send of a new conversation pins the
460
+ // provider server-side before session_info delivers the sessionId.
461
+ const providerLocked = sessionId != null || isStreaming;
462
+ const displayProviderId = providerLocked
463
+ ? pinnedProviderId ?? selectedProviderId
464
+ : selectedProviderId;
465
+ const displayProvider = providers.find((p) => p.id === displayProviderId);
466
+ const showProviderPicker = providers.length > 1;
467
+ // What happens if the user sends into the currently-running session.
468
+ const displayBackendId = displayProvider?.backendId;
469
+ const followUpLive = displayBackendId
470
+ ? backends[displayBackendId]?.capabilities.followUp ?? false
471
+ : false;
472
+ const followUpHint =
473
+ isStreaming && sessionId
474
+ ? followUpLive
475
+ ? "Follows up live"
476
+ : "Will queue"
477
+ : null;
478
+
479
+ return (
480
+ <div className="flex flex-1 flex-col overflow-hidden">
481
+ {/* Panels */}
482
+ <SessionDrawer
483
+ open={sessionPanelOpen}
484
+ onClose={() => setSessionPanelOpen(false)}
485
+ onResume={handleSessionResume}
486
+ />
487
+ <PasskeyPanel
488
+ open={securityPanelOpen}
489
+ onClose={() => setSecurityPanelOpen(false)}
490
+ />
491
+ <StreamingPanel
492
+ open={syncPanelOpen}
493
+ onClose={() => setSyncPanelOpen(false)}
494
+ title="Brain Sync"
495
+ endpoint={getBackendUrl("/api/brain/sync")}
496
+ />
497
+ <WhatsupPanel
498
+ open={whatsupPanelOpen}
499
+ onClose={() => setWhatsupPanelOpen(false)}
500
+ />
501
+ <FilePanel
502
+ open={filePanelOpen}
503
+ onClose={() => setFilePanelOpen(false)}
504
+ />
505
+
506
+ {/* Message area */}
507
+ <div className="relative flex-1 overflow-hidden">
508
+ {messages.length === 0 ? (
509
+ <div className="h-full overflow-y-auto">
510
+ <WelcomeState onAction={handleWelcomeAction} />
511
+ </div>
512
+ ) : (
513
+ <div ref={scrollRef} className="h-full overflow-y-auto px-4 md:px-6">
514
+ <div className="mx-auto max-w-3xl divide-y divide-border/20">
515
+ {messages.map((msg) => (
516
+ <MessageBubble
517
+ key={msg.id}
518
+ message={msg}
519
+ onToolApproval={handleToolApproval}
520
+ onAskUserSubmit={handleAskUserSubmit}
521
+ onAskUserCancel={handleAskUserCancel}
522
+ />
523
+ ))}
524
+ </div>
525
+ </div>
526
+ )}
527
+
528
+ {showScrollButton && (
529
+ <button
530
+ type="button"
531
+ onClick={scrollToBottom}
532
+ title="Scroll to bottom"
533
+ className="absolute bottom-4 left-1/2 -translate-x-1/2 flex h-8 w-8 items-center justify-center rounded-full border border-border bg-surface shadow-md text-muted-foreground transition-all duration-150 hover:text-foreground"
534
+ >
535
+ <ArrowDown className="h-4 w-4" />
536
+ </button>
537
+ )}
538
+ </div>
539
+
540
+ {/* Dictation sheet */}
541
+ <DictationSheet
542
+ open={voiceMode === "dictate"}
543
+ onStop={() => dictation.stop(true)}
544
+ onCancel={() => dictation.cancel()}
545
+ />
546
+
547
+ {/* Input composer */}
548
+ <div className="px-4 pt-2 pb-4 md:px-6 md:pb-6">
549
+ <div className="mx-auto max-w-3xl">
550
+ {/* Voice review card sits above the composer */}
551
+ <ReviewCard
552
+ text={reviewText}
553
+ onSend={handleVoiceSend}
554
+ onEdit={handleVoiceEdit}
555
+ onDiscard={clearReview}
556
+ onAppend={handleVoiceAppend}
557
+ />
558
+
559
+ {/* Rejected-file errors */}
560
+ {attachErrors.length > 0 && (
561
+ <div className="mb-2 flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-[11px] text-destructive">
562
+ <div className="flex-1 space-y-0.5">
563
+ {attachErrors.map((e, i) => (
564
+ <div key={i}>{e}</div>
565
+ ))}
566
+ </div>
567
+ <button
568
+ type="button"
569
+ onClick={() => setAttachErrors([])}
570
+ title="Dismiss"
571
+ className="shrink-0 rounded p-0.5 transition-colors hover:text-foreground"
572
+ >
573
+ <X className="h-3.5 w-3.5" />
574
+ </button>
575
+ </div>
576
+ )}
577
+
578
+ {/* Image attachment preview strip */}
579
+ {attachments.length > 0 && (
580
+ <div className="mb-2 flex flex-wrap gap-2">
581
+ {attachments.map((a, i) => (
582
+ <div key={i} className="relative h-16 w-16 shrink-0">
583
+ <img
584
+ src={a.previewUrl}
585
+ alt={a.name}
586
+ className="h-16 w-16 rounded-lg border border-border object-cover"
587
+ />
588
+ <button
589
+ type="button"
590
+ onClick={() => removeAttachment(i)}
591
+ title="Remove"
592
+ className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-surface text-muted-foreground shadow transition-colors hover:text-destructive"
593
+ >
594
+ <X className="h-3 w-3" />
595
+ </button>
596
+ </div>
597
+ ))}
598
+ </div>
599
+ )}
600
+
601
+ <div
602
+ className={cn(
603
+ "relative rounded-2xl border border-border bg-surface shadow-lg transition-all duration-200",
604
+ "focus-within:border-primary/40 focus-within:shadow-[0_0_20px_rgba(224,159,62,0.05)]"
605
+ )}
606
+ >
607
+ {/* Command palette */}
608
+ {showCommandPalette && (
609
+ <CommandPalette
610
+ filter={input.slice(1)}
611
+ onSelect={handleCommand}
612
+ />
613
+ )}
614
+
615
+ {/* Hidden file inputs for the paperclip / camera buttons */}
616
+ <input
617
+ ref={libraryInputRef}
618
+ type="file"
619
+ accept="image/*"
620
+ multiple
621
+ hidden
622
+ onChange={onFilePick}
623
+ />
624
+ <input
625
+ ref={cameraInputRef}
626
+ type="file"
627
+ accept="image/*"
628
+ capture="environment"
629
+ hidden
630
+ onChange={onFilePick}
631
+ />
632
+
633
+ <textarea
634
+ ref={textareaRef}
635
+ value={input}
636
+ onChange={(e) => setInput(e.target.value)}
637
+ onPaste={(e) => {
638
+ const files = e.clipboardData?.files;
639
+ if (files && files.length > 0) {
640
+ const images = Array.from(files).filter((f) =>
641
+ f.type.startsWith("image/")
642
+ );
643
+ if (images.length > 0) {
644
+ e.preventDefault();
645
+ void addFiles(images);
646
+ }
647
+ }
648
+ }}
649
+ onKeyDown={(e) => {
650
+ if (e.key === "Enter" && !e.shiftKey) {
651
+ if (showCommandPalette) return;
652
+ // On desktop (>=768px), Enter sends. On mobile, Enter inserts newline.
653
+ if (window.matchMedia("(min-width: 768px)").matches) {
654
+ e.preventDefault();
655
+ handleSubmit();
656
+ }
657
+ }
658
+ if (e.key === "ArrowUp" && !input.trim()) {
659
+ handleRecall();
660
+ }
661
+ if (e.key === "Escape") {
662
+ setShowCommandPalette(false);
663
+ }
664
+ }}
665
+ placeholder={
666
+ wsStatus !== "connected"
667
+ ? "Connecting..."
668
+ : uiConfig.composerPlaceholder
669
+ }
670
+ disabled={wsStatus !== "connected"}
671
+ rows={1}
672
+ className="w-full resize-none bg-transparent px-5 py-4 text-sm text-foreground placeholder:text-muted-foreground/40 focus:outline-none disabled:opacity-50"
673
+ style={{ minHeight: "56px", maxHeight: "200px" }}
674
+ />
675
+
676
+ <div className="flex items-center justify-between px-4 pb-3">
677
+ {/* Provider picker + hints */}
678
+ <div className="flex min-w-0 items-center gap-3 text-[11px] text-muted-foreground/50">
679
+ {showProviderPicker && (
680
+ <div ref={providerMenuRef} className="relative">
681
+ <button
682
+ type="button"
683
+ onClick={() =>
684
+ !providerLocked && setProviderMenuOpen((v) => !v)
685
+ }
686
+ disabled={providerLocked}
687
+ title={
688
+ providerLocked
689
+ ? "Provider is fixed for this conversation"
690
+ : "Choose model"
691
+ }
692
+ className={cn(
693
+ "flex max-w-[9rem] items-center gap-1 rounded-lg px-2 py-1 text-[11px] transition-colors md:max-w-[14rem]",
694
+ providerLocked
695
+ ? "cursor-default text-muted-foreground/60"
696
+ : "text-muted-foreground hover:bg-surface-raised hover:text-foreground"
697
+ )}
698
+ >
699
+ {providerLocked && (
700
+ <Lock className="h-3 w-3 shrink-0 opacity-70" />
701
+ )}
702
+ <span className="truncate">
703
+ {displayProvider?.label ?? "Default model"}
704
+ </span>
705
+ {!providerLocked && (
706
+ <ChevronDown className="h-3 w-3 shrink-0 opacity-70" />
707
+ )}
708
+ </button>
709
+ {providerMenuOpen && !providerLocked && (
710
+ <div
711
+ role="menu"
712
+ className="absolute bottom-full left-0 z-50 mb-1 min-w-[14rem] overflow-hidden rounded-xl border border-border bg-surface-overlay py-1 shadow-2xl"
713
+ >
714
+ {providers.map((p) => (
715
+ <button
716
+ key={p.id}
717
+ role="menuitem"
718
+ type="button"
719
+ onClick={() => {
720
+ setSelectedProvider(p.id);
721
+ setProviderMenuOpen(false);
722
+ }}
723
+ className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs text-foreground transition-colors hover:bg-surface-raised"
724
+ >
725
+ <Check
726
+ className={cn(
727
+ "h-3.5 w-3.5 shrink-0 text-primary",
728
+ p.id === selectedProviderId
729
+ ? "opacity-100"
730
+ : "opacity-0"
731
+ )}
732
+ />
733
+ <span className="truncate">{p.label}</span>
734
+ </button>
735
+ ))}
736
+ </div>
737
+ )}
738
+ </div>
739
+ )}
740
+ {followUpHint ? (
741
+ <span className="text-primary/70">{followUpHint}</span>
742
+ ) : (
743
+ <>
744
+ <span className="hidden sm:inline">
745
+ <span className="font-[family-name:var(--font-mono)]">/</span>{" "}
746
+ for commands
747
+ </span>
748
+ <span className="hidden md:inline">Shift+Enter for newline</span>
749
+ </>
750
+ )}
751
+ </div>
752
+
753
+ {/* Attach + Recall + Mic + Send / Cancel */}
754
+ <div className="flex items-center gap-1.5">
755
+ <button
756
+ type="button"
757
+ onClick={() => libraryInputRef.current?.click()}
758
+ disabled={isStreaming || wsStatus !== "connected"}
759
+ title="Attach images"
760
+ aria-label="Attach images"
761
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30"
762
+ >
763
+ <Paperclip className="h-4 w-4" />
764
+ </button>
765
+ <button
766
+ type="button"
767
+ onClick={() => cameraInputRef.current?.click()}
768
+ disabled={isStreaming || wsStatus !== "connected"}
769
+ title="Take a photo"
770
+ aria-label="Take a photo"
771
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground disabled:cursor-not-allowed disabled:opacity-30"
772
+ >
773
+ <Camera className="h-4 w-4" />
774
+ </button>
775
+ {lastPrompt && !input.trim() && !isStreaming && (
776
+ <button
777
+ type="button"
778
+ onClick={handleRecall}
779
+ title="Recall last prompt"
780
+ className="flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-all duration-150 hover:bg-surface-raised hover:text-foreground"
781
+ >
782
+ <CornerLeftUp className="h-4 w-4" />
783
+ </button>
784
+ )}
785
+ <MicButton
786
+ active={voiceMode === "dictate"}
787
+ disabled={isStreaming || wsStatus !== "connected"}
788
+ onTap={handleMicTap}
789
+ />
790
+ {isStreaming && !hasDraft ? (
791
+ // Streaming with nothing drafted: the primary action is cancel.
792
+ <button
793
+ type="button"
794
+ onClick={handleCancel}
795
+ title="Stop the running turn"
796
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-destructive text-primary-foreground transition-all duration-150"
797
+ >
798
+ <Square className="h-3.5 w-3.5" />
799
+ </button>
800
+ ) : (
801
+ // A draft always sends — as a new turn, or a follow-up when a
802
+ // session is already running.
803
+ <button
804
+ type="button"
805
+ onClick={handleSubmit}
806
+ disabled={!canSend}
807
+ title={followUpHint ?? "Send"}
808
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground transition-all duration-150 hover:brightness-110 disabled:opacity-30"
809
+ >
810
+ <ArrowUp className="h-4 w-4" />
811
+ </button>
812
+ )}
813
+ </div>
814
+ </div>
815
+ </div>
816
+ </div>
817
+ </div>
818
+ </div>
819
+ );
820
+ }