@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,66 @@
1
+ import { Mic, Pencil, Send, X } from "lucide-react";
2
+
3
+ export function ReviewCard({
4
+ text,
5
+ onSend,
6
+ onEdit,
7
+ onDiscard,
8
+ onAppend,
9
+ }: {
10
+ text: string;
11
+ onSend: () => void;
12
+ onEdit: () => void;
13
+ onDiscard: () => void;
14
+ onAppend: () => void;
15
+ }) {
16
+ if (!text) return null;
17
+ return (
18
+ <div className="mb-2 rounded-2xl border border-primary/30 bg-surface shadow-[0_8px_24px_rgba(0,0,0,0.3)]">
19
+ <div className="flex items-start gap-3 px-4 pt-3 pb-2">
20
+ <Mic className="mt-0.5 h-4 w-4 flex-none text-primary" />
21
+ <p className="max-h-40 flex-1 overflow-y-auto text-sm leading-relaxed text-foreground">
22
+ {text}
23
+ </p>
24
+ </div>
25
+ <div className="flex items-center justify-between border-t border-border/40 px-2 py-2">
26
+ <div className="flex items-center gap-1">
27
+ <button
28
+ type="button"
29
+ onClick={onDiscard}
30
+ title="Discard"
31
+ className="flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-destructive"
32
+ >
33
+ <X className="h-3.5 w-3.5" />
34
+ Discard
35
+ </button>
36
+ <button
37
+ type="button"
38
+ onClick={onEdit}
39
+ title="Edit"
40
+ className="flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
41
+ >
42
+ <Pencil className="h-3.5 w-3.5" />
43
+ Edit
44
+ </button>
45
+ <button
46
+ type="button"
47
+ onClick={onAppend}
48
+ title="Append more voice"
49
+ className="flex h-9 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
50
+ >
51
+ <Mic className="h-3.5 w-3.5" />
52
+ Add
53
+ </button>
54
+ </div>
55
+ <button
56
+ type="button"
57
+ onClick={onSend}
58
+ className="flex h-10 items-center gap-2 rounded-xl bg-primary px-5 text-sm font-semibold text-primary-foreground transition-all duration-150 hover:brightness-110 active:scale-[0.98]"
59
+ >
60
+ <Send className="h-4 w-4" />
61
+ Send
62
+ </button>
63
+ </div>
64
+ </div>
65
+ );
66
+ }
package/src/config.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Deployment-tunable branding/copy. A module-level singleton, matching the
3
+ * renderer/ASR registries: the shell calls `configureBrainUi()` once at boot
4
+ * (before mounting), components read `uiConfig` at render time. Runtime
5
+ * plugin-style reconfiguration is deliberately unsupported.
6
+ */
7
+ export interface BrainUiConfig {
8
+ /** Product name shown on the login screen and connection status. */
9
+ appName: string;
10
+ /** Name the assistant speaks as in the transcript. */
11
+ assistantName: string;
12
+ /** Default title for shared artifacts (share cards, rendered PNG/PDF). */
13
+ shareTitle: string;
14
+ /** Composer placeholder. */
15
+ composerPlaceholder: string;
16
+ }
17
+
18
+ export const uiConfig: BrainUiConfig = {
19
+ appName: "Brain UI",
20
+ assistantName: "Brain",
21
+ shareTitle: "Shared from Brain",
22
+ composerPlaceholder: "Ask your brain anything...",
23
+ };
24
+
25
+ export function configureBrainUi(overrides: Partial<BrainUiConfig>): void {
26
+ Object.assign(uiConfig, overrides);
27
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect } from "react";
2
+ import { useConnectionStore } from "../stores/connection-store.js";
3
+ import { getBackendUrl } from "../lib/backend.js";
4
+
5
+ const POLL_INTERVAL = 15_000;
6
+ // Tighter cadence while offline so recovery is near-immediate
7
+ const OFFLINE_POLL_INTERVAL = 3_000;
8
+ const TIMEOUT_MS = 5_000;
9
+
10
+ export function useVpnStatus() {
11
+ const setVpnStatus = useConnectionStore((s) => s.setVpnStatus);
12
+
13
+ useEffect(() => {
14
+ let timer: ReturnType<typeof setTimeout> | undefined;
15
+ let inFlight = false;
16
+ let disposed = false;
17
+
18
+ async function check() {
19
+ if (inFlight || disposed) return;
20
+ inFlight = true;
21
+ let connected = false;
22
+ try {
23
+ const controller = new AbortController();
24
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
25
+
26
+ const res = await fetch(getBackendUrl("/api/vpn-check"), {
27
+ signal: controller.signal,
28
+ });
29
+ clearTimeout(timeout);
30
+
31
+ if (res.ok) {
32
+ setVpnStatus("connected");
33
+ connected = true;
34
+ } else if (res.status === 401) {
35
+ // Auth required (password / proxy mode) but no valid session yet.
36
+ setVpnStatus("unauthorized");
37
+ connected = true; // reachable — poll at the relaxed cadence
38
+ } else if (res.status === 403) {
39
+ setVpnStatus("forbidden");
40
+ } else {
41
+ setVpnStatus("unreachable");
42
+ }
43
+ } catch {
44
+ setVpnStatus("unreachable");
45
+ } finally {
46
+ inFlight = false;
47
+ if (!disposed) {
48
+ clearTimeout(timer);
49
+ timer = setTimeout(
50
+ check,
51
+ connected ? POLL_INTERVAL : OFFLINE_POLL_INTERVAL
52
+ );
53
+ }
54
+ }
55
+ }
56
+
57
+ // Re-check immediately when the network returns or the PWA is foregrounded
58
+ // instead of waiting out the poll interval.
59
+ const recheckNow = () => void check();
60
+ const onVisible = () => {
61
+ if (document.visibilityState === "visible") void check();
62
+ };
63
+ window.addEventListener("online", recheckNow);
64
+ document.addEventListener("visibilitychange", onVisible);
65
+
66
+ void check();
67
+
68
+ return () => {
69
+ disposed = true;
70
+ clearTimeout(timer);
71
+ window.removeEventListener("online", recheckNow);
72
+ document.removeEventListener("visibilitychange", onVisible);
73
+ };
74
+ }, [setVpnStatus]);
75
+ }
@@ -0,0 +1,403 @@
1
+ import { useEffect, useRef, useCallback } from "react";
2
+ import { WSClient } from "../lib/ws-client.js";
3
+ import { useConnectionStore } from "../stores/connection-store.js";
4
+ import { useChatStore, activeChat, type ChatKey } from "../stores/chat-store.js";
5
+ import { useProviderStore } from "../stores/provider-store.js";
6
+ import { getWsUrl } from "../lib/backend.js";
7
+ import type {
8
+ ServerMessage,
9
+ ClientMessage,
10
+ ServerLocationRequest,
11
+ SessionHistoryMessage,
12
+ AskUserQuestion,
13
+ } from "@schlessera/brain-ui-sdk/protocol";
14
+ import type { ChatMessage, ToolCall, AskUserExchange } from "../stores/chat-store.js";
15
+ import { isAskUserTool } from "../lib/tool-names.js";
16
+
17
+ let historyMessageCounter = 0;
18
+ function historyId() {
19
+ return `hist-${++historyMessageCounter}-${Date.now()}`;
20
+ }
21
+
22
+ function convertHistoryMessage(msg: SessionHistoryMessage): ChatMessage {
23
+ // Fallback for history without chronological parts: synthesize the legacy
24
+ // grouped order (thinking, tools, text).
25
+ const parts =
26
+ msg.parts ??
27
+ [
28
+ ...(msg.thinking ? [{ kind: "thinking", text: msg.thinking } as const] : []),
29
+ ...msg.toolCalls.map((_, i) => ({ kind: "tool", toolIndex: i }) as const),
30
+ ...(msg.content ? [{ kind: "text", text: msg.content } as const] : []),
31
+ ];
32
+ const askUserExchanges = reconstructAskUserExchanges(msg.toolCalls);
33
+ return {
34
+ id: historyId(),
35
+ role: msg.role,
36
+ content: msg.content,
37
+ thinking: msg.thinking,
38
+ toolCalls: msg.toolCalls.map((tc): ToolCall => ({
39
+ id: tc.id,
40
+ name: tc.name,
41
+ input: tc.input,
42
+ inputJson: JSON.stringify(tc.input, null, 2),
43
+ output: tc.output,
44
+ isError: tc.isError,
45
+ status: "complete",
46
+ })),
47
+ parts,
48
+ isStreaming: false,
49
+ timestamp: Date.now(),
50
+ ...(askUserExchanges ? { askUserExchanges } : {}),
51
+ ...(msg.attachmentCount ? { attachmentCount: msg.attachmentCount } : {}),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Rebuild ask_user exchanges from a resumed message's tool calls. The ask_user
57
+ * tool's input carries the questions and its output carries the JSON answer
58
+ * payload, so an answered question survives session resume (rendered in its
59
+ * chronological slot, collapsed) instead of vanishing.
60
+ */
61
+ function reconstructAskUserExchanges(
62
+ toolCalls: SessionHistoryMessage["toolCalls"]
63
+ ): AskUserExchange[] | undefined {
64
+ const exchanges: AskUserExchange[] = [];
65
+ for (const tc of toolCalls) {
66
+ if (!isAskUserTool(tc.name)) continue;
67
+ const questions = (tc.input?.questions as AskUserQuestion[]) ?? [];
68
+ const exchange: AskUserExchange = { requestId: tc.id, questions };
69
+ if (tc.output) {
70
+ try {
71
+ const payload = JSON.parse(tc.output) as {
72
+ answers?: Record<string, string>;
73
+ annotations?: AskUserExchange["annotations"];
74
+ };
75
+ exchange.answers = payload.answers;
76
+ exchange.annotations = payload.annotations;
77
+ } catch {
78
+ // Non-JSON output means the question was dismissed or errored out.
79
+ exchange.cancelled = true;
80
+ }
81
+ }
82
+ if (tc.isError && !exchange.answers) exchange.cancelled = true;
83
+ exchanges.push(exchange);
84
+ }
85
+ return exchanges.length ? exchanges : undefined;
86
+ }
87
+
88
+ /** Map a frame to the run-state its session should show in the session list. */
89
+ export function runStateForFrame(msg: ServerMessage): "streaming" | "queued" | "idle" {
90
+ if (msg.type === "result" || msg.type === "error") return "idle";
91
+ if (msg.type === "status") {
92
+ if (msg.status === "queued") return "queued";
93
+ if (msg.status === "idle" || msg.status === "cancelled") return "idle";
94
+ }
95
+ return "streaming";
96
+ }
97
+
98
+ export function handleServerMessage(msg: ServerMessage) {
99
+ const state = useChatStore.getState();
100
+
101
+ // Parallel-session demux (per-session buffers). Every frame is scoped by
102
+ // sessionId; a scoped frame lands in ITS session's buffer, so a background
103
+ // session's transcript keeps accumulating while another one is in view.
104
+ // Frames without a sessionId (legacy single-session servers, or a new
105
+ // session's pre-binding frames) apply to the buffer in view.
106
+ const frameSessionId = (msg as { sessionId?: string }).sessionId;
107
+ if (frameSessionId) {
108
+ state.setRunState(frameSessionId, runStateForFrame(msg));
109
+ }
110
+
111
+ // Resolve the target buffer key for this frame.
112
+ let key: ChatKey;
113
+ if (!frameSessionId) {
114
+ key = state.activeSessionId; // null = the draft view
115
+ } else if (state.buffers[frameSessionId]) {
116
+ key = frameSessionId;
117
+ } else if (state.draft && (msg.type === "session_info" || msg.type === "result")) {
118
+ // A draft run just got its server identity: adopt the draft buffer.
119
+ state.bindDraftSession(frameSessionId);
120
+ key = frameSessionId;
121
+ } else if (msg.type === "session_info" && frameSessionId === state.activeSessionId) {
122
+ // The ACTIVE session announced itself but has no buffer yet — the cold
123
+ // reattach case (PWA relaunch mid-turn; the server may skip the history
124
+ // frame when a first turn has no history yet). Materialize the buffer so
125
+ // this session_info and every following delta land instead of vanishing.
126
+ state.setActiveSession(frameSessionId);
127
+ key = frameSessionId;
128
+ } else if (msg.type === "session_history") {
129
+ // History replay may create a buffer (cold resume / reattach).
130
+ key = frameSessionId;
131
+ } else {
132
+ // Scoped frame for a session with no buffer (never opened, or evicted):
133
+ // the badge above is all we track. The transcript heals via
134
+ // session_resume when the user opens it.
135
+ return;
136
+ }
137
+
138
+ const buffer = () => {
139
+ const s = useChatStore.getState();
140
+ return key === null ? s.draft : s.buffers[key];
141
+ };
142
+
143
+ switch (msg.type) {
144
+ case "text_delta":
145
+ if (!buffer()?.isStreaming) {
146
+ state.startAssistantMessage(key);
147
+ }
148
+ state.appendText(key, msg.text);
149
+ break;
150
+
151
+ case "thinking_delta":
152
+ if (!buffer()?.isStreaming) {
153
+ state.startAssistantMessage(key);
154
+ }
155
+ state.appendThinking(key, msg.text);
156
+ break;
157
+
158
+ case "tool_use_start":
159
+ state.startToolCall(key, msg.toolUseId, msg.toolName);
160
+ break;
161
+
162
+ case "tool_input_delta":
163
+ state.appendToolInput(key, msg.partialJson);
164
+ break;
165
+
166
+ case "tool_use_complete":
167
+ state.completeToolCall(key, msg.toolUseId, msg.toolName, msg.input);
168
+ break;
169
+
170
+ case "tool_approval_request":
171
+ state.requestToolApproval(key, msg.toolUseId, msg.toolName, msg.input, msg.description);
172
+ break;
173
+
174
+ case "tool_result": {
175
+ state.setToolResult(key, msg.toolUseId, msg.output, msg.isError);
176
+ // Once the agent receives the ask_user tool result, the exchange is
177
+ // complete — drop any leftover ask_user UI state.
178
+ const chat = buffer();
179
+ if (chat?.askUser?.answers || chat?.askUser?.cancelled) {
180
+ state.clearAskUser(key);
181
+ }
182
+ break;
183
+ }
184
+
185
+ case "ask_user_request":
186
+ state.setAskUserRequest(key, msg.requestId, msg.questions);
187
+ break;
188
+
189
+ case "location_request":
190
+ requestBrowserLocation(msg);
191
+ break;
192
+
193
+ case "result":
194
+ state.finishAssistantMessage(key);
195
+ resyncIfNeeded(msg.sessionId);
196
+ break;
197
+
198
+ case "session_info": {
199
+ // bindDraftSession above handled draft adoption; an info frame may still
200
+ // re-pin the provider picker when it concerns the session in view.
201
+ const current = useChatStore.getState();
202
+ if (frameSessionId && frameSessionId === current.activeSessionId) {
203
+ useProviderStore.getState().setPinned(msg.providerId ?? null);
204
+ // Reattachment on a fresh connection: enter streaming mode so deltas
205
+ // append to a live assistant message instead of vanishing.
206
+ const chat = current.buffers[frameSessionId];
207
+ if (chat && !chat.isStreaming && chat.messages.length === 0) {
208
+ current.startAssistantMessage(frameSessionId);
209
+ }
210
+ }
211
+ break;
212
+ }
213
+
214
+ case "session_history": {
215
+ const converted = msg.messages.map(convertHistoryMessage);
216
+ if (msg.append) {
217
+ state.appendMessages(key, converted);
218
+ } else {
219
+ // First (replacing) chunk = fresh authoritative transcript. Cancel any
220
+ // pending reconnect resync so we don't redundantly re-fetch it, and
221
+ // mark this session cold-resumed so idle-status handling doesn't loop.
222
+ state.setMessages(key, converted);
223
+ if (key !== null && key === resyncSessionId) resyncSessionId = null;
224
+ coldResumedSessionId = key;
225
+ }
226
+ break;
227
+ }
228
+
229
+ case "status": {
230
+ // Only finish streaming if this buffer actually started it
231
+ if (
232
+ (msg.status === "idle" || msg.status === "cancelled") &&
233
+ buffer()?.isStreaming
234
+ ) {
235
+ state.finishAssistantMessage(key);
236
+ }
237
+ // A session that finished while we were offline never delivers its
238
+ // `result` — the idle status on reconnect is the cue to resync. Keyed to
239
+ // THIS frame's session: a background session settling must not consume
240
+ // (or trigger) the active session's pending resync.
241
+ if (msg.status === "idle" || msg.status === "cancelled") {
242
+ const current = useChatStore.getState();
243
+ resyncIfNeeded(frameSessionId ?? current.activeSessionId);
244
+ // Cold load: we hold a stored session id but an empty transcript and
245
+ // the server is idle (so it sent no snapshot). Fetch the history.
246
+ // Only the active session's own (or an unscoped legacy) idle counts.
247
+ if (!frameSessionId || frameSessionId === current.activeSessionId) {
248
+ coldResumeIfNeeded(current.activeSessionId, activeChat(current).messages.length);
249
+ }
250
+ }
251
+ break;
252
+ }
253
+
254
+ case "error":
255
+ if (buffer()?.isStreaming) {
256
+ state.appendText(key, `\n\n**Error:** ${msg.message}`);
257
+ state.finishAssistantMessage(key);
258
+ }
259
+ break;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * The server asked the browser for its location (the agent called
265
+ * `get_current_location`). Read `navigator.geolocation` and reply over the same
266
+ * requestId. The browser shows its own permission prompt on first use; a denial,
267
+ * timeout, or unavailable position comes back as a `location_error` that the
268
+ * server turns into a tool error for the agent.
269
+ */
270
+ function requestBrowserLocation(msg: ServerLocationRequest) {
271
+ const { requestId } = msg;
272
+ if (typeof navigator === "undefined" || !navigator.geolocation) {
273
+ wsClient?.send({
274
+ type: "location_error",
275
+ requestId,
276
+ code: 0,
277
+ message: "Geolocation is not available in this browser.",
278
+ });
279
+ return;
280
+ }
281
+ const opts = msg.options ?? {};
282
+ navigator.geolocation.getCurrentPosition(
283
+ (pos) => {
284
+ wsClient?.send({
285
+ type: "location_response",
286
+ requestId,
287
+ coords: {
288
+ latitude: pos.coords.latitude,
289
+ longitude: pos.coords.longitude,
290
+ accuracy: pos.coords.accuracy,
291
+ altitude: pos.coords.altitude,
292
+ altitudeAccuracy: pos.coords.altitudeAccuracy,
293
+ heading: pos.coords.heading,
294
+ speed: pos.coords.speed,
295
+ },
296
+ timestamp: pos.timestamp,
297
+ });
298
+ },
299
+ (err) => {
300
+ wsClient?.send({
301
+ type: "location_error",
302
+ requestId,
303
+ code: err.code,
304
+ message: err.message || "Failed to retrieve location.",
305
+ });
306
+ },
307
+ {
308
+ enableHighAccuracy: opts.enableHighAccuracy ?? false,
309
+ timeout: opts.timeoutMs ?? 15000,
310
+ maximumAge: opts.maximumAgeMs ?? 60000,
311
+ }
312
+ );
313
+ }
314
+
315
+ // Reconnection bookkeeping: after a WS drop, the rendered transcript may have
316
+ // gaps (deltas streamed while offline are lost). We heal by replaying the
317
+ // authoritative session history — immediately if nothing is streaming, or
318
+ // once the in-flight turn settles (result / idle status). The pending resync
319
+ // is keyed to the session that needs healing (the one that was active at the
320
+ // reconnect), so a background session settling first cannot consume it.
321
+ let wasDisconnected = false;
322
+ let resyncSessionId: string | null = null;
323
+
324
+ function resyncIfNeeded(sessionId: string | null) {
325
+ if (!resyncSessionId || !sessionId || sessionId !== resyncSessionId) return;
326
+ resyncSessionId = null;
327
+ wsClient?.send({ type: "session_resume", sessionId });
328
+ }
329
+
330
+ // A cold page / PWA relaunch restores only the stored session id, not the
331
+ // transcript. The server pushes a snapshot on connect only while a session is
332
+ // actively running; when it's idle (the common "agent finished while the phone
333
+ // slept" case) nothing arrives, so the client must fetch the finished
334
+ // session's history itself. Guarded to fire at most once per session id per
335
+ // connection — an empty session would otherwise re-trigger on every idle
336
+ // status and loop.
337
+ let coldResumedSessionId: string | null = null;
338
+
339
+ function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
340
+ if (!sessionId || messageCount > 0) return;
341
+ if (coldResumedSessionId === sessionId) return;
342
+ coldResumedSessionId = sessionId;
343
+ wsClient?.send({ type: "session_resume", sessionId });
344
+ }
345
+
346
+ function handleStatusChange(status: "connecting" | "connected" | "disconnected") {
347
+ useConnectionStore.getState().setWsStatus(status);
348
+
349
+ if (status === "disconnected") {
350
+ wasDisconnected = true;
351
+ // Allow a fresh cold-resume attempt after we reconnect.
352
+ coldResumedSessionId = null;
353
+ } else if (status === "connected" && wasDisconnected) {
354
+ wasDisconnected = false;
355
+ const chat = useChatStore.getState();
356
+ const active = activeChat(chat);
357
+ if (chat.activeSessionId && active.messages.length > 0) {
358
+ resyncSessionId = chat.activeSessionId;
359
+ // Not mid-stream: replay right away. Mid-stream: the flag holds until
360
+ // the running turn finishes (result or idle status).
361
+ if (!active.isStreaming) {
362
+ resyncIfNeeded(chat.activeSessionId);
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ // Singleton client - survives React re-renders
369
+ let wsClient: WSClient | null = null;
370
+
371
+ export function useWebSocket() {
372
+ const initialized = useRef(false);
373
+
374
+ useEffect(() => {
375
+ if (initialized.current) return;
376
+ initialized.current = true;
377
+
378
+ wsClient = new WSClient(getWsUrl(), handleServerMessage, handleStatusChange);
379
+ wsClient.connect();
380
+
381
+ // Skip the exponential backoff when the network demonstrably returns.
382
+ const reconnectNow = () => wsClient?.reconnectNow();
383
+ const onVisible = () => {
384
+ if (document.visibilityState === "visible") wsClient?.reconnectNow();
385
+ };
386
+ window.addEventListener("online", reconnectNow);
387
+ document.addEventListener("visibilitychange", onVisible);
388
+
389
+ return () => {
390
+ window.removeEventListener("online", reconnectNow);
391
+ document.removeEventListener("visibilitychange", onVisible);
392
+ wsClient?.close();
393
+ wsClient = null;
394
+ initialized.current = false;
395
+ };
396
+ }, []);
397
+
398
+ const send = useCallback((msg: ClientMessage) => {
399
+ wsClient?.send(msg);
400
+ }, []);
401
+
402
+ return { send };
403
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @schlessera/brain-ui-react — the chat UI as a component library.
3
+ *
4
+ * The deployment shell owns the document: index.html, the mount point, the
5
+ * Vite/PWA build, the service worker, and the theme entry (import ./theme.css
6
+ * into a Tailwind v4 build together with an @source pointing at this package's
7
+ * src, or use the precompiled ./styles.css export). The shell composes the
8
+ * pieces below and calls configureBrainUi() before mounting.
9
+ */
10
+
11
+ // Branding / copy configuration (module singleton, set once at boot).
12
+ export { configureBrainUi, uiConfig, type BrainUiConfig } from "./config.js";
13
+
14
+ // Top-level surfaces the shell composes.
15
+ export { ConnectionGate } from "./components/connectivity/connection-gate.js";
16
+ export { AppShell } from "./components/layout/app-shell.js";
17
+ export { ChatPage } from "./components/chat/chat-page.js";
18
+
19
+ // Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
20
+ export { BrainMarkdown } from "./components/chat/brain-markdown.js";
21
+
22
+ // Stores + selectors the shell reads (service-worker busy check, deep links).
23
+ export {
24
+ useChatStore,
25
+ activeChat,
26
+ anyStreaming,
27
+ type ChatKey,
28
+ type ChatMessage,
29
+ type SessionChat,
30
+ type ToolCall,
31
+ type AskUserExchange,
32
+ type MessageAttachment,
33
+ } from "./stores/chat-store.js";
34
+ export { useFileStore } from "./stores/file-store.js";
35
+ export { useUIStore } from "./stores/ui-store.js";
36
+ export { useConnectionStore } from "./stores/connection-store.js";
37
+ export { useProviderStore } from "./stores/provider-store.js";
38
+ export { useVoiceStore } from "./voice/voice-store.js";
39
+
40
+ // Connectivity probes.
41
+ export { useVpnStatus } from "./hooks/use-vpn-status.js";
42
+ export { useWebSocket, handleServerMessage, runStateForFrame } from "./hooks/use-websocket.js";
43
+
44
+ // API surface (typed REST client + backend URL helpers).
45
+ export { api } from "./lib/api-client.js";
46
+ export { API_BASE, getWsUrl, getBackendUrl } from "./lib/backend.js";