@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/components/chat/ask-user-card.d.ts +12 -0
- package/dist/components/chat/ask-user-card.d.ts.map +1 -0
- package/dist/components/chat/ask-user-card.js +185 -0
- package/dist/components/chat/ask-user-card.js.map +1 -0
- package/dist/components/chat/brain-markdown.d.ts +35 -0
- package/dist/components/chat/brain-markdown.d.ts.map +1 -0
- package/dist/components/chat/brain-markdown.js +363 -0
- package/dist/components/chat/brain-markdown.js.map +1 -0
- package/dist/components/chat/chat-page.d.ts +2 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -0
- package/dist/components/chat/chat-page.js +442 -0
- package/dist/components/chat/chat-page.js.map +1 -0
- package/dist/components/chat/command-palette.d.ts +5 -0
- package/dist/components/chat/command-palette.d.ts.map +1 -0
- package/dist/components/chat/command-palette.js +18 -0
- package/dist/components/chat/command-palette.js.map +1 -0
- package/dist/components/chat/markdown-content.d.ts +4 -0
- package/dist/components/chat/markdown-content.d.ts.map +1 -0
- package/dist/components/chat/markdown-content.js +6 -0
- package/dist/components/chat/markdown-content.js.map +1 -0
- package/dist/components/chat/message-bubble.d.ts +9 -0
- package/dist/components/chat/message-bubble.d.ts.map +1 -0
- package/dist/components/chat/message-bubble.js +129 -0
- package/dist/components/chat/message-bubble.js.map +1 -0
- package/dist/components/chat/message-share.d.ts +10 -0
- package/dist/components/chat/message-share.d.ts.map +1 -0
- package/dist/components/chat/message-share.js +65 -0
- package/dist/components/chat/message-share.js.map +1 -0
- package/dist/components/chat/renderers/claude-tools.d.ts +3 -0
- package/dist/components/chat/renderers/claude-tools.d.ts.map +1 -0
- package/dist/components/chat/renderers/claude-tools.js +38 -0
- package/dist/components/chat/renderers/claude-tools.js.map +1 -0
- package/dist/components/chat/renderers/generic.d.ts +9 -0
- package/dist/components/chat/renderers/generic.d.ts.map +1 -0
- package/dist/components/chat/renderers/generic.js +68 -0
- package/dist/components/chat/renderers/generic.js.map +1 -0
- package/dist/components/chat/renderers/index.d.ts +5 -0
- package/dist/components/chat/renderers/index.d.ts.map +1 -0
- package/dist/components/chat/renderers/index.js +17 -0
- package/dist/components/chat/renderers/index.js.map +1 -0
- package/dist/components/chat/risk-hints.d.ts +20 -0
- package/dist/components/chat/risk-hints.d.ts.map +1 -0
- package/dist/components/chat/risk-hints.js +81 -0
- package/dist/components/chat/risk-hints.js.map +1 -0
- package/dist/components/chat/session-drawer.d.ts +6 -0
- package/dist/components/chat/session-drawer.d.ts.map +1 -0
- package/dist/components/chat/session-drawer.js +96 -0
- package/dist/components/chat/session-drawer.js.map +1 -0
- package/dist/components/chat/share-block.d.ts +9 -0
- package/dist/components/chat/share-block.d.ts.map +1 -0
- package/dist/components/chat/share-block.js +70 -0
- package/dist/components/chat/share-block.js.map +1 -0
- package/dist/components/chat/tool-call-timeline.d.ts +8 -0
- package/dist/components/chat/tool-call-timeline.d.ts.map +1 -0
- package/dist/components/chat/tool-call-timeline.js +100 -0
- package/dist/components/chat/tool-call-timeline.js.map +1 -0
- package/dist/components/chat/tool-views.d.ts +116 -0
- package/dist/components/chat/tool-views.d.ts.map +1 -0
- package/dist/components/chat/tool-views.js +628 -0
- package/dist/components/chat/tool-views.js.map +1 -0
- package/dist/components/chat/welcome-state.d.ts +4 -0
- package/dist/components/chat/welcome-state.d.ts.map +1 -0
- package/dist/components/chat/welcome-state.js +10 -0
- package/dist/components/chat/welcome-state.js.map +1 -0
- package/dist/components/connectivity/connection-gate.d.ts +5 -0
- package/dist/components/connectivity/connection-gate.d.ts.map +1 -0
- package/dist/components/connectivity/connection-gate.js +42 -0
- package/dist/components/connectivity/connection-gate.js.map +1 -0
- package/dist/components/connectivity/login-screen.d.ts +9 -0
- package/dist/components/connectivity/login-screen.d.ts.map +1 -0
- package/dist/components/connectivity/login-screen.js +99 -0
- package/dist/components/connectivity/login-screen.js.map +1 -0
- package/dist/components/files/file-panel.d.ts +5 -0
- package/dist/components/files/file-panel.d.ts.map +1 -0
- package/dist/components/files/file-panel.js +46 -0
- package/dist/components/files/file-panel.js.map +1 -0
- package/dist/components/files/file-tree.d.ts +2 -0
- package/dist/components/files/file-tree.d.ts.map +1 -0
- package/dist/components/files/file-tree.js +63 -0
- package/dist/components/files/file-tree.js.map +1 -0
- package/dist/components/files/file-viewer-binary.d.ts +5 -0
- package/dist/components/files/file-viewer-binary.d.ts.map +1 -0
- package/dist/components/files/file-viewer-binary.js +20 -0
- package/dist/components/files/file-viewer-binary.js.map +1 -0
- package/dist/components/files/file-viewer-html.d.ts +4 -0
- package/dist/components/files/file-viewer-html.d.ts.map +1 -0
- package/dist/components/files/file-viewer-html.js +5 -0
- package/dist/components/files/file-viewer-html.js.map +1 -0
- package/dist/components/files/file-viewer-markdown.d.ts +4 -0
- package/dist/components/files/file-viewer-markdown.d.ts.map +1 -0
- package/dist/components/files/file-viewer-markdown.js +10 -0
- package/dist/components/files/file-viewer-markdown.js.map +1 -0
- package/dist/components/files/file-viewer-raw.d.ts +5 -0
- package/dist/components/files/file-viewer-raw.d.ts.map +1 -0
- package/dist/components/files/file-viewer-raw.js +15 -0
- package/dist/components/files/file-viewer-raw.js.map +1 -0
- package/dist/components/files/file-viewer.d.ts +2 -0
- package/dist/components/files/file-viewer.d.ts.map +1 -0
- package/dist/components/files/file-viewer.js +175 -0
- package/dist/components/files/file-viewer.js.map +1 -0
- package/dist/components/files/frontmatter-panel.d.ts +5 -0
- package/dist/components/files/frontmatter-panel.d.ts.map +1 -0
- package/dist/components/files/frontmatter-panel.js +12 -0
- package/dist/components/files/frontmatter-panel.js.map +1 -0
- package/dist/components/layout/app-shell.d.ts +5 -0
- package/dist/components/layout/app-shell.d.ts.map +1 -0
- package/dist/components/layout/app-shell.js +10 -0
- package/dist/components/layout/app-shell.js.map +1 -0
- package/dist/components/layout/mobile-tab-bar.d.ts +2 -0
- package/dist/components/layout/mobile-tab-bar.d.ts.map +1 -0
- package/dist/components/layout/mobile-tab-bar.js +16 -0
- package/dist/components/layout/mobile-tab-bar.js.map +1 -0
- package/dist/components/layout/side-rail.d.ts +2 -0
- package/dist/components/layout/side-rail.d.ts.map +1 -0
- package/dist/components/layout/side-rail.js +26 -0
- package/dist/components/layout/side-rail.js.map +1 -0
- package/dist/components/layout/slide-panel.d.ts +9 -0
- package/dist/components/layout/slide-panel.d.ts.map +1 -0
- package/dist/components/layout/slide-panel.js +18 -0
- package/dist/components/layout/slide-panel.js.map +1 -0
- package/dist/components/quick-actions/streaming-modal.d.ts +8 -0
- package/dist/components/quick-actions/streaming-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/streaming-modal.js +92 -0
- package/dist/components/quick-actions/streaming-modal.js.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts +5 -0
- package/dist/components/quick-actions/whatsup-modal.d.ts.map +1 -0
- package/dist/components/quick-actions/whatsup-modal.js +82 -0
- package/dist/components/quick-actions/whatsup-modal.js.map +1 -0
- package/dist/components/settings/passkey-panel.d.ts +10 -0
- package/dist/components/settings/passkey-panel.d.ts.map +1 -0
- package/dist/components/settings/passkey-panel.js +118 -0
- package/dist/components/settings/passkey-panel.js.map +1 -0
- package/dist/components/share/share-menu.d.ts +23 -0
- package/dist/components/share/share-menu.d.ts.map +1 -0
- package/dist/components/share/share-menu.js +58 -0
- package/dist/components/share/share-menu.js.map +1 -0
- package/dist/components/voice/dictation-sheet.d.ts +6 -0
- package/dist/components/voice/dictation-sheet.d.ts.map +1 -0
- package/dist/components/voice/dictation-sheet.js +91 -0
- package/dist/components/voice/dictation-sheet.js.map +1 -0
- package/dist/components/voice/mic-button.d.ts +6 -0
- package/dist/components/voice/mic-button.d.ts.map +1 -0
- package/dist/components/voice/mic-button.js +9 -0
- package/dist/components/voice/mic-button.js.map +1 -0
- package/dist/components/voice/review-card.d.ts +8 -0
- package/dist/components/voice/review-card.d.ts.map +1 -0
- package/dist/components/voice/review-card.js +8 -0
- package/dist/components/voice/review-card.js.map +1 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +10 -0
- package/dist/config.js.map +1 -0
- package/dist/hooks/use-vpn-status.d.ts +2 -0
- package/dist/hooks/use-vpn-status.d.ts.map +1 -0
- package/dist/hooks/use-vpn-status.js +71 -0
- package/dist/hooks/use-vpn-status.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +8 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -0
- package/dist/hooks/use-websocket.js +366 -0
- package/dist/hooks/use-websocket.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +31 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/api-client.d.ts +115 -0
- package/dist/lib/api-client.d.ts.map +1 -0
- package/dist/lib/api-client.js +70 -0
- package/dist/lib/api-client.js.map +1 -0
- package/dist/lib/backend.d.ts +7 -0
- package/dist/lib/backend.d.ts.map +1 -0
- package/dist/lib/backend.js +33 -0
- package/dist/lib/backend.js.map +1 -0
- package/dist/lib/frontmatter.d.ts +21 -0
- package/dist/lib/frontmatter.d.ts.map +1 -0
- package/dist/lib/frontmatter.js +88 -0
- package/dist/lib/frontmatter.js.map +1 -0
- package/dist/lib/image-attachments.d.ts +36 -0
- package/dist/lib/image-attachments.d.ts.map +1 -0
- package/dist/lib/image-attachments.js +175 -0
- package/dist/lib/image-attachments.js.map +1 -0
- package/dist/lib/passkeys.d.ts +18 -0
- package/dist/lib/passkeys.d.ts.map +1 -0
- package/dist/lib/passkeys.js +36 -0
- package/dist/lib/passkeys.js.map +1 -0
- package/dist/lib/share.d.ts +53 -0
- package/dist/lib/share.d.ts.map +1 -0
- package/dist/lib/share.js +144 -0
- package/dist/lib/share.js.map +1 -0
- package/dist/lib/strip-markdown.d.ts +10 -0
- package/dist/lib/strip-markdown.d.ts.map +1 -0
- package/dist/lib/strip-markdown.js +37 -0
- package/dist/lib/strip-markdown.js.map +1 -0
- package/dist/lib/tool-names.d.ts +17 -0
- package/dist/lib/tool-names.d.ts.map +1 -0
- package/dist/lib/tool-names.js +23 -0
- package/dist/lib/tool-names.js.map +1 -0
- package/dist/lib/utils.d.ts +3 -0
- package/dist/lib/utils.d.ts.map +1 -0
- package/dist/lib/utils.js +6 -0
- package/dist/lib/utils.js.map +1 -0
- package/dist/lib/ws-client.d.ts +26 -0
- package/dist/lib/ws-client.d.ts.map +1 -0
- package/dist/lib/ws-client.js +93 -0
- package/dist/lib/ws-client.js.map +1 -0
- package/dist/stores/chat-store.d.ts +131 -0
- package/dist/stores/chat-store.d.ts.map +1 -0
- package/dist/stores/chat-store.js +376 -0
- package/dist/stores/chat-store.js.map +1 -0
- package/dist/stores/connection-store.d.ts +11 -0
- package/dist/stores/connection-store.d.ts.map +1 -0
- package/dist/stores/connection-store.js +8 -0
- package/dist/stores/connection-store.js.map +1 -0
- package/dist/stores/file-store.d.ts +53 -0
- package/dist/stores/file-store.d.ts.map +1 -0
- package/dist/stores/file-store.js +284 -0
- package/dist/stores/file-store.js.map +1 -0
- package/dist/stores/provider-store.d.ts +24 -0
- package/dist/stores/provider-store.d.ts.map +1 -0
- package/dist/stores/provider-store.js +56 -0
- package/dist/stores/provider-store.js.map +1 -0
- package/dist/stores/ui-store.d.ts +21 -0
- package/dist/stores/ui-store.d.ts.map +1 -0
- package/dist/stores/ui-store.js +23 -0
- package/dist/stores/ui-store.js.map +1 -0
- package/dist/styles.css +2 -0
- package/dist/voice/asr-clients.d.ts +2 -0
- package/dist/voice/asr-clients.d.ts.map +1 -0
- package/dist/voice/asr-clients.js +33 -0
- package/dist/voice/asr-clients.js.map +1 -0
- package/dist/voice/asr-deepgram.d.ts +38 -0
- package/dist/voice/asr-deepgram.d.ts.map +1 -0
- package/dist/voice/asr-deepgram.js +307 -0
- package/dist/voice/asr-deepgram.js.map +1 -0
- package/dist/voice/asr-webspeech.d.ts +23 -0
- package/dist/voice/asr-webspeech.d.ts.map +1 -0
- package/dist/voice/asr-webspeech.js +93 -0
- package/dist/voice/asr-webspeech.js.map +1 -0
- package/dist/voice/use-dictation.d.ts +6 -0
- package/dist/voice/use-dictation.d.ts.map +1 -0
- package/dist/voice/use-dictation.js +174 -0
- package/dist/voice/use-dictation.js.map +1 -0
- package/dist/voice/voice-store.d.ts +26 -0
- package/dist/voice/voice-store.d.ts.map +1 -0
- package/dist/voice/voice-store.js +27 -0
- package/dist/voice/voice-store.js.map +1 -0
- package/package.json +71 -0
- package/src/components/chat/ask-user-card.tsx +451 -0
- package/src/components/chat/brain-markdown.tsx +547 -0
- package/src/components/chat/chat-page.tsx +820 -0
- package/src/components/chat/command-palette.tsx +68 -0
- package/src/components/chat/markdown-content.tsx +5 -0
- package/src/components/chat/message-bubble.tsx +362 -0
- package/src/components/chat/message-share.ts +78 -0
- package/src/components/chat/renderers/claude-tools.tsx +53 -0
- package/src/components/chat/renderers/generic.tsx +85 -0
- package/src/components/chat/renderers/index.ts +19 -0
- package/src/components/chat/risk-hints.ts +95 -0
- package/src/components/chat/session-drawer.tsx +213 -0
- package/src/components/chat/share-block.tsx +128 -0
- package/src/components/chat/tool-call-timeline.tsx +322 -0
- package/src/components/chat/tool-views.tsx +898 -0
- package/src/components/chat/welcome-state.tsx +105 -0
- package/src/components/connectivity/connection-gate.tsx +137 -0
- package/src/components/connectivity/login-screen.tsx +169 -0
- package/src/components/files/file-panel.tsx +116 -0
- package/src/components/files/file-tree.tsx +143 -0
- package/src/components/files/file-viewer-binary.tsx +63 -0
- package/src/components/files/file-viewer-html.tsx +10 -0
- package/src/components/files/file-viewer-markdown.tsx +14 -0
- package/src/components/files/file-viewer-raw.tsx +44 -0
- package/src/components/files/file-viewer.tsx +290 -0
- package/src/components/files/frontmatter-panel.tsx +78 -0
- package/src/components/layout/app-shell.tsx +25 -0
- package/src/components/layout/mobile-tab-bar.tsx +54 -0
- package/src/components/layout/side-rail.tsx +128 -0
- package/src/components/layout/slide-panel.tsx +64 -0
- package/src/components/quick-actions/streaming-modal.tsx +168 -0
- package/src/components/quick-actions/whatsup-modal.tsx +149 -0
- package/src/components/settings/passkey-panel.tsx +281 -0
- package/src/components/share/share-menu.tsx +121 -0
- package/src/components/voice/dictation-sheet.tsx +217 -0
- package/src/components/voice/mic-button.tsx +31 -0
- package/src/components/voice/review-card.tsx +66 -0
- package/src/config.ts +27 -0
- package/src/hooks/use-vpn-status.ts +75 -0
- package/src/hooks/use-websocket.ts +403 -0
- package/src/index.ts +46 -0
- package/src/lib/api-client.ts +173 -0
- package/src/lib/backend.ts +35 -0
- package/src/lib/frontmatter.ts +99 -0
- package/src/lib/image-attachments.ts +221 -0
- package/src/lib/passkeys.ts +50 -0
- package/src/lib/share.ts +167 -0
- package/src/lib/strip-markdown.ts +38 -0
- package/src/lib/tool-names.ts +27 -0
- package/src/lib/utils.ts +6 -0
- package/src/lib/ws-client.ts +109 -0
- package/src/stores/chat-store.ts +619 -0
- package/src/stores/connection-store.ts +23 -0
- package/src/stores/file-store.ts +344 -0
- package/src/stores/provider-store.ts +85 -0
- package/src/stores/ui-store.ts +48 -0
- package/src/styles.css +4 -0
- package/src/theme.css +425 -0
- package/src/voice/asr-clients.ts +45 -0
- package/src/voice/asr-deepgram.ts +325 -0
- package/src/voice/asr-webspeech.ts +135 -0
- package/src/voice/use-dictation.ts +187 -0
- 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
|
+
}
|