@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,105 @@
1
+ import { Brain, Newspaper, Search, BarChart3 } from "lucide-react";
2
+ import { motion } from "framer-motion";
3
+
4
+ export function WelcomeState({
5
+ onAction,
6
+ }: {
7
+ onAction: (action: string) => void;
8
+ }) {
9
+ return (
10
+ <div className="flex min-h-full items-center justify-center px-4 py-8">
11
+ <div className="flex flex-col items-center gap-8 text-center">
12
+ {/* Brain icon with breathe animation */}
13
+ <motion.div
14
+ initial={{ opacity: 0, y: 8 }}
15
+ animate={{ opacity: 1, y: 0 }}
16
+ transition={{ duration: 0.4, ease: "easeOut" }}
17
+ >
18
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface">
19
+ <Brain className="h-8 w-8 text-primary" style={{ animation: "breathe 3s ease-in-out infinite" }} />
20
+ </div>
21
+ </motion.div>
22
+
23
+ {/* Headline */}
24
+ <motion.h1
25
+ className="font-[family-name:var(--font-display)] text-3xl text-foreground"
26
+ initial={{ opacity: 0, y: 8 }}
27
+ animate={{ opacity: 1, y: 0 }}
28
+ transition={{ duration: 0.4, delay: 0.1, ease: "easeOut" }}
29
+ >
30
+ What do you need to know?
31
+ </motion.h1>
32
+
33
+ {/* Suggestion cards */}
34
+ <motion.div
35
+ className="flex flex-col sm:flex-row gap-3"
36
+ initial={{ opacity: 0, y: 8 }}
37
+ animate={{ opacity: 1, y: 0 }}
38
+ transition={{ duration: 0.4, delay: 0.2, ease: "easeOut" }}
39
+ >
40
+ <SuggestionCard
41
+ icon={Newspaper}
42
+ label="What's new?"
43
+ description="Daily briefing"
44
+ onClick={() => onAction("whatsup")}
45
+ delay={0}
46
+ />
47
+ <SuggestionCard
48
+ icon={Search}
49
+ label="Search..."
50
+ description="Find anything"
51
+ onClick={() => onAction("search")}
52
+ delay={0.05}
53
+ />
54
+ <SuggestionCard
55
+ icon={BarChart3}
56
+ label="Brain stats"
57
+ description="Overview"
58
+ onClick={() => onAction("stats")}
59
+ delay={0.1}
60
+ />
61
+ </motion.div>
62
+
63
+ {/* Keyboard hints */}
64
+ <motion.div
65
+ className="text-xs text-muted-foreground/40"
66
+ initial={{ opacity: 0 }}
67
+ animate={{ opacity: 1 }}
68
+ transition={{ duration: 0.4, delay: 0.4 }}
69
+ >
70
+ <span className="font-mono">/</span> for commands
71
+ </motion.div>
72
+ </div>
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function SuggestionCard({
78
+ icon: Icon,
79
+ label,
80
+ description,
81
+ onClick,
82
+ delay,
83
+ }: {
84
+ icon: typeof Brain;
85
+ label: string;
86
+ description: string;
87
+ onClick: () => void;
88
+ delay: number;
89
+ }) {
90
+ return (
91
+ <motion.button
92
+ onClick={onClick}
93
+ className="flex w-44 flex-col items-center gap-2 rounded-xl border border-border bg-surface p-4 text-center transition-all duration-200 hover:border-primary/40 hover:bg-surface-raised"
94
+ initial={{ opacity: 0, y: 8 }}
95
+ animate={{ opacity: 1, y: 0 }}
96
+ transition={{ duration: 0.3, delay: 0.25 + delay, ease: "easeOut" }}
97
+ >
98
+ <Icon className="h-5 w-5 text-primary" />
99
+ <div>
100
+ <div className="text-sm font-medium text-foreground">{label}</div>
101
+ <div className="text-[11px] text-muted-foreground">{description}</div>
102
+ </div>
103
+ </motion.button>
104
+ );
105
+ }
@@ -0,0 +1,137 @@
1
+ import { Brain, WifiOff, ShieldAlert } from "lucide-react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import { useConnectionStore } from "../../stores/connection-store.js";
4
+ import { useEffect, useState, type ReactNode } from "react";
5
+ import { LoginScreen } from "./login-screen.js";
6
+ import { useVpnStatus } from "../../hooks/use-vpn-status.js";
7
+
8
+ export function ConnectionGate({ children }: { children: ReactNode }) {
9
+ // The gate owns its connectivity probe — composing <ConnectionGate> is all
10
+ // an embedder needs; the store would otherwise sit on "checking" forever.
11
+ useVpnStatus();
12
+ const vpnStatus = useConnectionStore((s) => s.vpnStatus);
13
+
14
+ // Once the app has connected, never unmount the UI again — an intermittent
15
+ // drop must not destroy rendered chat state. Show a banner instead.
16
+ const [everConnected, setEverConnected] = useState(false);
17
+ useEffect(() => {
18
+ if (vpnStatus === "connected") setEverConnected(true);
19
+ }, [vpnStatus]);
20
+
21
+ // Auth required (password mode) and no valid session: show the login screen,
22
+ // even if we were connected before (an expired session must re-prompt).
23
+ if (vpnStatus === "unauthorized") {
24
+ return <LoginScreen />;
25
+ }
26
+
27
+ if (vpnStatus === "connected" || everConnected) {
28
+ return (
29
+ <>
30
+ <OfflineBanner
31
+ show={everConnected && vpnStatus !== "connected"}
32
+ forbidden={vpnStatus === "forbidden"}
33
+ />
34
+ {children}
35
+ </>
36
+ );
37
+ }
38
+
39
+ return (
40
+ <div className="flex h-[100dvh] items-center justify-center bg-background">
41
+ <AnimatePresence mode="wait">
42
+ <motion.div
43
+ key={vpnStatus}
44
+ initial={{ opacity: 0, y: 8 }}
45
+ animate={{ opacity: 1, y: 0 }}
46
+ exit={{ opacity: 0, y: -8 }}
47
+ transition={{ duration: 0.3 }}
48
+ className="flex flex-col items-center gap-6 text-center px-6"
49
+ >
50
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface">
51
+ <Brain
52
+ className="h-8 w-8 text-primary"
53
+ style={
54
+ vpnStatus === "checking"
55
+ ? { animation: "breathe 3s ease-in-out infinite" }
56
+ : undefined
57
+ }
58
+ />
59
+ </div>
60
+
61
+ {vpnStatus === "checking" && (
62
+ <p className="text-sm text-muted-foreground">Connecting...</p>
63
+ )}
64
+
65
+ {vpnStatus === "forbidden" && (
66
+ <>
67
+ <h1 className="font-[family-name:var(--font-display)] text-2xl text-foreground">
68
+ VPN Required
69
+ </h1>
70
+ <p className="text-sm text-muted-foreground max-w-xs">
71
+ Connect to the Tailscale VPN to access Brain.
72
+ </p>
73
+ </>
74
+ )}
75
+
76
+ {vpnStatus === "unreachable" && (
77
+ <>
78
+ <h1 className="font-[family-name:var(--font-display)] text-2xl text-foreground">
79
+ Unable to reach server
80
+ </h1>
81
+ <p className="text-sm text-muted-foreground max-w-xs">
82
+ Check your connection and try again. Retrying automatically...
83
+ </p>
84
+ </>
85
+ )}
86
+ </motion.div>
87
+ </AnimatePresence>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Slim overlay banner for connection drops after the app is up. The UI below
94
+ * stays mounted and interactive; reconnection is automatic (VPN poll + WS
95
+ * backoff both tighten while offline).
96
+ */
97
+ function OfflineBanner({
98
+ show,
99
+ forbidden,
100
+ }: {
101
+ show: boolean;
102
+ forbidden: boolean;
103
+ }) {
104
+ return (
105
+ <AnimatePresence>
106
+ {show && (
107
+ <motion.div
108
+ initial={{ opacity: 0, y: -24 }}
109
+ animate={{ opacity: 1, y: 0 }}
110
+ exit={{ opacity: 0, y: -24 }}
111
+ transition={{ duration: 0.2 }}
112
+ className="pointer-events-none fixed inset-x-0 top-0 z-[60] flex justify-center px-4 pt-3"
113
+ >
114
+ <div
115
+ role="status"
116
+ className="flex items-center gap-2 rounded-full border border-amber-500/30 bg-amber-950/90 px-4 py-1.5 text-xs text-amber-200 shadow-lg backdrop-blur"
117
+ >
118
+ {forbidden ? (
119
+ <ShieldAlert className="h-3.5 w-3.5" />
120
+ ) : (
121
+ <WifiOff className="h-3.5 w-3.5" />
122
+ )}
123
+ <span>
124
+ {forbidden
125
+ ? "VPN required — reconnect Tailscale"
126
+ : "Connection lost — reconnecting…"}
127
+ </span>
128
+ <span
129
+ className="inline-block h-1.5 w-1.5 rounded-full bg-amber-400"
130
+ style={{ animation: "breathe 2s ease-in-out infinite" }}
131
+ />
132
+ </div>
133
+ </motion.div>
134
+ )}
135
+ </AnimatePresence>
136
+ );
137
+ }
@@ -0,0 +1,169 @@
1
+ import { uiConfig } from "../../config.js";
2
+ import { useEffect, useRef, useState, type FormEvent } from "react";
3
+ import { Brain, Fingerprint, Lock } from "lucide-react";
4
+ import { motion } from "framer-motion";
5
+ import { api } from "../../lib/api-client.js";
6
+ import {
7
+ isUserCancel,
8
+ loginWithPasskey,
9
+ supportsAutofill,
10
+ supportsPasskeys,
11
+ } from "../../lib/passkeys.js";
12
+
13
+ /**
14
+ * Login, shown when the server is in `password` auth mode and no valid session
15
+ * cookie is present (the /api/vpn-check probe returns 401). Password is always
16
+ * offered; a passkey button appears when the server reports registered
17
+ * passkeys and the browser supports WebAuthn. On success the browser has the
18
+ * HttpOnly session cookie; a reload boots the authenticated app.
19
+ */
20
+ export function LoginScreen() {
21
+ const [password, setPassword] = useState("");
22
+ const [error, setError] = useState<string | null>(null);
23
+ const [submitting, setSubmitting] = useState(false);
24
+ const [passkeyAvailable, setPasskeyAvailable] = useState(false);
25
+ // Fail-open: if the methods probe fails, keep the password form — the
26
+ // server enforces the real policy, and hiding the only working method on a
27
+ // transient error would be a self-inflicted lockout.
28
+ const [passwordAvailable, setPasswordAvailable] = useState(true);
29
+ const conditionalStarted = useRef(false);
30
+
31
+ useEffect(() => {
32
+ let cancelled = false;
33
+ api
34
+ .authMethods()
35
+ .then(async (methods) => {
36
+ if (cancelled) return;
37
+ if (methods.passkey && !methods.password) setPasswordAvailable(false);
38
+ if (!methods.passkey || !supportsPasskeys()) return;
39
+ setPasskeyAvailable(true);
40
+ // Conditional UI (progressive enhancement): surface passkeys in the
41
+ // password input's autofill. Runs once; a user cancel is not an error.
42
+ if (conditionalStarted.current || !(await supportsAutofill())) return;
43
+ conditionalStarted.current = true;
44
+ try {
45
+ await loginWithPasskey({ useBrowserAutofill: true });
46
+ window.location.reload();
47
+ } catch (err) {
48
+ if (!isUserCancel(err)) {
49
+ console.warn("[passkeys] conditional UI failed:", err);
50
+ }
51
+ }
52
+ })
53
+ .catch(() => {
54
+ // Methods probe failing degrades to password-only — unchanged behavior.
55
+ });
56
+ return () => {
57
+ cancelled = true;
58
+ };
59
+ }, []);
60
+
61
+ async function onSubmit(e: FormEvent) {
62
+ e.preventDefault();
63
+ if (!password || submitting) return;
64
+ setSubmitting(true);
65
+ setError(null);
66
+ try {
67
+ await api.login(password);
68
+ // Cookie is set; reload so the app boots authenticated.
69
+ window.location.reload();
70
+ } catch (err) {
71
+ setError(err instanceof Error ? err.message : "Login failed");
72
+ setSubmitting(false);
73
+ }
74
+ }
75
+
76
+ async function onPasskeyClick() {
77
+ if (submitting) return;
78
+ setSubmitting(true);
79
+ setError(null);
80
+ try {
81
+ await loginWithPasskey();
82
+ window.location.reload();
83
+ } catch (err) {
84
+ if (!isUserCancel(err)) {
85
+ setError(err instanceof Error ? err.message : "Passkey login failed");
86
+ }
87
+ setSubmitting(false);
88
+ }
89
+ }
90
+
91
+ return (
92
+ <div className="flex h-[100dvh] items-center justify-center bg-background">
93
+ <motion.div
94
+ initial={{ opacity: 0, y: 8 }}
95
+ animate={{ opacity: 1, y: 0 }}
96
+ transition={{ duration: 0.3 }}
97
+ className="flex w-full max-w-xs flex-col items-center gap-6 px-6 text-center"
98
+ >
99
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface">
100
+ <Brain className="h-8 w-8 text-primary" />
101
+ </div>
102
+ <div>
103
+ <h1 className="font-[family-name:var(--font-display)] text-2xl text-foreground">
104
+ {uiConfig.appName}
105
+ </h1>
106
+ <p className="mt-1 text-sm text-muted-foreground">
107
+ {!passwordAvailable
108
+ ? "Sign in with your passkey."
109
+ : passkeyAvailable
110
+ ? "Use your passkey or enter the password."
111
+ : "Enter the password to continue."}
112
+ </p>
113
+ </div>
114
+
115
+ <form onSubmit={onSubmit} className="flex w-full flex-col gap-3">
116
+ {passwordAvailable && (
117
+ <div className="relative">
118
+ <Lock className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground/60" />
119
+ <input
120
+ type="password"
121
+ autoFocus
122
+ autoComplete="current-password webauthn"
123
+ value={password}
124
+ onChange={(e) => setPassword(e.target.value)}
125
+ placeholder="Password"
126
+ className="w-full rounded-lg border border-border-subtle bg-surface py-2 pl-9 pr-3 text-sm text-foreground outline-none transition-colors focus:border-primary"
127
+ />
128
+ </div>
129
+ )}
130
+ {error && (
131
+ <p role="alert" className="text-xs text-destructive">
132
+ {error}
133
+ </p>
134
+ )}
135
+ {passwordAvailable && (
136
+ <button
137
+ type="submit"
138
+ disabled={submitting || !password}
139
+ className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
140
+ >
141
+ {submitting ? "Signing in…" : "Sign in"}
142
+ </button>
143
+ )}
144
+ {passkeyAvailable && (
145
+ <button
146
+ type="button"
147
+ onClick={onPasskeyClick}
148
+ disabled={submitting}
149
+ className={
150
+ passwordAvailable
151
+ ? "flex items-center justify-center gap-2 rounded-lg border border-border-subtle bg-surface px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-primary disabled:opacity-50"
152
+ : "flex items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:brightness-110 disabled:opacity-50"
153
+ }
154
+ >
155
+ <Fingerprint className="h-4 w-4" />
156
+ Sign in with a passkey
157
+ </button>
158
+ )}
159
+ {!passwordAvailable && !passkeyAvailable && (
160
+ <p className="text-xs text-muted-foreground">
161
+ Password login is disabled. Use a device or browser that supports
162
+ passkeys.
163
+ </p>
164
+ )}
165
+ </form>
166
+ </motion.div>
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,116 @@
1
+ import { useEffect } from "react";
2
+ import { ChevronDown, ChevronRight, X } from "lucide-react";
3
+ import { useFileStore } from "../../stores/file-store.js";
4
+ import { FileTree } from "./file-tree.js";
5
+ import { FileViewer } from "./file-viewer.js";
6
+ import { cn } from "../../lib/utils.js";
7
+
8
+ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => void }) {
9
+ const currentPath = useFileStore((s) => s.currentPath);
10
+ const treeExpanded = useFileStore((s) => s.treeExpanded);
11
+ const setTreeExpanded = useFileStore((s) => s.setTreeExpanded);
12
+ const closeFile = useFileStore((s) => s.closeFile);
13
+
14
+ useEffect(() => {
15
+ if (!open) return;
16
+ const handler = (e: KeyboardEvent) => {
17
+ if (e.key === "Escape") onClose();
18
+ };
19
+ document.addEventListener("keydown", handler);
20
+ return () => document.removeEventListener("keydown", handler);
21
+ }, [open, onClose]);
22
+
23
+ // Sync hash route to current file. When the panel closes, strip the
24
+ // `#/files*` segment entirely so a reload doesn't reopen it.
25
+ useEffect(() => {
26
+ if (!open) {
27
+ if (window.location.hash.startsWith("#/files")) {
28
+ // Replace with the pathname (no hash) so reload lands on the chat.
29
+ history.replaceState(null, "", window.location.pathname + window.location.search);
30
+ }
31
+ return;
32
+ }
33
+ if (currentPath) {
34
+ const target = `#/files/${currentPath}`;
35
+ if (window.location.hash !== target) {
36
+ history.replaceState(null, "", target);
37
+ }
38
+ } else if (window.location.hash.startsWith("#/files")) {
39
+ history.replaceState(null, "", "#/files");
40
+ }
41
+ }, [open, currentPath]);
42
+
43
+ const showTree = !currentPath || treeExpanded;
44
+
45
+ return (
46
+ <>
47
+ {open && (
48
+ <div
49
+ className="fixed inset-0 z-40 bg-black/40 transition-opacity md:bg-black/20"
50
+ onClick={onClose}
51
+ />
52
+ )}
53
+
54
+ <div
55
+ className={cn(
56
+ "fixed right-0 top-0 z-50 flex h-full w-full flex-col border-l border-border bg-surface shadow-[0_16px_48px_rgba(0,0,0,0.5)] md:w-[560px]",
57
+ "transform transition-transform duration-300 ease-out",
58
+ open ? "translate-x-0" : "translate-x-full"
59
+ )}
60
+ >
61
+ {/* Header */}
62
+ <div className="flex items-center justify-between border-b border-border px-5 py-4">
63
+ <h2 className="font-[family-name:var(--font-display)] text-lg text-foreground">
64
+ Files
65
+ </h2>
66
+ <button
67
+ onClick={onClose}
68
+ className="rounded-lg p-2.5 md:p-1.5 text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
69
+ >
70
+ <X className="h-5 w-5 md:h-4 md:w-4" />
71
+ </button>
72
+ </div>
73
+
74
+ <div className="flex flex-1 flex-col overflow-hidden">
75
+ {/* Tree toggle strip — visible when a file is open */}
76
+ {currentPath && (
77
+ <div className="flex items-center gap-1 border-b border-border bg-surface-raised/40 px-3 py-1.5">
78
+ <button
79
+ onClick={() => setTreeExpanded(!treeExpanded)}
80
+ className="flex items-center gap-1.5 rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
81
+ >
82
+ {treeExpanded ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
83
+ {treeExpanded ? "Hide tree" : "Show tree"}
84
+ </button>
85
+ <button
86
+ onClick={closeFile}
87
+ className="ml-auto rounded px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-surface-raised hover:text-foreground"
88
+ >
89
+ Close file
90
+ </button>
91
+ </div>
92
+ )}
93
+
94
+ {/* Tree area */}
95
+ {showTree && (
96
+ <div
97
+ className={cn(
98
+ "overflow-y-auto",
99
+ currentPath ? "max-h-[45vh] border-b border-border" : "flex-1"
100
+ )}
101
+ >
102
+ <FileTree />
103
+ </div>
104
+ )}
105
+
106
+ {/* Viewer area */}
107
+ {currentPath && (
108
+ <div className="flex-1 overflow-hidden">
109
+ <FileViewer />
110
+ </div>
111
+ )}
112
+ </div>
113
+ </div>
114
+ </>
115
+ );
116
+ }
@@ -0,0 +1,143 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { ChevronRight, Folder, FolderOpen, FileText, FileCode, FileImage, File as FileIcon, Loader2, AlertCircle } from "lucide-react";
3
+ import { useFileStore } from "../../stores/file-store.js";
4
+ import type { FileEntry } from "@schlessera/brain-ui-sdk/protocol";
5
+ import { cn } from "../../lib/utils.js";
6
+
7
+ const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif", ".bmp", ".ico"]);
8
+ const CODE_EXT = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs", ".java", ".sh", ".bash", ".css", ".html", ".json", ".yaml", ".yml", ".toml", ".sql"]);
9
+
10
+ function iconForFile(name: string) {
11
+ const i = name.lastIndexOf(".");
12
+ const ext = i >= 0 ? name.slice(i).toLowerCase() : "";
13
+ if (ext === ".md" || ext === ".markdown" || ext === ".txt") return FileText;
14
+ if (IMAGE_EXT.has(ext)) return FileImage;
15
+ if (CODE_EXT.has(ext)) return FileCode;
16
+ return FileIcon;
17
+ }
18
+
19
+ export function FileTree() {
20
+ const loadDir = useFileStore((s) => s.loadDir);
21
+ const dirCache = useFileStore((s) => s.dirCache);
22
+ const dirErrors = useFileStore((s) => s.dirErrors);
23
+
24
+ useEffect(() => {
25
+ if (!dirCache[""]) {
26
+ void loadDir("");
27
+ }
28
+ }, [dirCache, loadDir]);
29
+
30
+ const rootEntries = dirCache[""];
31
+ const rootError = dirErrors[""];
32
+
33
+ if (rootError) {
34
+ return (
35
+ <div className="flex items-start gap-2 px-4 py-3 text-xs text-destructive">
36
+ <AlertCircle className="h-4 w-4 shrink-0" />
37
+ <span>{rootError}</span>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ if (!rootEntries) {
43
+ return (
44
+ <div className="flex items-center gap-2 px-4 py-3 text-xs text-muted-foreground">
45
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
46
+ Loading files...
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <ul className="py-1 text-sm">
53
+ {rootEntries.map((entry) => (
54
+ <TreeNode key={entry.path} entry={entry} depth={0} />
55
+ ))}
56
+ </ul>
57
+ );
58
+ }
59
+
60
+ function TreeNode({ entry, depth }: { entry: FileEntry; depth: number }) {
61
+ const expanded = useFileStore((s) => s.expandedDirs.has(entry.path));
62
+ const loading = useFileStore((s) => s.loadingDirs.has(entry.path));
63
+ const childEntries = useFileStore((s) => (entry.type === "dir" ? s.dirCache[entry.path] : undefined));
64
+ const childError = useFileStore((s) => (entry.type === "dir" ? s.dirErrors[entry.path] : undefined));
65
+ const toggleDir = useFileStore((s) => s.toggleDir);
66
+ const openFile = useFileStore((s) => s.openFile);
67
+ const isActive = useFileStore((s) => s.currentPath === entry.path);
68
+ const highlighted = useFileStore(
69
+ (s) => entry.type === "dir" && s.highlightedDir === entry.path
70
+ );
71
+ const setHighlightedDir = useFileStore((s) => s.setHighlightedDir);
72
+ const dirBtnRef = useRef<HTMLButtonElement>(null);
73
+
74
+ useEffect(() => {
75
+ if (!highlighted || !dirBtnRef.current) return;
76
+ dirBtnRef.current.scrollIntoView({ behavior: "smooth", block: "center" });
77
+ const t = setTimeout(() => setHighlightedDir(null), 1800);
78
+ return () => clearTimeout(t);
79
+ }, [highlighted, setHighlightedDir]);
80
+
81
+ const indent = { paddingLeft: depth * 14 + 8 };
82
+
83
+ if (entry.type === "dir") {
84
+ return (
85
+ <li>
86
+ <button
87
+ ref={dirBtnRef}
88
+ onClick={() => void toggleDir(entry.path)}
89
+ className={cn(
90
+ "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-foreground transition-colors hover:bg-surface-raised",
91
+ highlighted && "brain-tree-highlight"
92
+ )}
93
+ style={indent}
94
+ aria-expanded={expanded}
95
+ >
96
+ <ChevronRight
97
+ className={cn(
98
+ "h-3 w-3 shrink-0 text-muted-foreground transition-transform",
99
+ expanded && "rotate-90"
100
+ )}
101
+ />
102
+ {expanded ? (
103
+ <FolderOpen className="h-4 w-4 shrink-0 text-primary/80" />
104
+ ) : (
105
+ <Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
106
+ )}
107
+ <span className="truncate">{entry.name}</span>
108
+ {loading && <Loader2 className="ml-auto h-3 w-3 animate-spin text-muted-foreground" />}
109
+ </button>
110
+ {expanded && (
111
+ <ul>
112
+ {childError && (
113
+ <li className="flex items-center gap-1.5 px-2 py-1 text-xs text-destructive" style={{ paddingLeft: (depth + 1) * 14 + 8 }}>
114
+ <AlertCircle className="h-3 w-3" /> {childError}
115
+ </li>
116
+ )}
117
+ {childEntries?.map((child) => (
118
+ <TreeNode key={child.path} entry={child} depth={depth + 1} />
119
+ ))}
120
+ </ul>
121
+ )}
122
+ </li>
123
+ );
124
+ }
125
+
126
+ const Icon = iconForFile(entry.name);
127
+ return (
128
+ <li>
129
+ <button
130
+ onClick={() => void openFile(entry.path)}
131
+ className={cn(
132
+ "flex w-full items-center gap-1.5 rounded px-2 py-1 text-left transition-colors hover:bg-surface-raised",
133
+ isActive ? "bg-surface-raised text-primary" : "text-foreground/90"
134
+ )}
135
+ style={indent}
136
+ >
137
+ <span className="h-3 w-3 shrink-0" />
138
+ <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
139
+ <span className="truncate">{entry.name}</span>
140
+ </button>
141
+ </li>
142
+ );
143
+ }