@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,95 @@
|
|
|
1
|
+
import type { ToolCall } from "../../stores/chat-store.js";
|
|
2
|
+
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Approval-time risk hints
|
|
5
|
+
// ============================================================
|
|
6
|
+
//
|
|
7
|
+
// Non-blocking advisories surfaced in the pending-approval detail so a human
|
|
8
|
+
// notices known-risky shapes before hitting Allow. Detection only — the human
|
|
9
|
+
// still decides. Each rule is { test, message } so the list is easy to extend.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Does this path resolve inside the brain repo?
|
|
13
|
+
*
|
|
14
|
+
* Deliberately STRICTER than tool-views.tsx's `toRepoRelative`: this drives a
|
|
15
|
+
* *risk advisory*, so it must err toward warning — a bare `/brain/` substring
|
|
16
|
+
* match would wrongly clear `/tmp/brain/secrets`. Inside means either
|
|
17
|
+
* repo-relative (not absolute, no `..` escape) or anchored at a known brain
|
|
18
|
+
* root: the container path `/data/brain/` or a home checkout `/home/<user>/brain/`
|
|
19
|
+
* (`~/brain`).
|
|
20
|
+
*/
|
|
21
|
+
export function isInsideBrainRepo(path: string): boolean {
|
|
22
|
+
if (/^\/data\/brain\//.test(path) || /^\/home\/[^/]+\/brain\//.test(path)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
// Repo-relative: not absolute and not escaping upward.
|
|
26
|
+
if (!path.startsWith("/") && !path.split("/").includes("..")) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function asString(v: unknown): string {
|
|
31
|
+
return typeof v === "string" ? v : "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type RiskRule = {
|
|
35
|
+
test: (tool: ToolCall) => boolean;
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const RISK_RULES: RiskRule[] = [
|
|
40
|
+
{
|
|
41
|
+
// Note: the Bash input view already badges this red, so this line is
|
|
42
|
+
// secondary. Kept for consistency so the advisory row reflects every known
|
|
43
|
+
// risk in one place rather than the human having to cross-reference badges.
|
|
44
|
+
test: (t) => t.name === "Bash" && Boolean(t.input?.dangerouslyDisableSandbox),
|
|
45
|
+
message: "runs without the sandbox",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
// Recursive + force in either flag order (-rf, -fr) or as separate flags
|
|
49
|
+
// (rm -r -f). Lookaheads match a `-…r` flag and a `-…f` flag in any order.
|
|
50
|
+
test: (t) =>
|
|
51
|
+
t.name === "Bash" &&
|
|
52
|
+
/\brm\b(?=[^\n]*\s-[a-zA-Z]*r)(?=[^\n]*\s-[a-zA-Z]*f)/.test(
|
|
53
|
+
asString(t.input?.command)
|
|
54
|
+
),
|
|
55
|
+
message: "removes files recursively (rm -rf)",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
test: (t) =>
|
|
59
|
+
t.name === "Bash" &&
|
|
60
|
+
/git\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)/.test(
|
|
61
|
+
asString(t.input?.command)
|
|
62
|
+
),
|
|
63
|
+
message: "force-pushes a git branch",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
test: (t) =>
|
|
67
|
+
t.name === "Bash" &&
|
|
68
|
+
/\b(?:curl|wget)\b[^|]*\|\s*(?:ba)?sh\b/.test(asString(t.input?.command)),
|
|
69
|
+
message: "pipes a remote script into a shell",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
test: (t) => {
|
|
73
|
+
if (t.name !== "Write" && t.name !== "Edit" && t.name !== "NotebookEdit")
|
|
74
|
+
return false;
|
|
75
|
+
const path =
|
|
76
|
+
asString(t.input?.file_path) || asString(t.input?.notebook_path);
|
|
77
|
+
if (!path) return false;
|
|
78
|
+
return !isInsideBrainRepo(path);
|
|
79
|
+
},
|
|
80
|
+
message: "writes outside the brain repo",
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
/** Advisory messages for every risk rule that fires. Never throws. */
|
|
85
|
+
export function riskHints(tool: ToolCall): string[] {
|
|
86
|
+
const hints: string[] = [];
|
|
87
|
+
for (const rule of RISK_RULES) {
|
|
88
|
+
try {
|
|
89
|
+
if (rule.test(tool)) hints.push(rule.message);
|
|
90
|
+
} catch {
|
|
91
|
+
// Defensive: never let a malformed input break the approval UI.
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return hints;
|
|
95
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import { Plus, Loader2 } from "lucide-react";
|
|
3
|
+
import { api } from "../../lib/api-client.js";
|
|
4
|
+
import { useChatStore } from "../../stores/chat-store.js";
|
|
5
|
+
import { SlidePanel } from "../layout/slide-panel.js";
|
|
6
|
+
import { cn } from "../../lib/utils.js";
|
|
7
|
+
|
|
8
|
+
interface SessionInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string | null;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
lastActiveAt: number;
|
|
13
|
+
totalCostUsd?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface GroupedSessions {
|
|
17
|
+
label: string;
|
|
18
|
+
sessions: SessionInfo[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function SessionDrawer({
|
|
22
|
+
open,
|
|
23
|
+
onClose,
|
|
24
|
+
onResume,
|
|
25
|
+
}: {
|
|
26
|
+
open: boolean;
|
|
27
|
+
onClose: () => void;
|
|
28
|
+
onResume: (sessionId: string) => void;
|
|
29
|
+
}) {
|
|
30
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
31
|
+
const [loading, setLoading] = useState(false);
|
|
32
|
+
const clearMessages = useChatStore((s) => s.clearMessages);
|
|
33
|
+
const currentSessionId = useChatStore((s) => s.activeSessionId);
|
|
34
|
+
const runStates = useChatStore((s) => s.runStates);
|
|
35
|
+
// A running/queued session other than the one in view is reattachable. Derive
|
|
36
|
+
// it from the live per-session run-state (kept current by the frame demux)
|
|
37
|
+
// rather than the deprecated status.activeSessionId, which multi-session
|
|
38
|
+
// servers no longer send.
|
|
39
|
+
const backgroundSessionId =
|
|
40
|
+
Object.keys(runStates).find((id) => id !== currentSessionId) ?? null;
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (open) {
|
|
44
|
+
setLoading(true);
|
|
45
|
+
api
|
|
46
|
+
.sessions()
|
|
47
|
+
.then((data) => setSessions(data.sessions))
|
|
48
|
+
.catch(() => setSessions([]))
|
|
49
|
+
.finally(() => setLoading(false));
|
|
50
|
+
}
|
|
51
|
+
}, [open]);
|
|
52
|
+
|
|
53
|
+
const groups = groupSessionsByDate(sessions);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<SlidePanel open={open} onClose={onClose} title="Sessions" wide>
|
|
57
|
+
<div className="flex h-full flex-col">
|
|
58
|
+
{/* New chat button */}
|
|
59
|
+
<div className="p-4">
|
|
60
|
+
<button
|
|
61
|
+
onClick={() => {
|
|
62
|
+
clearMessages();
|
|
63
|
+
onClose();
|
|
64
|
+
}}
|
|
65
|
+
className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/30 py-3 text-[13px] font-medium text-primary transition-all hover:border-primary/50 hover:bg-primary/5"
|
|
66
|
+
>
|
|
67
|
+
<Plus className="h-4 w-4" />
|
|
68
|
+
New conversation
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{/* Session list */}
|
|
73
|
+
<div className="flex-1 overflow-y-auto px-2 pb-4">
|
|
74
|
+
{/* Active session banner */}
|
|
75
|
+
{backgroundSessionId && (
|
|
76
|
+
<div className="px-2 pb-2">
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => {
|
|
79
|
+
onResume(backgroundSessionId);
|
|
80
|
+
onClose();
|
|
81
|
+
}}
|
|
82
|
+
className="flex w-full items-center gap-3 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
|
|
83
|
+
>
|
|
84
|
+
<span
|
|
85
|
+
className="h-2 w-2 shrink-0 rounded-full bg-primary"
|
|
86
|
+
style={{ animation: "breathe 2s ease-in-out infinite" }}
|
|
87
|
+
/>
|
|
88
|
+
<div className="min-w-0">
|
|
89
|
+
<div className="truncate text-[13px] font-medium text-foreground">
|
|
90
|
+
Session running...
|
|
91
|
+
</div>
|
|
92
|
+
<div className="text-[11px] text-primary">
|
|
93
|
+
Tap to reattach
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
{loading ? (
|
|
101
|
+
<div className="flex items-center justify-center py-12 text-muted-foreground">
|
|
102
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
103
|
+
</div>
|
|
104
|
+
) : sessions.length === 0 && !backgroundSessionId ? (
|
|
105
|
+
<p className="py-12 text-center text-xs text-muted-foreground">
|
|
106
|
+
No sessions yet
|
|
107
|
+
</p>
|
|
108
|
+
) : (
|
|
109
|
+
groups.map((group) => (
|
|
110
|
+
<div key={group.label}>
|
|
111
|
+
<div className="px-3 py-2 font-[family-name:var(--font-mono)] text-[10px] font-medium uppercase tracking-widest text-muted-foreground/40">
|
|
112
|
+
{group.label}
|
|
113
|
+
</div>
|
|
114
|
+
{group.sessions.map((session) => (
|
|
115
|
+
<button
|
|
116
|
+
key={session.id}
|
|
117
|
+
onClick={() => {
|
|
118
|
+
onResume(session.id);
|
|
119
|
+
onClose();
|
|
120
|
+
}}
|
|
121
|
+
className={cn(
|
|
122
|
+
"w-full rounded-lg px-3 py-2.5 text-left transition-colors hover:bg-surface-raised",
|
|
123
|
+
session.id === currentSessionId &&
|
|
124
|
+
"border-l-2 border-primary bg-primary/5"
|
|
125
|
+
)}
|
|
126
|
+
>
|
|
127
|
+
<div className="flex items-center gap-2">
|
|
128
|
+
<div className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
|
129
|
+
{session.title || "Untitled"}
|
|
130
|
+
</div>
|
|
131
|
+
<RunBadge state={runStates[session.id]} />
|
|
132
|
+
</div>
|
|
133
|
+
<div className="mt-0.5 flex items-center gap-2">
|
|
134
|
+
<span className="text-[11px] text-muted-foreground">
|
|
135
|
+
{formatRelativeTime(session.lastActiveAt)}
|
|
136
|
+
</span>
|
|
137
|
+
{session.totalCostUsd != null &&
|
|
138
|
+
session.totalCostUsd > 0 && (
|
|
139
|
+
<span className="text-[11px] text-muted-foreground/40">
|
|
140
|
+
${session.totalCostUsd.toFixed(2)}
|
|
141
|
+
</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
</button>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
))
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</SlidePanel>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Live run-state pill on a session row (streaming / queued). */
|
|
156
|
+
function RunBadge({ state }: { state?: "streaming" | "queued" | "idle" }) {
|
|
157
|
+
if (state !== "streaming" && state !== "queued") return null;
|
|
158
|
+
const running = state === "streaming";
|
|
159
|
+
return (
|
|
160
|
+
<span
|
|
161
|
+
className={cn(
|
|
162
|
+
"shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wide",
|
|
163
|
+
running ? "bg-primary/15 text-primary" : "bg-amber-500/15 text-amber-500"
|
|
164
|
+
)}
|
|
165
|
+
>
|
|
166
|
+
{running ? "Running" : "Queued"}
|
|
167
|
+
</span>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function groupSessionsByDate(sessions: SessionInfo[]): GroupedSessions[] {
|
|
172
|
+
const dayMs = 86400000;
|
|
173
|
+
const today = new Date();
|
|
174
|
+
today.setHours(0, 0, 0, 0);
|
|
175
|
+
const todayMs = today.getTime();
|
|
176
|
+
const yesterdayMs = todayMs - dayMs;
|
|
177
|
+
const weekAgoMs = todayMs - 7 * dayMs;
|
|
178
|
+
|
|
179
|
+
const groups: Record<string, SessionInfo[]> = {
|
|
180
|
+
Today: [],
|
|
181
|
+
Yesterday: [],
|
|
182
|
+
"This week": [],
|
|
183
|
+
Earlier: [],
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const s of sessions) {
|
|
187
|
+
if (s.lastActiveAt >= todayMs) {
|
|
188
|
+
groups.Today.push(s);
|
|
189
|
+
} else if (s.lastActiveAt >= yesterdayMs) {
|
|
190
|
+
groups.Yesterday.push(s);
|
|
191
|
+
} else if (s.lastActiveAt >= weekAgoMs) {
|
|
192
|
+
groups["This week"].push(s);
|
|
193
|
+
} else {
|
|
194
|
+
groups.Earlier.push(s);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return Object.entries(groups)
|
|
199
|
+
.filter(([, sessions]) => sessions.length > 0)
|
|
200
|
+
.map(([label, sessions]) => ({ label, sessions }));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function formatRelativeTime(ms: number): string {
|
|
204
|
+
const diff = Date.now() - ms;
|
|
205
|
+
const minutes = Math.floor(diff / 60000);
|
|
206
|
+
if (minutes < 1) return "just now";
|
|
207
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
208
|
+
const hours = Math.floor(minutes / 60);
|
|
209
|
+
if (hours < 24) return `${hours}h ago`;
|
|
210
|
+
const days = Math.floor(hours / 24);
|
|
211
|
+
if (days < 7) return `${days}d ago`;
|
|
212
|
+
return new Date(ms).toLocaleDateString();
|
|
213
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { uiConfig } from "../../config.js";
|
|
2
|
+
import { useRef } from "react";
|
|
3
|
+
import { BrainMarkdown } from "./brain-markdown.js";
|
|
4
|
+
import { ShareMenu, type ShareOption } from "../share/share-menu.js";
|
|
5
|
+
import { renderAndShare, shareFile, shareText, copyRichText } from "../../lib/share.js";
|
|
6
|
+
import { stripMarkdown } from "../../lib/strip-markdown.js";
|
|
7
|
+
import { Share2 } from "lucide-react";
|
|
8
|
+
|
|
9
|
+
export type ShareBlockFormat = "image" | "pdf" | "text" | "markdown" | "richtext";
|
|
10
|
+
|
|
11
|
+
const FORMAT_LABELS: Record<ShareBlockFormat, string> = {
|
|
12
|
+
image: "Share as image",
|
|
13
|
+
pdf: "Share as PDF",
|
|
14
|
+
text: "Share as plain text",
|
|
15
|
+
markdown: "Share markdown source",
|
|
16
|
+
richtext: "Copy as rich text",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ORDER: ShareBlockFormat[] = ["image", "pdf", "text", "markdown", "richtext"];
|
|
20
|
+
|
|
21
|
+
interface ShareBlockProps {
|
|
22
|
+
body: string;
|
|
23
|
+
format?: ShareBlockFormat;
|
|
24
|
+
title?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ShareBlock({ body, format = "image", title }: ShareBlockProps) {
|
|
28
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
29
|
+
const filename = sanitizeFilename(title ?? "share");
|
|
30
|
+
|
|
31
|
+
const primary = makeAction(format, body, filename, title, bodyRef);
|
|
32
|
+
const secondary: ShareOption[] = ORDER.filter((f) => f !== format).map((f) =>
|
|
33
|
+
makeAction(f, body, filename, title, bodyRef)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="my-4 overflow-hidden rounded-lg border border-primary/30 bg-primary/[0.04]">
|
|
38
|
+
<div className="flex items-center justify-between gap-3 border-b border-primary/20 bg-primary/[0.06] px-3 py-2">
|
|
39
|
+
<div className="flex min-w-0 items-center gap-2 text-xs">
|
|
40
|
+
<Share2 className="h-3.5 w-3.5 shrink-0 text-primary/80" />
|
|
41
|
+
<span className="truncate font-medium text-primary/90">
|
|
42
|
+
{title ?? "Share"}
|
|
43
|
+
</span>
|
|
44
|
+
<span className="rounded-full bg-primary/15 px-2 py-0.5 text-[10px] uppercase tracking-wide text-primary/80">
|
|
45
|
+
{format}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="flex items-center gap-1">
|
|
49
|
+
<button
|
|
50
|
+
onClick={() => void primary.run()}
|
|
51
|
+
className="rounded-md bg-primary px-2.5 py-1 text-[11px] font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
|
52
|
+
>
|
|
53
|
+
{primary.label}
|
|
54
|
+
</button>
|
|
55
|
+
<ShareMenu options={secondary} title="Other formats" />
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div ref={bodyRef} className="px-4 py-3">
|
|
59
|
+
<BrainMarkdown content={body} className="brain-prose" fileLinks />
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeAction(
|
|
66
|
+
fmt: ShareBlockFormat,
|
|
67
|
+
body: string,
|
|
68
|
+
filename: string,
|
|
69
|
+
title: string | undefined,
|
|
70
|
+
bodyRef: React.RefObject<HTMLDivElement | null>
|
|
71
|
+
): ShareOption {
|
|
72
|
+
return {
|
|
73
|
+
id: fmt,
|
|
74
|
+
label: FORMAT_LABELS[fmt],
|
|
75
|
+
run: () => runFormat(fmt, body, filename, title, bodyRef),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function runFormat(
|
|
80
|
+
fmt: ShareBlockFormat,
|
|
81
|
+
body: string,
|
|
82
|
+
filename: string,
|
|
83
|
+
title: string | undefined,
|
|
84
|
+
bodyRef: React.RefObject<HTMLDivElement | null>
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
switch (fmt) {
|
|
87
|
+
case "image":
|
|
88
|
+
return renderAndShare({
|
|
89
|
+
content: body,
|
|
90
|
+
contentType: "markdown",
|
|
91
|
+
format: "png",
|
|
92
|
+
filename,
|
|
93
|
+
title: title ?? uiConfig.shareTitle,
|
|
94
|
+
});
|
|
95
|
+
case "pdf":
|
|
96
|
+
return renderAndShare({
|
|
97
|
+
content: body,
|
|
98
|
+
contentType: "markdown",
|
|
99
|
+
format: "pdf",
|
|
100
|
+
filename,
|
|
101
|
+
title: title ?? uiConfig.shareTitle,
|
|
102
|
+
});
|
|
103
|
+
case "text":
|
|
104
|
+
return shareText({ text: stripMarkdown(body), title });
|
|
105
|
+
case "markdown":
|
|
106
|
+
return shareFile(
|
|
107
|
+
new File([body], `${filename}.md`, { type: "text/markdown" }),
|
|
108
|
+
{ title: title ?? filename }
|
|
109
|
+
);
|
|
110
|
+
case "richtext": {
|
|
111
|
+
const html = bodyRef.current?.innerHTML ?? `<pre>${escapeHtml(body)}</pre>`;
|
|
112
|
+
return copyRichText(html, stripMarkdown(body));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function sanitizeFilename(s: string): string {
|
|
118
|
+
return s.replace(/[^a-z0-9_-]+/gi, "_").slice(0, 60) || "share";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function escapeHtml(s: string): string {
|
|
122
|
+
return s
|
|
123
|
+
.replace(/&/g, "&")
|
|
124
|
+
.replace(/</g, "<")
|
|
125
|
+
.replace(/>/g, ">")
|
|
126
|
+
.replace(/"/g, """)
|
|
127
|
+
.replace(/'/g, "'");
|
|
128
|
+
}
|