@schlessera/brain-ui-react 0.30.1 → 0.31.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/dist/components/chat/brain-markdown.d.ts +6 -1
- package/dist/components/chat/brain-markdown.d.ts.map +1 -1
- package/dist/components/chat/brain-markdown.js +66 -7
- package/dist/components/chat/brain-markdown.js.map +1 -1
- package/dist/components/chat/chat-page.d.ts +9 -0
- package/dist/components/chat/chat-page.d.ts.map +1 -1
- package/dist/components/chat/chat-page.js +75 -388
- package/dist/components/chat/chat-page.js.map +1 -1
- package/dist/components/chat/composer.d.ts +18 -0
- package/dist/components/chat/composer.d.ts.map +1 -0
- package/dist/components/chat/composer.js +319 -0
- package/dist/components/chat/composer.js.map +1 -0
- package/dist/components/chat/message-bubble.d.ts +13 -2
- package/dist/components/chat/message-bubble.d.ts.map +1 -1
- package/dist/components/chat/message-bubble.js +16 -5
- package/dist/components/chat/message-bubble.js.map +1 -1
- package/dist/components/chat/use-chat-commands.d.ts +12 -0
- package/dist/components/chat/use-chat-commands.d.ts.map +1 -0
- package/dist/components/chat/use-chat-commands.js +82 -0
- package/dist/components/chat/use-chat-commands.js.map +1 -0
- package/dist/components/files/file-panel.d.ts.map +1 -1
- package/dist/components/files/file-panel.js +16 -5
- package/dist/components/files/file-panel.js.map +1 -1
- package/dist/components/layout/slide-panel.d.ts.map +1 -1
- package/dist/components/layout/slide-panel.js +11 -1
- package/dist/components/layout/slide-panel.js.map +1 -1
- package/dist/components/settings/settings-panel.d.ts.map +1 -1
- package/dist/components/settings/settings-panel.js +12 -4
- package/dist/components/settings/settings-panel.js.map +1 -1
- package/dist/hooks/use-deferred-unmount.d.ts +12 -0
- package/dist/hooks/use-deferred-unmount.d.ts.map +1 -0
- package/dist/hooks/use-deferred-unmount.js +24 -0
- package/dist/hooks/use-deferred-unmount.js.map +1 -0
- package/dist/hooks/use-websocket.d.ts +8 -0
- package/dist/hooks/use-websocket.d.ts.map +1 -1
- package/dist/hooks/use-websocket.js +66 -2
- package/dist/hooks/use-websocket.js.map +1 -1
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/lazy-pages.d.ts +3 -0
- package/dist/lazy-pages.d.ts.map +1 -0
- package/dist/lazy-pages.js +27 -0
- package/dist/lazy-pages.js.map +1 -0
- package/dist/styles.css +1 -1
- package/dist/theme.css +58 -0
- package/package.json +2 -2
- package/src/components/chat/brain-markdown.tsx +81 -9
- package/src/components/chat/chat-page.tsx +119 -709
- package/src/components/chat/composer.tsx +617 -0
- package/src/components/chat/message-bubble.tsx +24 -6
- package/src/components/chat/use-chat-commands.ts +87 -0
- package/src/components/files/file-panel.tsx +25 -8
- package/src/components/layout/slide-panel.tsx +13 -1
- package/src/components/settings/settings-panel.tsx +21 -10
- package/src/hooks/use-deferred-unmount.ts +24 -0
- package/src/hooks/use-websocket.ts +79 -2
- package/src/index.ts +3 -2
- package/src/lazy-pages.tsx +41 -0
- package/src/theme.css +58 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useChatStore } from "../../stores/chat-store.js";
|
|
3
|
+
import { useConnectionStore } from "../../stores/connection-store.js";
|
|
4
|
+
import { useUIStore } from "../../stores/ui-store.js";
|
|
5
|
+
import { api } from "../../lib/api-client.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Slash-command dispatch, shared by the composer's palette and the welcome
|
|
9
|
+
* screen's shortcut buttons.
|
|
10
|
+
*
|
|
11
|
+
* Every piece of state a command needs is read through `getState()` at the
|
|
12
|
+
* moment it runs rather than subscribed to, so the returned callback is stable
|
|
13
|
+
* for the life of the component. That matters because the composer is the
|
|
14
|
+
* component that re-renders on every keystroke: a command handler that changed
|
|
15
|
+
* identity per character would defeat the memoization around it.
|
|
16
|
+
*/
|
|
17
|
+
export function useChatCommands(): (command: string) => void {
|
|
18
|
+
return useCallback((command: string) => {
|
|
19
|
+
const ui = useUIStore.getState();
|
|
20
|
+
|
|
21
|
+
// Search and add talk to the brain CLI over REST, not to the agent — they
|
|
22
|
+
// stay available while a turn streams or the socket is down.
|
|
23
|
+
switch (command) {
|
|
24
|
+
case "search":
|
|
25
|
+
ui.setSearchPanelOpen(true);
|
|
26
|
+
return;
|
|
27
|
+
case "add":
|
|
28
|
+
ui.setAddPanelOpen(true);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const chat = useChatStore.getState();
|
|
33
|
+
const sessionId = chat.activeSessionId;
|
|
34
|
+
const buffer = sessionId ? chat.buffers[sessionId] : chat.draft;
|
|
35
|
+
const disabled =
|
|
36
|
+
useConnectionStore.getState().wsStatus !== "connected" ||
|
|
37
|
+
Boolean(buffer?.isStreaming);
|
|
38
|
+
if (disabled) return;
|
|
39
|
+
|
|
40
|
+
switch (command) {
|
|
41
|
+
case "sync":
|
|
42
|
+
ui.setSyncPanelOpen(true);
|
|
43
|
+
break;
|
|
44
|
+
case "whatsup":
|
|
45
|
+
ui.setWhatsupPanelOpen(true);
|
|
46
|
+
break;
|
|
47
|
+
case "stats":
|
|
48
|
+
void runStats(sessionId);
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
}, []);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Brain statistics rendered into the transcript as an assistant turn. */
|
|
55
|
+
async function runStats(sessionId: string | null): Promise<void> {
|
|
56
|
+
const chat = useChatStore.getState();
|
|
57
|
+
chat.addUserMessage(sessionId, "Stats");
|
|
58
|
+
chat.startAssistantMessage(sessionId);
|
|
59
|
+
try {
|
|
60
|
+
const stats = await api.brainStats();
|
|
61
|
+
const result = [
|
|
62
|
+
`**Brain Statistics**`,
|
|
63
|
+
`- Documents: ${stats.documents}`,
|
|
64
|
+
`- Tags: ${stats.tags}`,
|
|
65
|
+
`- Links: ${stats.links}`,
|
|
66
|
+
``,
|
|
67
|
+
`**By Type:** ${Object.entries(stats.byType)
|
|
68
|
+
.sort(([, a], [, b]) => b - a)
|
|
69
|
+
.map(([t, n]) => `${t} (${n})`)
|
|
70
|
+
.join(", ")}`,
|
|
71
|
+
``,
|
|
72
|
+
`**By Status:** ${Object.entries(stats.byStatus)
|
|
73
|
+
.map(([s, n]) => `${s} (${n})`)
|
|
74
|
+
.join(", ")}`,
|
|
75
|
+
].join("\n");
|
|
76
|
+
useChatStore.getState().appendText(sessionId, result);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
useChatStore
|
|
79
|
+
.getState()
|
|
80
|
+
.appendText(
|
|
81
|
+
sessionId,
|
|
82
|
+
`**Error:** ${err instanceof Error ? err.message : "Action failed"}`
|
|
83
|
+
);
|
|
84
|
+
} finally {
|
|
85
|
+
useChatStore.getState().finishAssistantMessage(sessionId);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
1
|
+
import { Suspense, lazy, useEffect } from "react";
|
|
2
2
|
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
|
3
3
|
import { useFileStore } from "../../stores/file-store.js";
|
|
4
|
-
import {
|
|
5
|
-
import { FileViewer } from "./file-viewer.js";
|
|
4
|
+
import { useDeferredUnmount } from "../../hooks/use-deferred-unmount.js";
|
|
6
5
|
import { cn } from "../../lib/utils.js";
|
|
7
6
|
|
|
7
|
+
/** Matches the `duration-300` slide-out below. */
|
|
8
|
+
const SLIDE_OUT_MS = 300;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The tree and the viewers load the first time the panel is opened. Together
|
|
12
|
+
* they carry the markdown, HTML, image and PDF viewers, none of which the chat
|
|
13
|
+
* surface needs to have on hand.
|
|
14
|
+
*/
|
|
15
|
+
const FileTree = lazy(() => import("./file-tree.js").then((m) => ({ default: m.FileTree })));
|
|
16
|
+
const FileViewer = lazy(() => import("./file-viewer.js").then((m) => ({ default: m.FileViewer })));
|
|
17
|
+
|
|
8
18
|
export function FilePanel({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
9
19
|
const currentPath = useFileStore((s) => s.currentPath);
|
|
10
20
|
const treeExpanded = useFileStore((s) => s.treeExpanded);
|
|
@@ -40,7 +50,10 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|
|
40
50
|
}
|
|
41
51
|
}, [open, currentPath]);
|
|
42
52
|
|
|
43
|
-
|
|
53
|
+
// The body outlives `open` by the slide-out, then unmounts: a closed panel
|
|
54
|
+
// was otherwise keeping the whole file tree mounted behind the chat page.
|
|
55
|
+
const showContent = useDeferredUnmount(open, SLIDE_OUT_MS);
|
|
56
|
+
const showTree = showContent && (!currentPath || treeExpanded);
|
|
44
57
|
|
|
45
58
|
return (
|
|
46
59
|
<>
|
|
@@ -73,7 +86,7 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|
|
73
86
|
|
|
74
87
|
<div className="flex flex-1 flex-col overflow-hidden">
|
|
75
88
|
{/* Tree toggle strip — visible when a file is open */}
|
|
76
|
-
{currentPath && (
|
|
89
|
+
{showContent && currentPath && (
|
|
77
90
|
<div className="flex items-center gap-1 border-b border-border bg-surface-raised/40 px-3 py-1.5">
|
|
78
91
|
<button
|
|
79
92
|
onClick={() => setTreeExpanded(!treeExpanded)}
|
|
@@ -99,14 +112,18 @@ export function FilePanel({ open, onClose }: { open: boolean; onClose: () => voi
|
|
|
99
112
|
currentPath ? "max-h-[45vh] border-b border-border" : "flex-1"
|
|
100
113
|
)}
|
|
101
114
|
>
|
|
102
|
-
<
|
|
115
|
+
<Suspense fallback={null}>
|
|
116
|
+
<FileTree />
|
|
117
|
+
</Suspense>
|
|
103
118
|
</div>
|
|
104
119
|
)}
|
|
105
120
|
|
|
106
121
|
{/* Viewer area */}
|
|
107
|
-
{currentPath && (
|
|
122
|
+
{showContent && currentPath && (
|
|
108
123
|
<div className="flex-1 overflow-hidden">
|
|
109
|
-
<
|
|
124
|
+
<Suspense fallback={null}>
|
|
125
|
+
<FileViewer />
|
|
126
|
+
</Suspense>
|
|
110
127
|
</div>
|
|
111
128
|
)}
|
|
112
129
|
</div>
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { useEffect, type ReactNode } from "react";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
|
+
import { useDeferredUnmount } from "../../hooks/use-deferred-unmount.js";
|
|
3
4
|
import { cn } from "../../lib/utils.js";
|
|
4
5
|
|
|
6
|
+
/** Matches the `duration-300` slide-out below. */
|
|
7
|
+
const SLIDE_OUT_MS = 300;
|
|
8
|
+
|
|
5
9
|
export function SlidePanel({
|
|
6
10
|
open,
|
|
7
11
|
onClose,
|
|
@@ -24,6 +28,14 @@ export function SlidePanel({
|
|
|
24
28
|
return () => document.removeEventListener("keydown", handler);
|
|
25
29
|
}, [open, onClose]);
|
|
26
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A closed panel renders nothing. The shell (the sliding frame and its
|
|
33
|
+
* header) stays mounted so the CSS transform still animates; the contents do
|
|
34
|
+
* not. Every panel already rebuilds its state when it opens, so a close never
|
|
35
|
+
* preserved anything worth keeping.
|
|
36
|
+
*/
|
|
37
|
+
const showContent = useDeferredUnmount(open, SLIDE_OUT_MS);
|
|
38
|
+
|
|
27
39
|
return (
|
|
28
40
|
<>
|
|
29
41
|
{/* Backdrop */}
|
|
@@ -57,7 +69,7 @@ export function SlidePanel({
|
|
|
57
69
|
</div>
|
|
58
70
|
|
|
59
71
|
{/* Content */}
|
|
60
|
-
<div className="flex-1 overflow-y-auto">{children}</div>
|
|
72
|
+
<div className="flex-1 overflow-y-auto">{showContent ? children : null}</div>
|
|
61
73
|
</div>
|
|
62
74
|
</>
|
|
63
75
|
);
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { Suspense, lazy } from "react";
|
|
1
2
|
import { KeyRound, Puzzle, SlidersHorizontal } from "lucide-react";
|
|
2
3
|
import { useUIStore, type SettingsTab } from "../../stores/ui-store.js";
|
|
3
4
|
import { SlidePanel } from "../layout/slide-panel.js";
|
|
4
5
|
import { cn } from "../../lib/utils.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Each tab is fetched the first time it is opened. Settings is the largest
|
|
9
|
+
* surface in the app and most sessions never open it, so none of it belongs in
|
|
10
|
+
* the bundle that has to arrive before the first message can be shown. The
|
|
11
|
+
* panel frame and its tab strip stay eager, so opening settings is immediate
|
|
12
|
+
* and only the body fills in.
|
|
13
|
+
*/
|
|
14
|
+
const ModelsTab = lazy(() => import("./models-tab.js").then((m) => ({ default: m.ModelsTab })));
|
|
15
|
+
const PasskeyTab = lazy(() => import("./passkey-tab.js").then((m) => ({ default: m.PasskeyTab })));
|
|
16
|
+
const SkillsTab = lazy(() => import("./skills-tab.js").then((m) => ({ default: m.SkillsTab })));
|
|
8
17
|
|
|
9
18
|
const TABS: Array<{ id: SettingsTab; label: string; icon: typeof KeyRound }> = [
|
|
10
19
|
{ id: "models", label: "Models", icon: SlidersHorizontal },
|
|
@@ -51,13 +60,15 @@ export function SettingsPanel({
|
|
|
51
60
|
</div>
|
|
52
61
|
|
|
53
62
|
<div className="min-h-0 flex-1">
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
<Suspense fallback={null}>
|
|
64
|
+
{tab === "models" ? (
|
|
65
|
+
<ModelsTab active={open && tab === "models"} />
|
|
66
|
+
) : tab === "skills" ? (
|
|
67
|
+
<SkillsTab active={open && tab === "skills"} />
|
|
68
|
+
) : (
|
|
69
|
+
<PasskeyTab active={open && tab === "security"} />
|
|
70
|
+
)}
|
|
71
|
+
</Suspense>
|
|
61
72
|
</div>
|
|
62
73
|
</div>
|
|
63
74
|
</SlidePanel>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True while a panel should render its contents: immediately when it opens,
|
|
5
|
+
* and until `delayMs` after it closes.
|
|
6
|
+
*
|
|
7
|
+
* Panels slide out rather than disappearing, so their contents have to outlive
|
|
8
|
+
* the `open` flag by the length of that transition — unmounting on the flag
|
|
9
|
+
* would empty the panel on its way off screen. Everything after that is dead
|
|
10
|
+
* weight: a panel is a child of the page it opens over, so a mounted one
|
|
11
|
+
* re-renders with the page and keeps its whole subtree alive for nothing.
|
|
12
|
+
*/
|
|
13
|
+
export function useDeferredUnmount(open: boolean, delayMs: number): boolean {
|
|
14
|
+
const [mounted, setMounted] = useState(open);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (open) {
|
|
17
|
+
setMounted(true);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const timer = setTimeout(() => setMounted(false), delayMs);
|
|
21
|
+
return () => clearTimeout(timer);
|
|
22
|
+
}, [open, delayMs]);
|
|
23
|
+
return mounted;
|
|
24
|
+
}
|
|
@@ -153,7 +153,76 @@ function ensureActivitySubscription(sessionId: string | null | undefined): void
|
|
|
153
153
|
void loadSessionActivityHistory(sessionId);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Text and thinking deltas waiting to be applied, per buffer.
|
|
158
|
+
*
|
|
159
|
+
* A model streams tokens far faster than the display refreshes, and each socket
|
|
160
|
+
* frame arrives in its own macrotask, so React cannot batch them: one token was
|
|
161
|
+
* one store write, one render and one markdown re-parse of the growing message.
|
|
162
|
+
* Coalescing a frame's worth of tokens into a single write costs nothing
|
|
163
|
+
* visually — the screen could not have shown the intermediate states anyway.
|
|
164
|
+
*
|
|
165
|
+
* Consecutive same-kind chunks merge, so a burst of text becomes ONE appendText
|
|
166
|
+
* rather than one per token. Kind changes start a new chunk, which is what keeps
|
|
167
|
+
* interleaved thinking and text in their true chronological order.
|
|
168
|
+
*/
|
|
169
|
+
type PendingDelta = { kind: "text" | "thinking"; text: string };
|
|
170
|
+
const pendingDeltas = new Map<ChatKey, PendingDelta[]>();
|
|
171
|
+
let flushScheduled = false;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Apply every buffered delta, in arrival order, and clear the buffer.
|
|
175
|
+
*
|
|
176
|
+
* Exported so tests can force the frame rather than wait for one; production
|
|
177
|
+
* code never needs to call it, because every path that reads the transcript
|
|
178
|
+
* flushes first.
|
|
179
|
+
*/
|
|
180
|
+
export function flushChatDeltas(): void {
|
|
181
|
+
flushDeltas();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function flushDeltas(): void {
|
|
185
|
+
flushScheduled = false;
|
|
186
|
+
if (pendingDeltas.size === 0) return;
|
|
187
|
+
const batches = [...pendingDeltas.entries()];
|
|
188
|
+
pendingDeltas.clear();
|
|
189
|
+
const state = useChatStore.getState();
|
|
190
|
+
for (const [key, chunks] of batches) {
|
|
191
|
+
for (const chunk of chunks) {
|
|
192
|
+
if (chunk.kind === "text") state.appendText(key, chunk.text);
|
|
193
|
+
else state.appendThinking(key, chunk.text);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function enqueueDelta(key: ChatKey, kind: PendingDelta["kind"], text: string): void {
|
|
199
|
+
const chunks = pendingDeltas.get(key);
|
|
200
|
+
if (!chunks) {
|
|
201
|
+
pendingDeltas.set(key, [{ kind, text }]);
|
|
202
|
+
} else {
|
|
203
|
+
const last = chunks[chunks.length - 1];
|
|
204
|
+
if (last.kind === kind) last.text += text;
|
|
205
|
+
else chunks.push({ kind, text });
|
|
206
|
+
}
|
|
207
|
+
// Outside a browser (unit tests, SSR) there is no frame to wait for, so apply
|
|
208
|
+
// straight away and keep the handler's behaviour synchronous.
|
|
209
|
+
if (typeof requestAnimationFrame !== "function") {
|
|
210
|
+
flushDeltas();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!flushScheduled) {
|
|
214
|
+
flushScheduled = true;
|
|
215
|
+
requestAnimationFrame(flushDeltas);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
156
219
|
export function handleServerMessage(msg: ServerMessage) {
|
|
220
|
+
// Every frame that is not itself a delta must see the transcript fully
|
|
221
|
+
// applied: the demux below reads buffer state, and the store records parts in
|
|
222
|
+
// arrival order, so a tool call landing ahead of buffered text would reorder
|
|
223
|
+
// the message.
|
|
224
|
+
if (msg.type !== "text_delta" && msg.type !== "thinking_delta") flushDeltas();
|
|
225
|
+
|
|
157
226
|
const state = useChatStore.getState();
|
|
158
227
|
|
|
159
228
|
// Activity stream frames feed their own store and never touch chat state.
|
|
@@ -223,17 +292,19 @@ export function handleServerMessage(msg: ServerMessage) {
|
|
|
223
292
|
|
|
224
293
|
switch (msg.type) {
|
|
225
294
|
case "text_delta":
|
|
295
|
+
// Opening the bubble stays immediate — the first token should show a
|
|
296
|
+
// message starting, and every later delta needs isStreaming to be true.
|
|
226
297
|
if (!buffer()?.isStreaming) {
|
|
227
298
|
state.startAssistantMessage(key);
|
|
228
299
|
}
|
|
229
|
-
|
|
300
|
+
enqueueDelta(key, "text", msg.text);
|
|
230
301
|
break;
|
|
231
302
|
|
|
232
303
|
case "thinking_delta":
|
|
233
304
|
if (!buffer()?.isStreaming) {
|
|
234
305
|
state.startAssistantMessage(key);
|
|
235
306
|
}
|
|
236
|
-
|
|
307
|
+
enqueueDelta(key, "thinking", msg.text);
|
|
237
308
|
break;
|
|
238
309
|
|
|
239
310
|
case "tool_use_start":
|
|
@@ -463,6 +534,12 @@ function coldResumeIfNeeded(sessionId: string | null, messageCount: number) {
|
|
|
463
534
|
function handleStatusChange(status: "connecting" | "connected" | "disconnected") {
|
|
464
535
|
useConnectionStore.getState().setWsStatus(status);
|
|
465
536
|
|
|
537
|
+
// A status change can be followed by a history replay that rewrites the
|
|
538
|
+
// buffer, so land whatever is still buffered before anything reads it. In a
|
|
539
|
+
// hidden tab requestAnimationFrame does not run at all, which is exactly the
|
|
540
|
+
// case where deltas could otherwise sit unapplied across a reconnect.
|
|
541
|
+
flushDeltas();
|
|
542
|
+
|
|
466
543
|
if (status === "disconnected") {
|
|
467
544
|
wasDisconnected = true;
|
|
468
545
|
// Allow a fresh cold-resume attempt after we reconnect.
|
package/src/index.ts
CHANGED
|
@@ -15,8 +15,9 @@ export { configureBrainUi, uiConfig, type BrainUiConfig } from "./config.js";
|
|
|
15
15
|
export { ConnectionGate } from "./components/connectivity/connection-gate.js";
|
|
16
16
|
export { AppShell } from "./components/layout/app-shell.js";
|
|
17
17
|
export { ChatPage } from "./components/chat/chat-page.js";
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Loaded on first use, with their own Suspense boundary, so a shell that
|
|
19
|
+
// renders them keeps working unchanged — see lazy-pages.tsx.
|
|
20
|
+
export { GraphPage, ActivityPage } from "./lazy-pages.js";
|
|
20
21
|
|
|
21
22
|
// Markdown renderer (also useful standalone, e.g. for a dev kitchen sink).
|
|
22
23
|
export { BrainMarkdown } from "./components/chat/brain-markdown.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Suspense, lazy } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two secondary surfaces, loaded on first use.
|
|
5
|
+
*
|
|
6
|
+
* Chat is what the app opens on; the graph (which drags in sigma and
|
|
7
|
+
* graphology) and the activity record are reached from the rail, often never in
|
|
8
|
+
* a given session. Splitting them keeps their code out of the bundle that has
|
|
9
|
+
* to arrive before anything can be shown.
|
|
10
|
+
*
|
|
11
|
+
* The Suspense boundary lives here rather than in the consuming shell so these
|
|
12
|
+
* stay drop-in components: the shell renders <GraphPage /> exactly as before
|
|
13
|
+
* and does not have to know that it suspends. The fallback is deliberately
|
|
14
|
+
* empty — the chunk resolves in a frame or two on any warm cache, and a
|
|
15
|
+
* spinner that flashes for one frame reads as a glitch rather than as progress.
|
|
16
|
+
*/
|
|
17
|
+
const GraphPageLazy = lazy(() =>
|
|
18
|
+
import("./components/graph/graph-page.js").then((m) => ({ default: m.GraphPage }))
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const ActivityPageLazy = lazy(() =>
|
|
22
|
+
import("./components/activity/activity-page.js").then((m) => ({
|
|
23
|
+
default: m.ActivityPage,
|
|
24
|
+
}))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export function GraphPage() {
|
|
28
|
+
return (
|
|
29
|
+
<Suspense fallback={null}>
|
|
30
|
+
<GraphPageLazy />
|
|
31
|
+
</Suspense>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ActivityPage() {
|
|
36
|
+
return (
|
|
37
|
+
<Suspense fallback={null}>
|
|
38
|
+
<ActivityPageLazy />
|
|
39
|
+
</Suspense>
|
|
40
|
+
);
|
|
41
|
+
}
|
package/src/theme.css
CHANGED
|
@@ -433,3 +433,61 @@
|
|
|
433
433
|
.brain-tree-highlight {
|
|
434
434
|
animation: brain-tree-flash 1.6s ease-out;
|
|
435
435
|
}
|
|
436
|
+
|
|
437
|
+
/* Auto-growing composer textarea.
|
|
438
|
+
*
|
|
439
|
+
* The wrapper is a one-cell grid holding the textarea and a hidden ::after that
|
|
440
|
+
* mirrors the draft; the mirror sizes the row, so the textarea follows without
|
|
441
|
+
* anyone reading scrollHeight. That read is a forced synchronous layout of the
|
|
442
|
+
* whole document, and it ran on every keystroke — its cost grew with the size
|
|
443
|
+
* of the transcript rendered behind the composer.
|
|
444
|
+
*
|
|
445
|
+
* The mirror and the textarea MUST agree on every box and typography value
|
|
446
|
+
* below, or the two will disagree about where the text wraps. */
|
|
447
|
+
.composer-grow {
|
|
448
|
+
display: grid;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
.composer-grow::after,
|
|
452
|
+
.composer-grow > textarea {
|
|
453
|
+
grid-area: 1 / 1 / 2 / 2;
|
|
454
|
+
min-height: 56px;
|
|
455
|
+
max-height: 200px;
|
|
456
|
+
padding: 1rem 1.25rem;
|
|
457
|
+
font: inherit;
|
|
458
|
+
font-size: 0.875rem;
|
|
459
|
+
line-height: 1.25rem;
|
|
460
|
+
white-space: pre-wrap;
|
|
461
|
+
overflow-wrap: anywhere;
|
|
462
|
+
border: 0;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* The trailing space the composer appends keeps a trailing newline visible. */
|
|
466
|
+
.composer-grow::after {
|
|
467
|
+
content: attr(data-value);
|
|
468
|
+
visibility: hidden;
|
|
469
|
+
overflow: hidden;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.composer-grow > textarea {
|
|
473
|
+
resize: none;
|
|
474
|
+
overflow-y: auto;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* Offscreen transcript prose is skipped by the browser entirely — no style,
|
|
478
|
+
* layout or paint until it scrolls near the viewport. A long conversation is
|
|
479
|
+
* mostly offscreen, so this is where the cost of a big DOM goes.
|
|
480
|
+
*
|
|
481
|
+
* Scoped to the message BODY rather than the whole message on purpose:
|
|
482
|
+
* content-visibility implies paint containment, which clips descendants to the
|
|
483
|
+
* border box and makes the element a containing block for fixed positioning.
|
|
484
|
+
* The share dropdown opens past the bottom of its message, so it stays outside
|
|
485
|
+
* this wrapper. Full-screen viewers are unaffected — they portal to the body.
|
|
486
|
+
*
|
|
487
|
+
* The `auto` in contain-intrinsic-size makes the browser remember each block's
|
|
488
|
+
* real height once measured, so scroll position stays honest after the first
|
|
489
|
+
* pass; 6rem is only the guess for blocks it has never rendered. */
|
|
490
|
+
.chat-message-body {
|
|
491
|
+
content-visibility: auto;
|
|
492
|
+
contain-intrinsic-size: auto 6rem;
|
|
493
|
+
}
|