@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,451 @@
1
+ import { uiConfig } from "../../config.js";
2
+ import { useState, useEffect, useRef, useMemo } from "react";
3
+ import {
4
+ Check,
5
+ X,
6
+ MessageCircleQuestion,
7
+ ChevronRight,
8
+ ChevronUp,
9
+ } from "lucide-react";
10
+ import type {
11
+ AskUserQuestion,
12
+ AskUserOption,
13
+ AskUserAnnotation,
14
+ } from "@schlessera/brain-ui-sdk/protocol";
15
+ import { BrainMarkdown } from "./brain-markdown.js";
16
+ import { cn } from "../../lib/utils.js";
17
+
18
+ const OTHER_LABEL = "Other";
19
+
20
+ interface PerQuestionState {
21
+ /** Picked option labels (single-select keeps only the latest). */
22
+ selected: string[];
23
+ /** Free-text content when "Other" is selected. */
24
+ otherText: string;
25
+ /** Currently focused option index — drives the preview pane. */
26
+ focusedIndex: number | null;
27
+ }
28
+
29
+ function defaultState(): PerQuestionState {
30
+ return { selected: [], otherText: "", focusedIndex: null };
31
+ }
32
+
33
+ export function AskUserCard({
34
+ requestId,
35
+ questions,
36
+ answered,
37
+ cancelled,
38
+ live = false,
39
+ onSubmit,
40
+ onCancel,
41
+ }: {
42
+ requestId: string;
43
+ questions: AskUserQuestion[];
44
+ answered?: Record<string, string>;
45
+ cancelled?: boolean;
46
+ /** Parent message still streaming — keeps an expanded card open until it ends. */
47
+ live?: boolean;
48
+ onSubmit: (
49
+ requestId: string,
50
+ answers: Record<string, string>,
51
+ annotations?: Record<string, AskUserAnnotation>
52
+ ) => void;
53
+ onCancel: (requestId: string) => void;
54
+ }) {
55
+
56
+ // Independent per-question state
57
+ const [state, setState] = useState<PerQuestionState[]>(() =>
58
+ questions.map(() => defaultState())
59
+ );
60
+
61
+ // Once answered/dismissed, the card rolls up to a one-line summary that
62
+ // scrolls away with the rest of the turn. It stays open while unanswered
63
+ // (it's an active prompt) and re-collapses when the streaming turn ends.
64
+ const [collapsed, setCollapsed] = useState(true);
65
+ const prevLive = useRef(live);
66
+ useEffect(() => {
67
+ if (prevLive.current && !live) setCollapsed(true);
68
+ prevLive.current = live;
69
+ }, [live]);
70
+
71
+ // Re-initialise if the question set changes (new requestId arriving)
72
+ const lastRequestId = useRef(requestId);
73
+ useEffect(() => {
74
+ if (lastRequestId.current !== requestId) {
75
+ lastRequestId.current = requestId;
76
+ setState(questions.map(() => defaultState()));
77
+ }
78
+ }, [requestId, questions]);
79
+
80
+ // If we already have answers (from prior submit), force-fill state for display
81
+ const lockedAnswers = answered ?? null;
82
+ const isLocked = !!lockedAnswers || !!cancelled;
83
+
84
+ const filledState = useMemo<PerQuestionState[]>(() => {
85
+ if (!lockedAnswers) return state;
86
+ return questions.map((q) => {
87
+ const raw = lockedAnswers[q.question] ?? "";
88
+ const labels = q.multiSelect
89
+ ? raw
90
+ .split(",")
91
+ .map((s) => s.trim())
92
+ .filter(Boolean)
93
+ : raw
94
+ ? [raw]
95
+ : [];
96
+ // If the saved label isn't a known option, treat as "Other"
97
+ const knownLabels = new Set(q.options.map((o) => o.label));
98
+ const selected = labels.filter((l) => knownLabels.has(l));
99
+ const otherLabels = labels.filter((l) => !knownLabels.has(l));
100
+ const finalSelected = otherLabels.length
101
+ ? [...selected, OTHER_LABEL]
102
+ : selected;
103
+ return {
104
+ selected: finalSelected,
105
+ otherText: otherLabels.join(", "),
106
+ focusedIndex: null,
107
+ };
108
+ });
109
+ }, [lockedAnswers, questions, state]);
110
+
111
+ // Collapsed once locked: a compact, expandable summary that behaves like a
112
+ // finished tool-timeline row instead of a full card pinned in place.
113
+ if (isLocked && collapsed) {
114
+ return (
115
+ <AskUserSummaryRow
116
+ questions={questions}
117
+ answers={lockedAnswers}
118
+ cancelled={!!cancelled}
119
+ onExpand={() => setCollapsed(false)}
120
+ />
121
+ );
122
+ }
123
+
124
+ function toggleOption(qi: number, label: string) {
125
+ if (isLocked) return;
126
+ setState((prev) => {
127
+ const next = prev.map((s, i) => {
128
+ if (i !== qi) return s;
129
+ const q = questions[qi];
130
+ if (q.multiSelect) {
131
+ const has = s.selected.includes(label);
132
+ return {
133
+ ...s,
134
+ selected: has
135
+ ? s.selected.filter((l) => l !== label)
136
+ : [...s.selected, label],
137
+ };
138
+ }
139
+ return {
140
+ ...s,
141
+ selected: s.selected[0] === label ? [] : [label],
142
+ };
143
+ });
144
+ return next;
145
+ });
146
+ }
147
+
148
+ function setFocus(qi: number, idx: number | null) {
149
+ if (isLocked) return;
150
+ setState((prev) =>
151
+ prev.map((s, i) => (i === qi ? { ...s, focusedIndex: idx } : s))
152
+ );
153
+ }
154
+
155
+ function setOtherText(qi: number, text: string) {
156
+ if (isLocked) return;
157
+ setState((prev) =>
158
+ prev.map((s, i) => (i === qi ? { ...s, otherText: text } : s))
159
+ );
160
+ }
161
+
162
+ const canSubmit = !isLocked && questions.every((_q, i) => {
163
+ const s = state[i];
164
+ if (s.selected.length === 0) return false;
165
+ if (s.selected.includes(OTHER_LABEL) && !s.otherText.trim()) return false;
166
+ return true;
167
+ });
168
+
169
+ function handleSubmit() {
170
+ if (!canSubmit) return;
171
+ const answers: Record<string, string> = {};
172
+ const annotations: Record<string, AskUserAnnotation> = {};
173
+ for (let i = 0; i < questions.length; i++) {
174
+ const q = questions[i];
175
+ const s = state[i];
176
+ const labels: string[] = [];
177
+ let usedOther = false;
178
+ for (const sel of s.selected) {
179
+ if (sel === OTHER_LABEL) {
180
+ if (s.otherText.trim()) labels.push(s.otherText.trim());
181
+ usedOther = true;
182
+ } else {
183
+ labels.push(sel);
184
+ }
185
+ }
186
+ answers[q.question] = q.multiSelect ? labels.join(", ") : (labels[0] ?? "");
187
+
188
+ // Capture preview from a single non-other selection for annotations
189
+ if (!usedOther && !q.multiSelect && s.selected.length === 1) {
190
+ const picked = q.options.find((o) => o.label === s.selected[0]);
191
+ if (picked?.preview) {
192
+ annotations[q.question] = { preview: picked.preview };
193
+ }
194
+ }
195
+ }
196
+ onSubmit(
197
+ requestId,
198
+ answers,
199
+ Object.keys(annotations).length ? annotations : undefined
200
+ );
201
+ }
202
+
203
+ function handleCancel() {
204
+ onCancel(requestId);
205
+ }
206
+
207
+ return (
208
+ <div className="rounded-xl border border-accent/30 bg-surface px-4 py-4 shadow-[0_0_0_1px_rgba(91,181,162,0.08)]">
209
+ <div className="mb-3 flex items-center gap-2 text-xs uppercase tracking-wider text-accent/80">
210
+ <MessageCircleQuestion className="h-3.5 w-3.5" />
211
+ <span className="font-[family-name:var(--font-mono)]">
212
+ {isLocked
213
+ ? cancelled
214
+ ? "Dismissed"
215
+ : "You answered"
216
+ : `${uiConfig.assistantName} needs your input`}
217
+ </span>
218
+ {isLocked && (
219
+ <button
220
+ type="button"
221
+ onClick={() => setCollapsed(true)}
222
+ className="ml-auto flex items-center gap-1 text-[11px] normal-case tracking-normal text-muted-foreground/50 transition-colors hover:text-muted-foreground"
223
+ >
224
+ <ChevronUp className="h-3 w-3" />
225
+ Collapse
226
+ </button>
227
+ )}
228
+ </div>
229
+
230
+ <div className="space-y-5">
231
+ {questions.map((q, qi) => (
232
+ <QuestionBlock
233
+ key={qi}
234
+ question={q}
235
+ state={filledState[qi]}
236
+ locked={isLocked}
237
+ onToggleOption={(label) => toggleOption(qi, label)}
238
+ onFocus={(idx) => setFocus(qi, idx)}
239
+ onOtherChange={(t) => setOtherText(qi, t)}
240
+ />
241
+ ))}
242
+ </div>
243
+
244
+ {!isLocked && (
245
+ <div className="mt-4 flex items-center justify-end gap-2">
246
+ <button
247
+ type="button"
248
+ onClick={handleCancel}
249
+ className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
250
+ >
251
+ <X className="h-3.5 w-3.5" />
252
+ Dismiss
253
+ </button>
254
+ <button
255
+ type="button"
256
+ onClick={handleSubmit}
257
+ disabled={!canSubmit}
258
+ className={cn(
259
+ "flex items-center gap-1.5 rounded-lg px-4 py-1.5 text-xs font-medium transition-all",
260
+ canSubmit
261
+ ? "bg-accent text-accent-foreground hover:brightness-110"
262
+ : "bg-surface-raised text-muted-foreground/40 cursor-not-allowed"
263
+ )}
264
+ >
265
+ <Check className="h-3.5 w-3.5" />
266
+ Submit
267
+ </button>
268
+ </div>
269
+ )}
270
+ </div>
271
+ );
272
+ }
273
+
274
+ /** One-line, expandable stand-in for an answered/dismissed question. */
275
+ function AskUserSummaryRow({
276
+ questions,
277
+ answers,
278
+ cancelled,
279
+ onExpand,
280
+ }: {
281
+ questions: AskUserQuestion[];
282
+ answers: Record<string, string> | null;
283
+ cancelled: boolean;
284
+ onExpand: () => void;
285
+ }) {
286
+ const detail = cancelled
287
+ ? "Dismissed"
288
+ : questions
289
+ .map((q) => {
290
+ const a = answers?.[q.question];
291
+ return a ? `${q.header}: ${a}` : q.header;
292
+ })
293
+ .join(" · ");
294
+ return (
295
+ <button
296
+ type="button"
297
+ onClick={onExpand}
298
+ className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-muted-foreground/70 transition-colors hover:text-foreground"
299
+ >
300
+ <MessageCircleQuestion className="h-3.5 w-3.5 shrink-0 text-accent/70" />
301
+ <span className="min-w-0 truncate font-[family-name:var(--font-mono)]">
302
+ {!cancelled && <span className="text-accent/70">Answered · </span>}
303
+ {detail}
304
+ </span>
305
+ <ChevronRight className="h-3 w-3 shrink-0" />
306
+ </button>
307
+ );
308
+ }
309
+
310
+ function QuestionBlock({
311
+ question,
312
+ state,
313
+ locked,
314
+ onToggleOption,
315
+ onFocus,
316
+ onOtherChange,
317
+ }: {
318
+ question: AskUserQuestion;
319
+ state: PerQuestionState;
320
+ locked: boolean;
321
+ onToggleOption: (label: string) => void;
322
+ onFocus: (idx: number | null) => void;
323
+ onOtherChange: (text: string) => void;
324
+ }) {
325
+ const optionsWithOther: Array<AskUserOption & { isOther?: boolean }> = [
326
+ ...question.options,
327
+ { label: OTHER_LABEL, description: "Provide a custom answer.", isOther: true },
328
+ ];
329
+
330
+ const focusedOption =
331
+ state.focusedIndex !== null ? optionsWithOther[state.focusedIndex] : null;
332
+ const previewToShow =
333
+ focusedOption?.preview ??
334
+ (state.selected.length === 1 && !state.selected.includes(OTHER_LABEL)
335
+ ? question.options.find((o) => o.label === state.selected[0])?.preview
336
+ : undefined);
337
+
338
+ return (
339
+ <div>
340
+ <div className="mb-2 flex items-center gap-2">
341
+ <span className="rounded-md border border-accent/30 bg-accent/10 px-2 py-0.5 font-[family-name:var(--font-mono)] text-[10px] uppercase tracking-wider text-accent">
342
+ {question.header}
343
+ </span>
344
+ {question.multiSelect && (
345
+ <span className="text-[10px] uppercase tracking-wider text-muted-foreground/60">
346
+ multi-select
347
+ </span>
348
+ )}
349
+ </div>
350
+ <div className="mb-3 text-sm leading-relaxed text-foreground">
351
+ {question.question}
352
+ </div>
353
+
354
+ <div className="grid gap-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
355
+ <ul className="space-y-1.5">
356
+ {optionsWithOther.map((opt, idx) => {
357
+ const selected = state.selected.includes(opt.label);
358
+ return (
359
+ <li key={opt.label}>
360
+ <button
361
+ type="button"
362
+ onClick={() => onToggleOption(opt.label)}
363
+ onMouseEnter={() => onFocus(idx)}
364
+ onFocus={() => onFocus(idx)}
365
+ disabled={locked}
366
+ className={cn(
367
+ "group flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors",
368
+ selected
369
+ ? "border-accent/60 bg-accent/10"
370
+ : "border-border bg-background hover:bg-surface-raised",
371
+ locked && "cursor-default opacity-90"
372
+ )}
373
+ >
374
+ <span
375
+ className={cn(
376
+ "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
377
+ question.multiSelect ? "rounded-sm" : "rounded-full",
378
+ selected
379
+ ? "border-accent bg-accent text-accent-foreground"
380
+ : "border-border bg-background"
381
+ )}
382
+ >
383
+ {selected && <Check className="h-3 w-3" />}
384
+ </span>
385
+ <span className="min-w-0 flex-1">
386
+ <span
387
+ className={cn(
388
+ "block text-sm font-medium",
389
+ opt.isOther
390
+ ? "italic text-muted-foreground group-hover:text-foreground"
391
+ : "text-foreground"
392
+ )}
393
+ >
394
+ {opt.label}
395
+ </span>
396
+ {opt.description && (
397
+ <span className="block text-xs leading-snug text-muted-foreground">
398
+ {opt.description}
399
+ </span>
400
+ )}
401
+ </span>
402
+ </button>
403
+ {opt.isOther && state.selected.includes(OTHER_LABEL) && (
404
+ <textarea
405
+ value={state.otherText}
406
+ onChange={(e) => onOtherChange(e.target.value)}
407
+ disabled={locked}
408
+ placeholder="Type your answer..."
409
+ rows={2}
410
+ className="mt-1.5 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/40 focus:border-accent/60 focus:outline-none"
411
+ />
412
+ )}
413
+ </li>
414
+ );
415
+ })}
416
+ </ul>
417
+
418
+ {previewToShow && (
419
+ <div className="hidden min-w-0 md:block">
420
+ <div className="rounded-lg border border-border bg-background p-3">
421
+ <div className="mb-1 text-[10px] uppercase tracking-wider text-muted-foreground/60 font-[family-name:var(--font-mono)]">
422
+ Preview
423
+ </div>
424
+ <BrainMarkdown
425
+ content={previewToShow}
426
+ className="brain-prose text-xs"
427
+ />
428
+ </div>
429
+ </div>
430
+ )}
431
+ </div>
432
+
433
+ {/* Mobile preview drops below the options when a preview exists */}
434
+ {previewToShow && (
435
+ <div className="mt-2 md:hidden">
436
+ <details className="rounded-lg border border-border bg-background">
437
+ <summary className="cursor-pointer px-3 py-2 text-[11px] uppercase tracking-wider text-muted-foreground">
438
+ Preview
439
+ </summary>
440
+ <div className="border-t border-border px-3 py-2">
441
+ <BrainMarkdown
442
+ content={previewToShow}
443
+ className="brain-prose text-xs"
444
+ />
445
+ </div>
446
+ </details>
447
+ </div>
448
+ )}
449
+ </div>
450
+ );
451
+ }