@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,68 @@
1
+ import {
2
+ RefreshCw,
3
+ Search,
4
+ Newspaper,
5
+ Plus,
6
+ BarChart3,
7
+ } from "lucide-react";
8
+ import type { LucideIcon } from "lucide-react";
9
+ import { motion } from "framer-motion";
10
+
11
+ interface Command {
12
+ name: string;
13
+ label: string;
14
+ description: string;
15
+ icon: LucideIcon;
16
+ }
17
+
18
+ const commands: Command[] = [
19
+ { name: "sync", label: "/sync", description: "Sync brain repository", icon: RefreshCw },
20
+ { name: "search", label: "/search", description: "Search knowledge base", icon: Search },
21
+ { name: "whatsup", label: "/whatsup", description: "Daily briefing", icon: Newspaper },
22
+ { name: "add", label: "/add", description: "Add a note", icon: Plus },
23
+ { name: "stats", label: "/stats", description: "Brain statistics", icon: BarChart3 },
24
+ ];
25
+
26
+ export function CommandPalette({
27
+ filter,
28
+ onSelect,
29
+ }: {
30
+ filter: string;
31
+ onSelect: (command: string) => void;
32
+ }) {
33
+ const filtered = commands.filter(
34
+ (cmd) =>
35
+ cmd.name.startsWith(filter.toLowerCase()) ||
36
+ cmd.label.startsWith("/" + filter.toLowerCase())
37
+ );
38
+
39
+ if (filtered.length === 0) return null;
40
+
41
+ return (
42
+ <motion.div
43
+ initial={{ opacity: 0, y: 4 }}
44
+ animate={{ opacity: 1, y: 0 }}
45
+ exit={{ opacity: 0, y: 4 }}
46
+ transition={{ duration: 0.15 }}
47
+ className="absolute bottom-full left-0 right-0 mb-2 overflow-hidden rounded-xl border border-border bg-surface-overlay shadow-2xl"
48
+ >
49
+ {filtered.map((cmd) => (
50
+ <button
51
+ key={cmd.name}
52
+ onClick={() => onSelect(cmd.name)}
53
+ className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-surface-raised"
54
+ >
55
+ <cmd.icon className="h-4 w-4 shrink-0 text-primary" />
56
+ <div className="min-w-0">
57
+ <div className="font-[family-name:var(--font-mono)] text-[13px] font-medium text-foreground">
58
+ {cmd.label}
59
+ </div>
60
+ <div className="text-[11px] text-muted-foreground">
61
+ {cmd.description}
62
+ </div>
63
+ </div>
64
+ </button>
65
+ ))}
66
+ </motion.div>
67
+ );
68
+ }
@@ -0,0 +1,5 @@
1
+ import { BrainMarkdown } from "./brain-markdown.js";
2
+
3
+ export function MarkdownContent({ content }: { content: string }) {
4
+ return <BrainMarkdown content={content} fileLinks />;
5
+ }
@@ -0,0 +1,362 @@
1
+ import { uiConfig } from "../../config.js";
2
+ import { useRef, useState } from "react";
3
+ import { ChevronDown, Sparkles, Mic, Image as ImageIcon } from "lucide-react";
4
+ import type {
5
+ ChatMessage,
6
+ ToolCall,
7
+ MessagePart,
8
+ AskUserExchange,
9
+ } from "../../stores/chat-store.js";
10
+ import type { AskUserAnnotation } from "@schlessera/brain-ui-sdk/protocol";
11
+ import { ToolCallTimeline } from "./tool-call-timeline.js";
12
+ import { MarkdownContent } from "./markdown-content.js";
13
+ import { linkifyPaths } from "./brain-markdown.js";
14
+ import { AskUserCard } from "./ask-user-card.js";
15
+ import { isAskUserTool } from "../../lib/tool-names.js";
16
+ import { motion } from "framer-motion";
17
+ import { buildMessageShareOptions } from "./message-share.js";
18
+ import { ShareMenu } from "../share/share-menu.js";
19
+
20
+ export function MessageBubble({
21
+ message,
22
+ onToolApproval,
23
+ onAskUserSubmit,
24
+ onAskUserCancel,
25
+ }: {
26
+ message: ChatMessage;
27
+ onToolApproval: (toolUseId: string, approved: boolean) => void;
28
+ onAskUserSubmit: (
29
+ requestId: string,
30
+ answers: Record<string, string>,
31
+ annotations?: Record<string, AskUserAnnotation>
32
+ ) => void;
33
+ onAskUserCancel: (requestId: string) => void;
34
+ }) {
35
+ const isUser = message.role === "user";
36
+
37
+ return (
38
+ <motion.div
39
+ initial={{ opacity: 0, y: 6 }}
40
+ animate={{ opacity: 1, y: 0 }}
41
+ transition={{ duration: 0.2, ease: "easeOut" }}
42
+ className="py-4"
43
+ >
44
+ {/* Label row */}
45
+ <div className="mb-2 flex items-center gap-3">
46
+ <span
47
+ className={
48
+ isUser
49
+ ? "select-none font-[family-name:var(--font-mono)] text-xs font-semibold uppercase tracking-widest text-primary/80"
50
+ : "select-none font-[family-name:var(--font-mono)] text-xs font-semibold uppercase tracking-widest text-accent/80"
51
+ }
52
+ >
53
+ {isUser ? "You" : uiConfig.assistantName}
54
+ </span>
55
+ <div className="h-px flex-1 bg-border/40" />
56
+ {isUser && message.source && message.source !== "typed" && (
57
+ <Mic
58
+ className={
59
+ message.source === "voice-conversation"
60
+ ? "h-3 w-3 text-primary/70"
61
+ : "h-3 w-3 text-primary/50"
62
+ }
63
+ aria-label={
64
+ message.source === "voice-conversation"
65
+ ? "Voice conversation"
66
+ : "Voice dictation"
67
+ }
68
+ />
69
+ )}
70
+ <span className="font-[family-name:var(--font-mono)] text-[10px] text-muted-foreground/40">
71
+ {formatTime(message.timestamp)}
72
+ </span>
73
+ </div>
74
+
75
+ {/* Content */}
76
+ {isUser ? (
77
+ <div className="space-y-2">
78
+ <UserAttachments message={message} />
79
+ {message.content && (
80
+ <div className="text-sm leading-relaxed text-foreground whitespace-pre-wrap">
81
+ {linkifyPaths(message.content)}
82
+ </div>
83
+ )}
84
+ </div>
85
+ ) : (
86
+ <AssistantContent
87
+ message={message}
88
+ onToolApproval={onToolApproval}
89
+ onAskUserSubmit={onAskUserSubmit}
90
+ onAskUserCancel={onAskUserCancel}
91
+ />
92
+ )}
93
+ </motion.div>
94
+ );
95
+ }
96
+
97
+ /**
98
+ * User-message image attachments. Live messages render real thumbnails from
99
+ * their local object URLs; resumed-from-history messages carry only a count,
100
+ * so they get a neutral "N image(s)" chip instead.
101
+ */
102
+ function UserAttachments({ message }: { message: ChatMessage }) {
103
+ if (message.attachments && message.attachments.length > 0) {
104
+ return (
105
+ <div className="flex flex-wrap gap-2">
106
+ {message.attachments.map((a, i) => (
107
+ <img
108
+ key={i}
109
+ src={a.previewUrl}
110
+ alt="Attached image"
111
+ className="h-20 w-20 rounded-lg border border-border/60 object-cover"
112
+ />
113
+ ))}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ if (message.attachmentCount && message.attachmentCount > 0) {
119
+ return (
120
+ <div className="inline-flex items-center gap-1.5 rounded-lg border border-border/40 bg-surface/50 px-2.5 py-1 text-xs text-muted-foreground">
121
+ <ImageIcon className="h-3.5 w-3.5" />
122
+ {message.attachmentCount} image{message.attachmentCount === 1 ? "" : "s"}
123
+ </div>
124
+ );
125
+ }
126
+
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Render groups: consecutive `tool` parts collapse into one timeline run so
132
+ * back-to-back tool calls share a single visual timeline, while thinking and
133
+ * text stay interleaved in true chronological order.
134
+ */
135
+ type PartGroup =
136
+ | { kind: "thinking"; text: string; isLast: boolean }
137
+ | { kind: "text"; text: string; isLast: boolean; isLastText: boolean }
138
+ | { kind: "tools"; toolCalls: ToolCall[]; isLast: boolean }
139
+ | { kind: "askUser"; exchange: AskUserExchange; isLast: boolean };
140
+
141
+ function groupParts(
142
+ parts: MessagePart[],
143
+ toolCalls: ToolCall[],
144
+ askUserExchanges: AskUserExchange[] | undefined
145
+ ): PartGroup[] {
146
+ const groups: PartGroup[] = [];
147
+ // ask_user tool parts don't join the tool timeline — each maps (in order)
148
+ // to its exchange and renders as its own AskUserCard at that chronological
149
+ // spot, so it collapses and scrolls away like the surrounding events.
150
+ let askUserSeen = 0;
151
+ for (const part of parts) {
152
+ if (part.kind === "tool") {
153
+ const tool = toolCalls[part.toolIndex];
154
+ if (!tool) continue;
155
+ if (isAskUserTool(tool.name)) {
156
+ const exchange = askUserExchanges?.[askUserSeen];
157
+ askUserSeen++;
158
+ // The exchange may not have arrived yet mid-stream — skip until it does.
159
+ if (exchange) {
160
+ groups.push({ kind: "askUser", exchange, isLast: false });
161
+ }
162
+ continue;
163
+ }
164
+ const last = groups[groups.length - 1];
165
+ if (last?.kind === "tools") {
166
+ last.toolCalls.push(tool);
167
+ } else {
168
+ groups.push({ kind: "tools", toolCalls: [tool], isLast: false });
169
+ }
170
+ } else if (part.text.trim()) {
171
+ groups.push({ kind: part.kind, text: part.text, isLast: false, isLastText: false });
172
+ }
173
+ }
174
+ const last = groups[groups.length - 1];
175
+ if (last) last.isLast = true;
176
+ for (let i = groups.length - 1; i >= 0; i--) {
177
+ const g = groups[i];
178
+ if (g.kind === "text") {
179
+ g.isLastText = true;
180
+ break;
181
+ }
182
+ }
183
+ return groups;
184
+ }
185
+
186
+ function AssistantContent({
187
+ message,
188
+ onToolApproval,
189
+ onAskUserSubmit,
190
+ onAskUserCancel,
191
+ }: {
192
+ message: ChatMessage;
193
+ onToolApproval: (toolUseId: string, approved: boolean) => void;
194
+ onAskUserSubmit: (
195
+ requestId: string,
196
+ answers: Record<string, string>,
197
+ annotations?: Record<string, AskUserAnnotation>
198
+ ) => void;
199
+ onAskUserCancel: (requestId: string) => void;
200
+ }) {
201
+ const contentRef = useRef<HTMLDivElement>(null);
202
+ const showShare =
203
+ !message.isStreaming && !!message.content && message.content.trim().length > 0;
204
+ const shareOptions = showShare
205
+ ? buildMessageShareOptions({
206
+ content: message.content,
207
+ renderedRef: contentRef,
208
+ })
209
+ : [];
210
+
211
+ const groups = groupParts(
212
+ message.parts,
213
+ message.toolCalls,
214
+ message.askUserExchanges
215
+ );
216
+
217
+ // Safety net: each ask_user tool part consumes one exchange (by order) into a
218
+ // chronological group above. Any exchange without a matching tool part — e.g.
219
+ // a stream that missed the tool_use_start — would otherwise vanish, stranding
220
+ // an unanswerable prompt. Render those trailing so a prompt is never lost.
221
+ const askUserSlots = message.parts.reduce(
222
+ (n, p) =>
223
+ p.kind === "tool" && isAskUserTool(message.toolCalls[p.toolIndex]?.name)
224
+ ? n + 1
225
+ : n,
226
+ 0
227
+ );
228
+ const unmatchedExchanges = (message.askUserExchanges ?? []).slice(askUserSlots);
229
+
230
+ return (
231
+ <div className="space-y-3">
232
+ {groups.map((group, i) => {
233
+ switch (group.kind) {
234
+ case "thinking":
235
+ return (
236
+ <ThinkingSection
237
+ key={i}
238
+ content={group.text}
239
+ isStreaming={message.isStreaming && group.isLast}
240
+ />
241
+ );
242
+ case "tools":
243
+ return (
244
+ <ToolCallTimeline
245
+ key={i}
246
+ toolCalls={group.toolCalls}
247
+ onApproval={onToolApproval}
248
+ live={message.isStreaming}
249
+ />
250
+ );
251
+ case "askUser":
252
+ return (
253
+ <AskUserCard
254
+ key={i}
255
+ requestId={group.exchange.requestId}
256
+ questions={group.exchange.questions}
257
+ answered={group.exchange.answers}
258
+ cancelled={group.exchange.cancelled}
259
+ live={message.isStreaming}
260
+ onSubmit={onAskUserSubmit}
261
+ onCancel={onAskUserCancel}
262
+ />
263
+ );
264
+ case "text":
265
+ // The final text block carries the share affordance; text/markdown
266
+ // share formats still use the full message content.
267
+ return group.isLastText ? (
268
+ <div key={i} className="group relative" ref={contentRef}>
269
+ <MarkdownContent content={group.text} />
270
+ {showShare && shareOptions.length > 0 && (
271
+ <div className="mt-1 flex justify-end opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
272
+ <ShareMenu options={shareOptions} title="Share message" />
273
+ </div>
274
+ )}
275
+ </div>
276
+ ) : (
277
+ <MarkdownContent key={i} content={group.text} />
278
+ );
279
+ }
280
+ })}
281
+
282
+ {unmatchedExchanges.map((ex) => (
283
+ <AskUserCard
284
+ key={ex.requestId}
285
+ requestId={ex.requestId}
286
+ questions={ex.questions}
287
+ answered={ex.answers}
288
+ cancelled={ex.cancelled}
289
+ live={message.isStreaming}
290
+ onSubmit={onAskUserSubmit}
291
+ onCancel={onAskUserCancel}
292
+ />
293
+ ))}
294
+
295
+ {message.isStreaming && groups.length === 0 && unmatchedExchanges.length === 0 && (
296
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
297
+ <span
298
+ className="inline-block h-1.5 w-1.5 rounded-full bg-primary"
299
+ style={{ animation: "breathe 2s ease-in-out infinite" }}
300
+ />
301
+ Thinking...
302
+ </div>
303
+ )}
304
+ </div>
305
+ );
306
+ }
307
+
308
+ function ThinkingSection({
309
+ content,
310
+ isStreaming,
311
+ }: {
312
+ content: string;
313
+ isStreaming: boolean;
314
+ }) {
315
+ const [expanded, setExpanded] = useState(isStreaming);
316
+
317
+ if (!isStreaming && !expanded) {
318
+ // Collapsed: minimal clickable text
319
+ const estimatedTokens = Math.round(content.length / 4);
320
+ return (
321
+ <button
322
+ onClick={() => setExpanded(true)}
323
+ className="flex items-center gap-1.5 text-xs text-muted-foreground/50 transition-colors hover:text-muted-foreground"
324
+ >
325
+ <Sparkles className="h-3 w-3" />
326
+ <span className="font-[family-name:var(--font-mono)]">
327
+ Thought for ~{estimatedTokens} tokens
328
+ </span>
329
+ <ChevronDown className="h-3 w-3" />
330
+ </button>
331
+ );
332
+ }
333
+
334
+ return (
335
+ <div className="rounded-lg bg-surface/50 px-4 py-3">
336
+ {!isStreaming && (
337
+ <button
338
+ onClick={() => setExpanded(false)}
339
+ className="mb-2 text-[11px] font-medium text-muted-foreground/50 transition-colors hover:text-muted-foreground"
340
+ >
341
+ Hide thinking
342
+ </button>
343
+ )}
344
+ <div className="font-[family-name:var(--font-mono)] text-xs leading-relaxed text-muted-foreground/60 italic whitespace-pre-wrap">
345
+ {linkifyPaths(content)}
346
+ {isStreaming && (
347
+ <span
348
+ className="inline-block h-1.5 w-1.5 rounded-full bg-primary ml-1"
349
+ style={{ animation: "breathe 2s ease-in-out infinite" }}
350
+ />
351
+ )}
352
+ </div>
353
+ </div>
354
+ );
355
+ }
356
+
357
+ function formatTime(timestamp: number): string {
358
+ return new Date(timestamp).toLocaleTimeString([], {
359
+ hour: "2-digit",
360
+ minute: "2-digit",
361
+ });
362
+ }
@@ -0,0 +1,78 @@
1
+ import { uiConfig } from "../../config.js";
2
+ import type { RefObject } from "react";
3
+ import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
4
+ import { stripMarkdown } from "../../lib/strip-markdown.js";
5
+ import type { ShareOption } from "../share/share-menu.js";
6
+
7
+ interface BuildOpts {
8
+ content: string;
9
+ /** Ref to the rendered message DOM. Used to grab outerHTML for rich-text copy. */
10
+ renderedRef: RefObject<HTMLElement | null>;
11
+ }
12
+
13
+ export function buildMessageShareOptions({ content, renderedRef }: BuildOpts): ShareOption[] {
14
+ const plain = () => stripMarkdown(content);
15
+
16
+ return [
17
+ {
18
+ id: "image",
19
+ label: "Image (PNG)",
20
+ hint: "Rendered snapshot",
21
+ run: () =>
22
+ renderAndShare({
23
+ content,
24
+ contentType: "markdown",
25
+ format: "png",
26
+ filename: "message",
27
+ title: uiConfig.shareTitle,
28
+ }),
29
+ },
30
+ {
31
+ id: "pdf",
32
+ label: "PDF",
33
+ hint: "Vector PDF, A4",
34
+ run: () =>
35
+ renderAndShare({
36
+ content,
37
+ contentType: "markdown",
38
+ format: "pdf",
39
+ filename: "message",
40
+ title: uiConfig.shareTitle,
41
+ }),
42
+ },
43
+ {
44
+ id: "text",
45
+ label: "Plain text",
46
+ hint: "Markdown stripped",
47
+ run: () => shareText({ text: plain() }),
48
+ },
49
+ {
50
+ id: "markdown",
51
+ label: "Markdown source",
52
+ hint: "Raw markdown verbatim",
53
+ run: () =>
54
+ shareFile(new File([content], "message.md", { type: "text/markdown" }), {
55
+ title: uiConfig.shareTitle,
56
+ }),
57
+ },
58
+ {
59
+ id: "richtext",
60
+ label: "Copy as rich text",
61
+ hint: "Paste into Gmail / Docs",
62
+ run: async () => {
63
+ const node = renderedRef.current;
64
+ const html = node?.innerHTML ?? `<p>${escapeHtml(plain())}</p>`;
65
+ return copyRichText(html, plain());
66
+ },
67
+ },
68
+ ];
69
+ }
70
+
71
+ function escapeHtml(s: string): string {
72
+ return s
73
+ .replace(/&/g, "&amp;")
74
+ .replace(/</g, "&lt;")
75
+ .replace(/>/g, "&gt;")
76
+ .replace(/"/g, "&quot;")
77
+ .replace(/'/g, "&#39;");
78
+ }
@@ -0,0 +1,53 @@
1
+ // Claude backend tool-renderer pack. These are the previous per-tool switch
2
+ // bodies, now expressed as a backend-scoped registry pack. Rendering is
3
+ // delegated to the shared switch-based views so output stays byte-identical to
4
+ // the pre-registry timeline.
5
+
6
+ import type { RendererPack, ToolRenderer, ToolCallView } from "@schlessera/brain-ui-sdk/client";
7
+ import type { ToolCall } from "../../../stores/chat-store.js";
8
+ import { GET_LOCATION_TOOL_NAME } from "../../../lib/tool-names.js";
9
+ import {
10
+ getToolIcon,
11
+ getToolSummary,
12
+ getOutputMeta,
13
+ ToolInputView,
14
+ ToolOutputView,
15
+ } from "../tool-views.js";
16
+
17
+ /** Claude tool names with dedicated iconography / summaries. */
18
+ const CLAUDE_TOOL_NAMES = [
19
+ "Bash",
20
+ "Edit",
21
+ "Write",
22
+ "Read",
23
+ "Grep",
24
+ "Glob",
25
+ "WebSearch",
26
+ "WebFetch",
27
+ "Agent",
28
+ "Skill",
29
+ "NotebookEdit",
30
+ "LSP",
31
+ GET_LOCATION_TOOL_NAME,
32
+ ];
33
+
34
+ // The shared views read brain-ui's richer ToolCall (status, inputJson, timing).
35
+ // The timeline passes the full ToolCall at runtime, so widening the neutral
36
+ // ToolCallView back to ToolCall here is safe.
37
+ const asToolCall = (tool: ToolCallView) => tool as unknown as ToolCall;
38
+
39
+ function claudeRenderer(name: string): ToolRenderer {
40
+ return {
41
+ match: name,
42
+ icon: getToolIcon(name),
43
+ summary: (tool) => getToolSummary(asToolCall(tool)),
44
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
45
+ Input: ({ tool }) => <ToolInputView tool={asToolCall(tool)} />,
46
+ Output: ({ tool }) => <ToolOutputView tool={asToolCall(tool)} />,
47
+ };
48
+ }
49
+
50
+ export const claudeToolPack: RendererPack = {
51
+ backend: "claude",
52
+ renderers: CLAUDE_TOOL_NAMES.map(claudeRenderer),
53
+ };
@@ -0,0 +1,85 @@
1
+ // Generic, backend-agnostic tool renderer — the fallback tier where unknown
2
+ // tools (a new backend, pi's curated tools) still render decently. It sniffs
3
+ // the tool's input/output SHAPE rather than its name, reusing the same view
4
+ // components the Claude pack uses.
5
+
6
+ import type { RendererPack, ToolRenderer, ToolCallView } from "@schlessera/brain-ui-sdk/client";
7
+ import { FileText } from "lucide-react";
8
+ import type { ToolCall } from "../../../stores/chat-store.js";
9
+ import {
10
+ getOutputMeta,
11
+ EditDiffView,
12
+ WriteFileView,
13
+ BashCommandView,
14
+ KeyValueView,
15
+ ClampedPre,
16
+ FileRowsView,
17
+ splitGrepRow,
18
+ } from "../tool-views.js";
19
+
20
+ const asToolCall = (tool: ToolCallView) => tool as unknown as ToolCall;
21
+
22
+ function str(v: unknown): v is string {
23
+ return typeof v === "string" && v.length > 0;
24
+ }
25
+
26
+ /** Input rendered by input SHAPE: diff, written file, terminal command, else key/value. */
27
+ function GenericInput({ tool }: { tool: ToolCallView }) {
28
+ const t = asToolCall(tool);
29
+ const input = t.input ?? {};
30
+ if (str(input.old_string) && str(input.new_string)) {
31
+ return <EditDiffView tool={t} />;
32
+ }
33
+ if ((str(input.file_path) || str(input.path)) && str(input.content)) {
34
+ // WriteFileView reads `file_path`; alias `path` so `{path, content}` tools render too.
35
+ const aliased = str(input.file_path)
36
+ ? t
37
+ : ({ ...t, input: { ...input, file_path: input.path } } as ToolCall);
38
+ return <WriteFileView tool={aliased} />;
39
+ }
40
+ if (str(input.command) || str(input.cmd)) {
41
+ const aliased = str(input.command)
42
+ ? t
43
+ : ({ ...t, input: { ...input, command: input.cmd } } as ToolCall);
44
+ return <BashCommandView tool={aliased} />;
45
+ }
46
+ return <KeyValueView tool={t} />;
47
+ }
48
+
49
+ /** Output rendered by output SHAPE: error, `path:line:` rows, else clamped text. */
50
+ function GenericOutput({ tool }: { tool: ToolCallView }) {
51
+ const t = asToolCall(tool);
52
+ if (!t.output) return null;
53
+ if (t.isError) return <ClampedPre text={t.output} isError />;
54
+ if (looksLikeFileRows(t.output)) return <FileRowsView output={t.output} />;
55
+ return <ClampedPre text={t.output} />;
56
+ }
57
+
58
+ /** True when most non-empty lines look like ripgrep-style `path:line:content`. */
59
+ function looksLikeFileRows(output: string): boolean {
60
+ const lines = output
61
+ .split("\n")
62
+ .map((l) => l.trim())
63
+ .filter(Boolean)
64
+ .slice(0, 20);
65
+ if (lines.length === 0) return false;
66
+ const matches = lines.filter((l) => splitGrepRow(l) !== null).length;
67
+ return matches >= Math.ceil(lines.length / 2);
68
+ }
69
+
70
+ /**
71
+ * The catch-all renderer. Registered as a low-scoring predicate so it applies
72
+ * to any tool no more specific renderer claimed, and exported for the timeline
73
+ * to use as an explicit fallback.
74
+ */
75
+ export const GENERIC_RENDERER: ToolRenderer = {
76
+ match: () => 0.1,
77
+ icon: FileText,
78
+ meta: (tool) => getOutputMeta(asToolCall(tool)),
79
+ Input: GenericInput,
80
+ Output: GenericOutput,
81
+ };
82
+
83
+ export const genericToolPack: RendererPack = {
84
+ renderers: [GENERIC_RENDERER],
85
+ };
@@ -0,0 +1,19 @@
1
+ // Build-time registration of tool-renderer packs. A contributor adds one
2
+ // import line + registerToolRenderers call here (React.lazy for heavy packs).
3
+ // Runtime plugin loading into the compiled PWA is deliberately not supported.
4
+
5
+ import { registerToolRenderers } from "@schlessera/brain-ui-sdk/client";
6
+ import { claudeToolPack } from "./claude-tools.js";
7
+ import { genericToolPack, GENERIC_RENDERER } from "./generic.js";
8
+
9
+ let registered = false;
10
+
11
+ /** Register the built-in packs once (idempotent). */
12
+ export function registerBuiltinRenderers(): void {
13
+ if (registered) return;
14
+ registered = true;
15
+ registerToolRenderers(claudeToolPack);
16
+ registerToolRenderers(genericToolPack);
17
+ }
18
+
19
+ export { GENERIC_RENDERER };