@brainpilot/web 0.0.4 → 0.0.6
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/assets/index-Br55rkHb.css +1 -0
- package/dist/assets/index-CeUzk-ej.js +445 -0
- package/dist/index.html +2 -2
- package/index.html +13 -0
- package/package.json +12 -3
- package/src/App.tsx +10 -0
- package/src/__tests__/agentsReducer.test.ts +67 -0
- package/src/__tests__/api.test.ts +221 -0
- package/src/__tests__/chatScrollMemory.test.ts +49 -0
- package/src/__tests__/demoConversation.test.ts +73 -0
- package/src/__tests__/demoReset.test.ts +24 -0
- package/src/__tests__/messageGroups.test.ts +80 -0
- package/src/__tests__/newUiComponents.test.tsx +101 -0
- package/src/__tests__/newUiEvents.test.ts +236 -0
- package/src/__tests__/runningToast.test.ts +29 -0
- package/src/__tests__/tokenUsage.test.ts +48 -0
- package/src/__tests__/toolDisplay.test.ts +55 -0
- package/src/__tests__/traceReducer.test.ts +62 -0
- package/src/components/chat/AskUserCard.tsx +123 -0
- package/src/components/chat/AutoRetryIndicator.tsx +71 -0
- package/src/components/chat/ComposerInput.tsx +73 -0
- package/src/components/chat/ComposerSendButton.tsx +26 -0
- package/src/components/chat/MarkdownMessage.tsx +24 -0
- package/src/components/chat/MessageStream.tsx +505 -0
- package/src/components/chat/PromptComposer.tsx +489 -0
- package/src/components/chat/SystemMessageBubble.tsx +46 -0
- package/src/components/chat/chatScrollMemory.ts +49 -0
- package/src/components/demo/DemoFileTree.tsx +146 -0
- package/src/components/demo/DemoView.tsx +730 -0
- package/src/components/demo/TraceNodeModal.tsx +80 -0
- package/src/components/demo/demoBundle.ts +223 -0
- package/src/components/demo/demoCache.ts +42 -0
- package/src/components/demo/demoReset.ts +16 -0
- package/src/components/files/FilePreviewView.tsx +153 -0
- package/src/components/files/FileSidebar.tsx +664 -0
- package/src/components/files/filePreview.ts +113 -0
- package/src/components/primitives/CustomSelect.tsx +200 -0
- package/src/components/primitives/IconButton.tsx +27 -0
- package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
- package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
- package/src/components/quota/QuotaFileManager.tsx +197 -0
- package/src/components/search/SearchDialog.tsx +101 -0
- package/src/components/session/AgentNetwork.tsx +1233 -0
- package/src/components/session/AgentTraceViews.tsx +346 -0
- package/src/components/session/AnalyticsTab.tsx +220 -0
- package/src/components/session/GlobalOverview.tsx +108 -0
- package/src/components/session/NodeTooltip.tsx +127 -0
- package/src/components/session/TimelineTab.tsx +320 -0
- package/src/components/session/TraceGraphView.tsx +307 -0
- package/src/components/session/TraceNodeDetail.tsx +179 -0
- package/src/components/session/agentAnalytics.ts +397 -0
- package/src/components/session/agentNetworkShared.ts +339 -0
- package/src/components/session/traceLayout.ts +182 -0
- package/src/components/settings/SettingsDialog.tsx +737 -0
- package/src/components/shell/DesktopShell.tsx +261 -0
- package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
- package/src/components/shell/SandboxStatus.tsx +287 -0
- package/src/components/shell/TerminalDrawer.tsx +387 -0
- package/src/components/sidebar/Sidebar.tsx +191 -0
- package/src/config.ts +10 -0
- package/src/contexts/AppProviders.tsx +20 -0
- package/src/contexts/AuthContext.tsx +61 -0
- package/src/contexts/PreferencesContext.tsx +125 -0
- package/src/contexts/SSEContext.tsx +264 -0
- package/src/contexts/SandboxContext.tsx +310 -0
- package/src/contexts/SessionContext.tsx +919 -0
- package/src/contexts/agentsReducer.ts +49 -0
- package/src/contexts/draftStore.ts +103 -0
- package/src/contexts/messageFilters.ts +29 -0
- package/src/contexts/messageGroups.ts +77 -0
- package/src/contexts/messageReducer.ts +401 -0
- package/src/contexts/newUiEvents.ts +190 -0
- package/src/contexts/runningToast.ts +33 -0
- package/src/contexts/traceReducer.ts +62 -0
- package/src/contexts/turnTimer.test.ts +97 -0
- package/src/contexts/turnTimer.ts +108 -0
- package/src/contexts/useTurnTimer.ts +104 -0
- package/src/contracts/backend.ts +897 -0
- package/src/contracts/demoBundle.ts +83 -0
- package/src/i18n/messages/analytics.ts +106 -0
- package/src/i18n/messages/chat.ts +130 -0
- package/src/i18n/messages/contexts.ts +42 -0
- package/src/i18n/messages/demo.ts +80 -0
- package/src/i18n/messages/files.ts +82 -0
- package/src/i18n/messages/network.ts +190 -0
- package/src/i18n/messages/profile.ts +44 -0
- package/src/i18n/messages/quota.ts +36 -0
- package/src/i18n/messages/sandbox.ts +116 -0
- package/src/i18n/messages/search.ts +16 -0
- package/src/i18n/messages/settings.ts +188 -0
- package/src/i18n/messages/shell.ts +38 -0
- package/src/i18n/messages/sidebar.ts +52 -0
- package/src/i18n/messages/terminal.ts +22 -0
- package/src/i18n/messages/trace.ts +136 -0
- package/src/i18n/messages.ts +32 -0
- package/src/i18n/translate.ts +46 -0
- package/src/i18n/types.ts +15 -0
- package/src/i18n/useT.ts +15 -0
- package/src/main.tsx +13 -0
- package/src/mocks/backend.ts +729 -0
- package/src/styles/global.css +7578 -0
- package/src/styles/tokens.css +161 -0
- package/src/utils/api.ts +724 -0
- package/src/utils/download.ts +18 -0
- package/src/utils/format.ts +7 -0
- package/src/utils/toolDisplay.ts +74 -0
- package/src/utils/zip.ts +119 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.app.json +22 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +13 -0
- package/vite.config.ts +13 -0
- package/dist/assets/index-Cd0Mi_WU.css +0 -1
- package/dist/assets/index-FGg-DeYR.js +0 -448
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Minus, Plus, RefreshCw, Trash2, X } from "lucide-react";
|
|
3
|
+
import { runtimeConfig } from "../../config";
|
|
4
|
+
import { useAuth } from "../../contexts/AuthContext";
|
|
5
|
+
import { useSandbox } from "../../contexts/SandboxContext";
|
|
6
|
+
import { mockInitialTerminalOutput, mockTerminalResponse } from "../../mocks/backend";
|
|
7
|
+
import { getTerminalWsUrl } from "../../utils/api";
|
|
8
|
+
import { useT } from "../../i18n/useT";
|
|
9
|
+
import { tg } from "../../i18n/translate";
|
|
10
|
+
|
|
11
|
+
export type TerminalTab = {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type TerminalDrawerProps = {
|
|
18
|
+
activeTabId: string | null;
|
|
19
|
+
isMinimized: boolean;
|
|
20
|
+
onActivateTab: (terminalId: string) => void;
|
|
21
|
+
onCloseTab: (terminalId: string) => void;
|
|
22
|
+
onMinimize: () => void;
|
|
23
|
+
onNewTab: () => void;
|
|
24
|
+
tabs: TerminalTab[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const MIN_TERMINAL_HEIGHT = 220;
|
|
28
|
+
const DEFAULT_TERMINAL_HEIGHT = 320;
|
|
29
|
+
const TERMINAL_CHAR_WIDTH = 8;
|
|
30
|
+
const TERMINAL_LINE_HEIGHT = 21;
|
|
31
|
+
|
|
32
|
+
type TerminalConnectionState = "idle" | "connecting" | "connected" | "error" | "closed";
|
|
33
|
+
|
|
34
|
+
const terminalConnectionLabel: Record<TerminalConnectionState, string> = {
|
|
35
|
+
idle: "Idle",
|
|
36
|
+
connecting: "Connecting",
|
|
37
|
+
connected: "Connected",
|
|
38
|
+
error: "Error",
|
|
39
|
+
closed: "Closed",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function TerminalDrawer({
|
|
43
|
+
activeTabId,
|
|
44
|
+
isMinimized,
|
|
45
|
+
onActivateTab,
|
|
46
|
+
onCloseTab,
|
|
47
|
+
onMinimize,
|
|
48
|
+
onNewTab,
|
|
49
|
+
tabs,
|
|
50
|
+
}: TerminalDrawerProps) {
|
|
51
|
+
const t = useT();
|
|
52
|
+
const { isAuthReady } = useAuth();
|
|
53
|
+
const { currentSandbox } = useSandbox();
|
|
54
|
+
const [height, setHeight] = useState(DEFAULT_TERMINAL_HEIGHT);
|
|
55
|
+
const [loadingIds, setLoadingIds] = useState<Set<string>>(new Set());
|
|
56
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
57
|
+
const [outputByTab, setOutputByTab] = useState<Record<string, string>>({});
|
|
58
|
+
const [connectionByTab, setConnectionByTab] = useState<Record<string, TerminalConnectionState>>({});
|
|
59
|
+
const [reconnectKey, setReconnectKey] = useState(0);
|
|
60
|
+
const [input, setInput] = useState("");
|
|
61
|
+
const [terminalError, setTerminalError] = useState<string | null>(null);
|
|
62
|
+
const knownTabIdsRef = useRef<Set<string>>(new Set());
|
|
63
|
+
const dragStartRef = useRef<{ height: number; y: number } | null>(null);
|
|
64
|
+
const terminalScreenRef = useRef<HTMLPreElement | null>(null);
|
|
65
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
tabs.forEach((tab) => {
|
|
69
|
+
if (knownTabIdsRef.current.has(tab.id)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
knownTabIdsRef.current.add(tab.id);
|
|
74
|
+
setLoadingIds((current) => new Set(current).add(tab.id));
|
|
75
|
+
|
|
76
|
+
window.setTimeout(() => {
|
|
77
|
+
setLoadingIds((current) => {
|
|
78
|
+
const next = new Set(current);
|
|
79
|
+
next.delete(tab.id);
|
|
80
|
+
return next;
|
|
81
|
+
});
|
|
82
|
+
}, 700);
|
|
83
|
+
});
|
|
84
|
+
}, [tabs]);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (!isDragging) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
92
|
+
if (!dragStartRef.current) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const maxTerminalHeight = Math.min(window.innerHeight * 0.72, 720);
|
|
97
|
+
const nextHeight = dragStartRef.current.height + (dragStartRef.current.y - event.clientY);
|
|
98
|
+
setHeight(Math.max(MIN_TERMINAL_HEIGHT, Math.min(maxTerminalHeight, nextHeight)));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const handleMouseUp = () => {
|
|
102
|
+
setIsDragging(false);
|
|
103
|
+
dragStartRef.current = null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
107
|
+
window.addEventListener("mouseup", handleMouseUp);
|
|
108
|
+
return () => {
|
|
109
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
110
|
+
window.removeEventListener("mouseup", handleMouseUp);
|
|
111
|
+
};
|
|
112
|
+
}, [isDragging]);
|
|
113
|
+
|
|
114
|
+
const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? tabs[0];
|
|
115
|
+
const isOpen = tabs.length > 0 && !isMinimized;
|
|
116
|
+
const isLoading = activeTab ? loadingIds.has(activeTab.id) : false;
|
|
117
|
+
const isReady = !!activeTab && isAuthReady && currentSandbox?.status === "running";
|
|
118
|
+
const activeConnection = activeTab ? (connectionByTab[activeTab.id] ?? "idle") : "idle";
|
|
119
|
+
|
|
120
|
+
const getTerminalSize = () => {
|
|
121
|
+
const rect = terminalScreenRef.current?.getBoundingClientRect();
|
|
122
|
+
const width = rect?.width ?? 640;
|
|
123
|
+
const screenHeight = rect?.height ?? Math.max(160, height - 86);
|
|
124
|
+
return {
|
|
125
|
+
cols: Math.max(40, Math.floor((width - 24) / TERMINAL_CHAR_WIDTH)),
|
|
126
|
+
rows: Math.max(8, Math.floor((screenHeight - 24) / TERMINAL_LINE_HEIGHT)),
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const sendTerminalResize = () => {
|
|
131
|
+
if (runtimeConfig.useMockBackend || wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
wsRef.current.send(JSON.stringify({ type: "resize", ...getTerminalSize() }));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!isOpen || !activeTab || !isAuthReady || !currentSandbox || currentSandbox.status !== "running") {
|
|
139
|
+
wsRef.current?.close();
|
|
140
|
+
wsRef.current = null;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (runtimeConfig.useMockBackend) {
|
|
145
|
+
setTerminalError(null);
|
|
146
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: "connected" }));
|
|
147
|
+
setLoadingIds((current) => {
|
|
148
|
+
const next = new Set(current);
|
|
149
|
+
next.delete(activeTab.id);
|
|
150
|
+
return next;
|
|
151
|
+
});
|
|
152
|
+
setOutputByTab((current) => ({
|
|
153
|
+
...current,
|
|
154
|
+
[activeTab.id]: current[activeTab.id] || mockInitialTerminalOutput(),
|
|
155
|
+
}));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
setTerminalError(null);
|
|
160
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: "connecting" }));
|
|
161
|
+
setLoadingIds((current) => new Set(current).add(activeTab.id));
|
|
162
|
+
const { cols, rows } = getTerminalSize();
|
|
163
|
+
const ws = new WebSocket(getTerminalWsUrl(currentSandbox.id, cols, rows));
|
|
164
|
+
wsRef.current = ws;
|
|
165
|
+
|
|
166
|
+
ws.onopen = () => {
|
|
167
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: "connected" }));
|
|
168
|
+
setLoadingIds((current) => {
|
|
169
|
+
const next = new Set(current);
|
|
170
|
+
next.delete(activeTab.id);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
setOutputByTab((current) => ({
|
|
174
|
+
...current,
|
|
175
|
+
[activeTab.id]: current[activeTab.id] || "$ ",
|
|
176
|
+
}));
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
ws.onmessage = async (event) => {
|
|
180
|
+
let text = "";
|
|
181
|
+
if (event.data instanceof Blob) {
|
|
182
|
+
text = await event.data.text();
|
|
183
|
+
} else if (typeof event.data === "string") {
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(event.data) as { message?: string };
|
|
186
|
+
text = parsed.message ? `\n${parsed.message}\n` : event.data;
|
|
187
|
+
} catch {
|
|
188
|
+
text = event.data;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
setOutputByTab((current) => ({
|
|
192
|
+
...current,
|
|
193
|
+
[activeTab.id]: `${current[activeTab.id] || ""}${text}`,
|
|
194
|
+
}));
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
ws.onerror = () => {
|
|
198
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: "error" }));
|
|
199
|
+
setTerminalError(tg("terminal.connectFailed"));
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
ws.onclose = () => {
|
|
203
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: current[activeTab.id] === "error" ? "error" : "closed" }));
|
|
204
|
+
setLoadingIds((current) => {
|
|
205
|
+
const next = new Set(current);
|
|
206
|
+
next.delete(activeTab.id);
|
|
207
|
+
return next;
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return () => {
|
|
212
|
+
ws.close();
|
|
213
|
+
if (wsRef.current === ws) {
|
|
214
|
+
wsRef.current = null;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
}, [activeTab, currentSandbox, isOpen, reconnectKey, isAuthReady]);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (!isOpen || !activeTab || activeConnection !== "connected") {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
sendTerminalResize();
|
|
224
|
+
}, [activeConnection, activeTab?.id, height, isOpen]);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
const handleResize = () => sendTerminalResize();
|
|
228
|
+
window.addEventListener("resize", handleResize);
|
|
229
|
+
return () => window.removeEventListener("resize", handleResize);
|
|
230
|
+
}, []);
|
|
231
|
+
|
|
232
|
+
const clearTerminal = () => {
|
|
233
|
+
if (!activeTab) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
setOutputByTab((current) => ({ ...current, [activeTab.id]: "$ " }));
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const reconnectTerminal = () => {
|
|
240
|
+
if (!activeTab) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (runtimeConfig.useMockBackend) {
|
|
244
|
+
setOutputByTab((current) => ({
|
|
245
|
+
...current,
|
|
246
|
+
[activeTab.id]: `${current[activeTab.id] || ""}\nMock terminal reconnected.\n$ `,
|
|
247
|
+
}));
|
|
248
|
+
setConnectionByTab((current) => ({ ...current, [activeTab.id]: "connected" }));
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
wsRef.current?.close();
|
|
252
|
+
setReconnectKey((current) => current + 1);
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
const sendTerminalInput = () => {
|
|
256
|
+
if (!input || !activeTab) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (runtimeConfig.useMockBackend) {
|
|
260
|
+
setOutputByTab((current) => ({
|
|
261
|
+
...current,
|
|
262
|
+
[activeTab.id]: `${current[activeTab.id] || ""}${input}\n${mockTerminalResponse(input)}`,
|
|
263
|
+
}));
|
|
264
|
+
setInput("");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
wsRef.current.send(`${input}\n`);
|
|
271
|
+
setOutputByTab((current) => ({
|
|
272
|
+
...current,
|
|
273
|
+
[activeTab.id]: `${current[activeTab.id] || ""}${input}\n`,
|
|
274
|
+
}));
|
|
275
|
+
setInput("");
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<section
|
|
280
|
+
aria-hidden={!isOpen}
|
|
281
|
+
aria-label={t("terminal.aria.drawer")}
|
|
282
|
+
className={`terminal-drawer ${isOpen ? "is-open" : ""} ${isDragging ? "is-resizing" : ""}`}
|
|
283
|
+
style={{ height }}
|
|
284
|
+
>
|
|
285
|
+
<div
|
|
286
|
+
aria-label={t("terminal.aria.resize")}
|
|
287
|
+
className="terminal-drawer__resize-handle"
|
|
288
|
+
onMouseDown={(event) => {
|
|
289
|
+
dragStartRef.current = { height, y: event.clientY };
|
|
290
|
+
setIsDragging(true);
|
|
291
|
+
}}
|
|
292
|
+
role="separator"
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
<div className="terminal-drawer__tabs">
|
|
296
|
+
<div className="terminal-drawer__tab-strip" role="tablist">
|
|
297
|
+
{tabs.map((tab) => {
|
|
298
|
+
const tabIsActive = tab.id === activeTab?.id;
|
|
299
|
+
const tabIsLoading = loadingIds.has(tab.id);
|
|
300
|
+
const tabConnection = connectionByTab[tab.id] ?? "idle";
|
|
301
|
+
|
|
302
|
+
return (
|
|
303
|
+
<div className={`terminal-tab ${tabIsActive ? "is-active" : ""}`} key={tab.id}>
|
|
304
|
+
<button
|
|
305
|
+
aria-selected={tabIsActive}
|
|
306
|
+
className="terminal-tab__select"
|
|
307
|
+
onClick={() => onActivateTab(tab.id)}
|
|
308
|
+
role="tab"
|
|
309
|
+
type="button"
|
|
310
|
+
>
|
|
311
|
+
<span
|
|
312
|
+
className={`terminal-tab__status ${
|
|
313
|
+
tabIsLoading || tabConnection === "connecting"
|
|
314
|
+
? "is-loading"
|
|
315
|
+
: tabConnection === "error"
|
|
316
|
+
? "is-error"
|
|
317
|
+
: tabConnection === "connected"
|
|
318
|
+
? "is-connected"
|
|
319
|
+
: ""
|
|
320
|
+
}`}
|
|
321
|
+
/>
|
|
322
|
+
<span>{tab.title}</span>
|
|
323
|
+
</button>
|
|
324
|
+
<button
|
|
325
|
+
aria-label={`Close ${tab.title}`}
|
|
326
|
+
className="terminal-tab__close"
|
|
327
|
+
onClick={() => onCloseTab(tab.id)}
|
|
328
|
+
type="button"
|
|
329
|
+
>
|
|
330
|
+
<X size={13} />
|
|
331
|
+
</button>
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
})}
|
|
335
|
+
<button aria-label={t("terminal.aria.newTab")} className="terminal-tab-add" onClick={onNewTab} type="button">
|
|
336
|
+
<Plus size={14} />
|
|
337
|
+
</button>
|
|
338
|
+
</div>
|
|
339
|
+
<div className="terminal-drawer__tools">
|
|
340
|
+
<button aria-label={t("terminal.aria.clear")} onClick={clearTerminal} type="button">
|
|
341
|
+
<Trash2 size={14} />
|
|
342
|
+
</button>
|
|
343
|
+
<button aria-label={t("terminal.aria.reconnect")} onClick={reconnectTerminal} type="button">
|
|
344
|
+
<RefreshCw size={14} />
|
|
345
|
+
</button>
|
|
346
|
+
<button aria-label={t("terminal.aria.minimize")} onClick={onMinimize} type="button">
|
|
347
|
+
<Minus size={15} />
|
|
348
|
+
</button>
|
|
349
|
+
</div>
|
|
350
|
+
</div>
|
|
351
|
+
|
|
352
|
+
<div className="terminal-drawer__body">
|
|
353
|
+
{isLoading || !activeTab ? (
|
|
354
|
+
<div className="terminal-loading">
|
|
355
|
+
<span className="terminal-loading__dot" />
|
|
356
|
+
<span>Connecting to sandbox terminal...</span>
|
|
357
|
+
</div>
|
|
358
|
+
) : !isReady ? (
|
|
359
|
+
<div className="terminal-loading">
|
|
360
|
+
<span className="terminal-loading__dot" />
|
|
361
|
+
<span>Start a running sandbox to open the terminal.</span>
|
|
362
|
+
</div>
|
|
363
|
+
) : (
|
|
364
|
+
<>
|
|
365
|
+
<div className="terminal-statusbar">
|
|
366
|
+
<span>{activeTab.cwd}</span>
|
|
367
|
+
<span>{terminalConnectionLabel[activeConnection]}</span>
|
|
368
|
+
</div>
|
|
369
|
+
<pre className="terminal-screen" aria-label={`${activeTab.title} output`} ref={terminalScreenRef}>
|
|
370
|
+
<code>{terminalError ? `${terminalError}\n` : outputByTab[activeTab.id] || "$ "}</code>
|
|
371
|
+
</pre>
|
|
372
|
+
<form
|
|
373
|
+
className="terminal-input"
|
|
374
|
+
onSubmit={(event) => {
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
sendTerminalInput();
|
|
377
|
+
}}
|
|
378
|
+
>
|
|
379
|
+
<span>$</span>
|
|
380
|
+
<input value={input} onChange={(event) => setInput(event.target.value)} spellCheck={false} />
|
|
381
|
+
</form>
|
|
382
|
+
</>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</section>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Check,
|
|
3
|
+
MessageCircle,
|
|
4
|
+
MessageSquarePlus,
|
|
5
|
+
MonitorPlay,
|
|
6
|
+
PanelLeft,
|
|
7
|
+
PenLine,
|
|
8
|
+
Search,
|
|
9
|
+
Settings,
|
|
10
|
+
Trash2,
|
|
11
|
+
X,
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
import { FormEvent, useState } from "react";
|
|
14
|
+
import { useSessions } from "../../contexts/SessionContext";
|
|
15
|
+
import { useT } from "../../i18n/useT";
|
|
16
|
+
import { IconButton } from "../primitives/IconButton";
|
|
17
|
+
|
|
18
|
+
type SidebarProps = {
|
|
19
|
+
isCollapsed: boolean;
|
|
20
|
+
activePage: "workspace" | "demo";
|
|
21
|
+
onOpenDemo: () => void;
|
|
22
|
+
onGoWorkspace: () => void;
|
|
23
|
+
onOpenSettings: () => void;
|
|
24
|
+
onOpenSearch: () => void;
|
|
25
|
+
onResizeStart: (pointerX: number) => void;
|
|
26
|
+
onToggle: () => void;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function Sidebar({ isCollapsed, activePage, onOpenDemo, onGoWorkspace, onOpenSettings, onOpenSearch, onResizeStart, onToggle }: SidebarProps) {
|
|
30
|
+
const {
|
|
31
|
+
sessions,
|
|
32
|
+
currentSession,
|
|
33
|
+
isLoading,
|
|
34
|
+
startDraftSession,
|
|
35
|
+
selectSession,
|
|
36
|
+
updateSessionTitle,
|
|
37
|
+
deleteSession,
|
|
38
|
+
} = useSessions();
|
|
39
|
+
const t = useT();
|
|
40
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
41
|
+
const [editingTitle, setEditingTitle] = useState("");
|
|
42
|
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
const submitRename = async (event: FormEvent) => {
|
|
45
|
+
event.preventDefault();
|
|
46
|
+
if (!editingId || !editingTitle.trim()) {
|
|
47
|
+
setEditingId(null);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await updateSessionTitle(editingId, editingTitle.trim());
|
|
51
|
+
setEditingId(null);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<aside className="sidebar" aria-label={t("sidebar.aria.nav")}>
|
|
56
|
+
<div
|
|
57
|
+
aria-label={t("sidebar.aria.resize")}
|
|
58
|
+
className="sidebar__resize-handle"
|
|
59
|
+
onPointerDown={(event) => {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
onResizeStart(event.clientX);
|
|
62
|
+
}}
|
|
63
|
+
role="separator"
|
|
64
|
+
/>
|
|
65
|
+
|
|
66
|
+
<div className="sidebar__topbar">
|
|
67
|
+
<IconButton
|
|
68
|
+
aria-expanded={!isCollapsed}
|
|
69
|
+
label={isCollapsed ? t("sidebar.aria.expand") : t("sidebar.aria.collapse")}
|
|
70
|
+
onClick={onToggle}
|
|
71
|
+
>
|
|
72
|
+
<PanelLeft size={16} />
|
|
73
|
+
</IconButton>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<nav className="sidebar__nav" aria-label={t("sidebar.aria.primary")}>
|
|
77
|
+
<button className="nav-item nav-item--strong" onClick={() => { onGoWorkspace(); startDraftSession(); }} type="button">
|
|
78
|
+
<PenLine size={16} />
|
|
79
|
+
<span>{t("sidebar.newChat")}</span>
|
|
80
|
+
</button>
|
|
81
|
+
{/*
|
|
82
|
+
issue #44: 插件 / 自动化 have no view yet — as clickable no-op buttons
|
|
83
|
+
they read as broken navigation. Hidden until the views exist; the
|
|
84
|
+
i18n keys (sidebar.plugins / sidebar.automations) are kept. Re-add the
|
|
85
|
+
Plug / Clock3 lucide imports when restoring these.
|
|
86
|
+
<button className="nav-item" type="button">
|
|
87
|
+
<Plug size={16} />
|
|
88
|
+
<span>{t("sidebar.plugins")}</span>
|
|
89
|
+
</button>
|
|
90
|
+
<button className="nav-item" type="button">
|
|
91
|
+
<Clock3 size={16} />
|
|
92
|
+
<span>{t("sidebar.automations")}</span>
|
|
93
|
+
</button>
|
|
94
|
+
*/}
|
|
95
|
+
<button
|
|
96
|
+
className={`nav-item ${activePage === "demo" ? "is-active" : ""}`}
|
|
97
|
+
onClick={onOpenDemo}
|
|
98
|
+
type="button"
|
|
99
|
+
>
|
|
100
|
+
<MonitorPlay size={16} />
|
|
101
|
+
<span>{t("sidebar.demo")}</span>
|
|
102
|
+
</button>
|
|
103
|
+
</nav>
|
|
104
|
+
|
|
105
|
+
<section className="sidebar-section sidebar-section--conversations" aria-labelledby="conversations-heading">
|
|
106
|
+
<div className="section-heading">
|
|
107
|
+
<h2 id="conversations-heading">{t("sidebar.conversations")}</h2>
|
|
108
|
+
<div className="section-heading__actions">
|
|
109
|
+
<IconButton label={t("sidebar.aria.newConversation")} onClick={() => { onGoWorkspace(); startDraftSession(); }}>
|
|
110
|
+
<MessageSquarePlus size={13} />
|
|
111
|
+
</IconButton>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div className="conversation-stack">
|
|
116
|
+
<button className="conversation-search-trigger" onClick={onOpenSearch} type="button">
|
|
117
|
+
<Search size={14} />
|
|
118
|
+
<span>{t("sidebar.search")}</span>
|
|
119
|
+
</button>
|
|
120
|
+
<p className="muted-label">{isLoading ? t("sidebar.loading") : t("sidebar.sessionCount", { count: sessions.length })}</p>
|
|
121
|
+
{sessions.length === 0 && !isLoading ? <p className="sidebar-empty">{t("sidebar.empty")}</p> : null}
|
|
122
|
+
{sessions.map((session) => {
|
|
123
|
+
const isEditing = editingId === session.id;
|
|
124
|
+
const isConfirming = confirmDeleteId === session.id;
|
|
125
|
+
return (
|
|
126
|
+
<div className={`conversation-item ${currentSession?.id === session.id ? "is-active" : ""}`} key={session.id}>
|
|
127
|
+
{isEditing ? (
|
|
128
|
+
<form className="conversation-edit" onSubmit={submitRename}>
|
|
129
|
+
<input
|
|
130
|
+
autoFocus
|
|
131
|
+
onChange={(event) => setEditingTitle(event.target.value)}
|
|
132
|
+
value={editingTitle}
|
|
133
|
+
/>
|
|
134
|
+
<IconButton label={t("sidebar.aria.saveTitle")} type="submit">
|
|
135
|
+
<Check size={14} />
|
|
136
|
+
</IconButton>
|
|
137
|
+
<IconButton label={t("sidebar.aria.cancelRename")} onClick={() => setEditingId(null)}>
|
|
138
|
+
<X size={14} />
|
|
139
|
+
</IconButton>
|
|
140
|
+
</form>
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<button className="conversation-row" onClick={() => { onGoWorkspace(); selectSession(session.id); }} type="button">
|
|
144
|
+
<MessageCircle size={16} />
|
|
145
|
+
<span>{session.title}</span>
|
|
146
|
+
<small>{new Date(session.updatedAt).toLocaleDateString()}</small>
|
|
147
|
+
</button>
|
|
148
|
+
<div className="conversation-actions">
|
|
149
|
+
{isConfirming ? (
|
|
150
|
+
<>
|
|
151
|
+
<IconButton label={t("sidebar.aria.confirmDelete")} onClick={() => void deleteSession(session.id)}>
|
|
152
|
+
<Check size={14} />
|
|
153
|
+
</IconButton>
|
|
154
|
+
<IconButton label={t("sidebar.aria.cancelDelete")} onClick={() => setConfirmDeleteId(null)}>
|
|
155
|
+
<X size={14} />
|
|
156
|
+
</IconButton>
|
|
157
|
+
</>
|
|
158
|
+
) : (
|
|
159
|
+
<>
|
|
160
|
+
<IconButton
|
|
161
|
+
label={t("sidebar.aria.rename")}
|
|
162
|
+
onClick={() => {
|
|
163
|
+
setEditingId(session.id);
|
|
164
|
+
setEditingTitle(session.title);
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<PenLine size={14} />
|
|
168
|
+
</IconButton>
|
|
169
|
+
<IconButton label={t("sidebar.aria.delete")} onClick={() => setConfirmDeleteId(session.id)}>
|
|
170
|
+
<Trash2 size={14} />
|
|
171
|
+
</IconButton>
|
|
172
|
+
</>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
</>
|
|
176
|
+
)}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
})}
|
|
180
|
+
</div>
|
|
181
|
+
</section>
|
|
182
|
+
|
|
183
|
+
<div className="sidebar__footer">
|
|
184
|
+
<button className="nav-item" onClick={onOpenSettings} type="button">
|
|
185
|
+
<Settings size={16} />
|
|
186
|
+
<span>{t("sidebar.settings")}</span>
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</aside>
|
|
190
|
+
);
|
|
191
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const runtimeConfig = {
|
|
2
|
+
useMockBackend: import.meta.env.VITE_USE_MOCK_BACKEND === "1",
|
|
3
|
+
// Local single-user mode is ON by default: there is no container lifecycle to
|
|
4
|
+
// manage — the runtime launched by `brainpilot up` IS the sandbox, and the
|
|
5
|
+
// active session id addresses its workspace. Sandbox list/create/rebuild/
|
|
6
|
+
// destroy/stats/logs are skipped. A downstream multi-user deployment with a
|
|
7
|
+
// real per-user sandbox orchestrator opts out by building with
|
|
8
|
+
// VITE_LOCAL_MODE=0.
|
|
9
|
+
localMode: import.meta.env.VITE_LOCAL_MODE !== "0",
|
|
10
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { AuthProvider } from "./AuthContext";
|
|
3
|
+
import { PreferencesProvider } from "./PreferencesContext";
|
|
4
|
+
import { SandboxProvider } from "./SandboxContext";
|
|
5
|
+
import { SessionProvider } from "./SessionContext";
|
|
6
|
+
import { SSEProvider } from "./SSEContext";
|
|
7
|
+
|
|
8
|
+
export function AppProviders({ children }: { children: ReactNode }) {
|
|
9
|
+
return (
|
|
10
|
+
<PreferencesProvider>
|
|
11
|
+
<AuthProvider>
|
|
12
|
+
<SandboxProvider>
|
|
13
|
+
<SSEProvider>
|
|
14
|
+
<SessionProvider>{children}</SessionProvider>
|
|
15
|
+
</SSEProvider>
|
|
16
|
+
</SandboxProvider>
|
|
17
|
+
</AuthProvider>
|
|
18
|
+
</PreferencesProvider>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createContext, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { User } from "../contracts/backend";
|
|
3
|
+
import { api } from "../utils/api";
|
|
4
|
+
|
|
5
|
+
interface AuthContextValue {
|
|
6
|
+
/** The current identity, resolved from the upstream gateway via GET /api/auth/me. */
|
|
7
|
+
user: User | null;
|
|
8
|
+
/** True once the identity bootstrap has settled (success or redirect-in-flight). */
|
|
9
|
+
isAuthReady: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Trust-front auth: the open-source frontend does NOT authenticate. The hosted
|
|
16
|
+
* gateway owns auth and carries identity via an httpOnly cookie. We only read the
|
|
17
|
+
* resolved identity (GET /api/auth/me) for display. There is no login/register/
|
|
18
|
+
* logout UI here.
|
|
19
|
+
*/
|
|
20
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
21
|
+
const [user, setUser] = useState<User | null>(null);
|
|
22
|
+
const [isAuthReady, setIsAuthReady] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
let cancelled = false;
|
|
26
|
+
|
|
27
|
+
async function bootstrap() {
|
|
28
|
+
try {
|
|
29
|
+
const currentUser = await api.auth.me();
|
|
30
|
+
if (!cancelled) {
|
|
31
|
+
setUser(currentUser);
|
|
32
|
+
setIsAuthReady(true);
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
if (cancelled) return;
|
|
36
|
+
// No identity resolved. In self-hosted `bp --up` there is no hosted
|
|
37
|
+
// gateway/login to hand off to, so redirecting here would loop (#38).
|
|
38
|
+
// Fall back to a local anonymous identity and let the app render.
|
|
39
|
+
setUser({ id: "local", username: "local", createdAt: new Date(0).toISOString() });
|
|
40
|
+
setIsAuthReady(true);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void bootstrap();
|
|
45
|
+
return () => {
|
|
46
|
+
cancelled = true;
|
|
47
|
+
};
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const value = useMemo<AuthContextValue>(() => ({ user, isAuthReady }), [user, isAuthReady]);
|
|
51
|
+
|
|
52
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function useAuth() {
|
|
56
|
+
const value = useContext(AuthContext);
|
|
57
|
+
if (!value) {
|
|
58
|
+
throw new Error("useAuth must be used within AuthProvider");
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|