@brainpilot/web 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/assets/index-C-8G4D4j.js +448 -0
  2. package/dist/assets/index-C501m5OS.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +9 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/api.test.ts +103 -0
  8. package/src/__tests__/messageGroups.test.ts +80 -0
  9. package/src/__tests__/newUiComponents.test.tsx +101 -0
  10. package/src/__tests__/newUiEvents.test.ts +236 -0
  11. package/src/components/chat/AskUserCard.tsx +123 -0
  12. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  13. package/src/components/chat/ComposerInput.tsx +73 -0
  14. package/src/components/chat/ComposerSendButton.tsx +26 -0
  15. package/src/components/chat/MarkdownMessage.tsx +24 -0
  16. package/src/components/chat/MessageStream.tsx +464 -0
  17. package/src/components/chat/PromptComposer.tsx +398 -0
  18. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  19. package/src/components/demo/DemoFileTree.tsx +146 -0
  20. package/src/components/demo/DemoView.tsx +668 -0
  21. package/src/components/demo/TraceNodeModal.tsx +76 -0
  22. package/src/components/demo/demoBundle.ts +218 -0
  23. package/src/components/demo/demoCache.ts +42 -0
  24. package/src/components/files/FilePreviewView.tsx +153 -0
  25. package/src/components/files/FileSidebar.tsx +664 -0
  26. package/src/components/files/filePreview.ts +113 -0
  27. package/src/components/primitives/CustomSelect.tsx +200 -0
  28. package/src/components/primitives/IconButton.tsx +27 -0
  29. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  30. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  31. package/src/components/quota/QuotaFileManager.tsx +197 -0
  32. package/src/components/search/SearchDialog.tsx +101 -0
  33. package/src/components/session/AgentNetwork.tsx +1240 -0
  34. package/src/components/session/AgentTraceViews.tsx +381 -0
  35. package/src/components/session/AnalyticsTab.tsx +386 -0
  36. package/src/components/session/GlobalOverview.tsx +108 -0
  37. package/src/components/session/NodeTooltip.tsx +127 -0
  38. package/src/components/session/TimelineTab.tsx +320 -0
  39. package/src/components/session/TraceGraphView.tsx +301 -0
  40. package/src/components/session/TraceNodeDetail.tsx +142 -0
  41. package/src/components/session/agentAnalytics.ts +397 -0
  42. package/src/components/session/agentNetworkShared.ts +329 -0
  43. package/src/components/session/traceLayout.ts +150 -0
  44. package/src/components/settings/SettingsDialog.tsx +719 -0
  45. package/src/components/shell/DesktopShell.tsx +236 -0
  46. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  47. package/src/components/shell/SandboxStatus.tsx +287 -0
  48. package/src/components/shell/TerminalDrawer.tsx +387 -0
  49. package/src/components/sidebar/Sidebar.tsx +187 -0
  50. package/src/config.ts +10 -0
  51. package/src/contexts/AppProviders.tsx +20 -0
  52. package/src/contexts/AuthContext.tsx +61 -0
  53. package/src/contexts/PreferencesContext.tsx +125 -0
  54. package/src/contexts/SSEContext.tsx +175 -0
  55. package/src/contexts/SandboxContext.tsx +310 -0
  56. package/src/contexts/SessionContext.tsx +608 -0
  57. package/src/contexts/draftStore.ts +103 -0
  58. package/src/contexts/messageFilters.ts +29 -0
  59. package/src/contexts/messageGroups.ts +77 -0
  60. package/src/contexts/messageReducer.ts +401 -0
  61. package/src/contexts/newUiEvents.ts +190 -0
  62. package/src/contracts/backend.ts +846 -0
  63. package/src/contracts/demoBundle.ts +83 -0
  64. package/src/i18n/messages/analytics.ts +96 -0
  65. package/src/i18n/messages/chat.ts +108 -0
  66. package/src/i18n/messages/contexts.ts +40 -0
  67. package/src/i18n/messages/demo.ts +80 -0
  68. package/src/i18n/messages/files.ts +82 -0
  69. package/src/i18n/messages/network.ts +186 -0
  70. package/src/i18n/messages/profile.ts +40 -0
  71. package/src/i18n/messages/quota.ts +36 -0
  72. package/src/i18n/messages/sandbox.ts +116 -0
  73. package/src/i18n/messages/search.ts +16 -0
  74. package/src/i18n/messages/settings.ts +184 -0
  75. package/src/i18n/messages/shell.ts +38 -0
  76. package/src/i18n/messages/sidebar.ts +52 -0
  77. package/src/i18n/messages/terminal.ts +22 -0
  78. package/src/i18n/messages/trace.ts +84 -0
  79. package/src/i18n/messages.ts +32 -0
  80. package/src/i18n/translate.ts +46 -0
  81. package/src/i18n/types.ts +15 -0
  82. package/src/i18n/useT.ts +15 -0
  83. package/src/main.tsx +13 -0
  84. package/src/mocks/backend.ts +722 -0
  85. package/src/styles/global.css +7429 -0
  86. package/src/styles/tokens.css +161 -0
  87. package/src/utils/api.ts +627 -0
  88. package/src/utils/download.ts +18 -0
  89. package/src/utils/format.ts +7 -0
  90. package/src/utils/zip.ts +119 -0
  91. package/src/vite-env.d.ts +1 -0
  92. package/tsconfig.app.json +22 -0
  93. package/tsconfig.json +7 -0
  94. package/tsconfig.node.json +13 -0
  95. package/vite.config.ts +13 -0
  96. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  97. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,236 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Bot, FolderOpen, GitBranch, MessageSquare, RefreshCw } from "lucide-react";
3
+ import { useAuth } from "../../contexts/AuthContext";
4
+ import { useSandbox } from "../../contexts/SandboxContext";
5
+ import { useSessions } from "../../contexts/SessionContext";
6
+ import { useT } from "../../i18n/useT";
7
+ import { PromptComposer } from "../chat/PromptComposer";
8
+ import { DemoView } from "../demo/DemoView";
9
+ import { FileSidebar } from "../files/FileSidebar";
10
+ import { IconButton } from "../primitives/IconButton";
11
+ import { SearchDialog } from "../search/SearchDialog";
12
+ import { SettingsDialog } from "../settings/SettingsDialog";
13
+ import { AgentsPanel, TracePanel } from "../session/AgentTraceViews";
14
+ import { SandboxBuildingOverlay } from "./SandboxBuildingOverlay";
15
+ import { SandboxStatus } from "./SandboxStatus";
16
+ import { Sidebar } from "../sidebar/Sidebar";
17
+ import { DiskQuotaWarningDialog } from "../quota/DiskQuotaWarningDialog";
18
+ import { DiskQuotaCriticalDialog } from "../quota/DiskQuotaCriticalDialog";
19
+
20
+ const MIN_SIDEBAR_WIDTH = 220;
21
+ const MAX_SIDEBAR_WIDTH = 420;
22
+
23
+ export function DesktopShell() {
24
+ const { isAuthReady } = useAuth();
25
+ const { currentSandbox, operation, error, stats } = useSandbox();
26
+ const { currentSession, currentView, messages, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
27
+ const t = useT();
28
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
29
+ const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
30
+ const [sidebarWidth, setSidebarWidth] = useState(268);
31
+ const [isSidebarResizing, setIsSidebarResizing] = useState(false);
32
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
33
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
34
+ const [isFilesOpen, setIsFilesOpen] = useState(false);
35
+ const [fileSidebarWidth, setFileSidebarWidth] = useState(420);
36
+ const [isFileSidebarResizing, setIsFileSidebarResizing] = useState(false);
37
+ const [sandboxOverlayDismissed, setSandboxOverlayDismissed] = useState(false);
38
+ const [isWarningOpen, setIsWarningOpen] = useState(false);
39
+ const hasWarnedRef = useRef(false);
40
+ const sidebarResizeRef = useRef<{ pointerX: number; width: number } | null>(null);
41
+
42
+ useEffect(() => {
43
+ if (operation === "creating" || operation === "rebuilding") {
44
+ setSandboxOverlayDismissed(false);
45
+ }
46
+ }, [operation]);
47
+
48
+ // Show warning dialog once per page session when disk usage is >= 90% but < 100%
49
+ useEffect(() => {
50
+ const percent = stats?.disk.percentOfQuota ?? 0;
51
+ if (percent >= 90 && percent < 100 && !hasWarnedRef.current) {
52
+ hasWarnedRef.current = true;
53
+ setIsWarningOpen(true);
54
+ }
55
+ }, [stats]);
56
+
57
+ const isCriticalOpen = stats ? stats.disk.percentOfQuota >= 100 : false;
58
+
59
+ useEffect(() => {
60
+ const handlePointerMove = (event: PointerEvent) => {
61
+ if (!sidebarResizeRef.current) {
62
+ return;
63
+ }
64
+
65
+ const delta = event.clientX - sidebarResizeRef.current.pointerX;
66
+ const nextWidth = Math.max(
67
+ MIN_SIDEBAR_WIDTH,
68
+ Math.min(MAX_SIDEBAR_WIDTH, sidebarResizeRef.current.width + delta),
69
+ );
70
+ setSidebarWidth(nextWidth);
71
+ };
72
+
73
+ const handlePointerUp = () => {
74
+ if (!sidebarResizeRef.current) {
75
+ return;
76
+ }
77
+
78
+ sidebarResizeRef.current = null;
79
+ setIsSidebarResizing(false);
80
+ };
81
+
82
+ window.addEventListener("pointermove", handlePointerMove);
83
+ window.addEventListener("pointerup", handlePointerUp);
84
+ return () => {
85
+ window.removeEventListener("pointermove", handlePointerMove);
86
+ window.removeEventListener("pointerup", handlePointerUp);
87
+ };
88
+ }, []);
89
+
90
+ // Trust-front: while the upstream identity is resolving (GET /api/auth/me),
91
+ // show a lightweight splash. On failure AuthProvider redirects to the hosted
92
+ // login, so we never render the app for an unauthenticated request.
93
+ if (!isAuthReady) {
94
+ return (
95
+ <div className="app-bootstrapping" role="status" aria-live="polite">
96
+ <span className="sandbox-status__eyebrow">BrainPilot</span>
97
+ <p>{t("shell.bootstrapping")}</p>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ return (
103
+ <div
104
+ className={`desktop-shell ${isSidebarCollapsed ? "desktop-shell--sidebar-collapsed" : ""} ${
105
+ isSidebarResizing ? "desktop-shell--resizing-sidebar" : ""
106
+ }`}
107
+ style={{ "--active-sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
108
+ >
109
+ <Sidebar
110
+ isCollapsed={isSidebarCollapsed}
111
+ activePage={activePage}
112
+ onOpenDemo={() => setActivePage("demo")}
113
+ onGoWorkspace={() => setActivePage("workspace")}
114
+ onOpenSettings={() => setIsSettingsOpen(true)}
115
+ onOpenSearch={() => setIsSearchOpen(true)}
116
+ onResizeStart={(pointerX) => {
117
+ if (isSidebarCollapsed) {
118
+ return;
119
+ }
120
+
121
+ sidebarResizeRef.current = { pointerX, width: sidebarWidth };
122
+ setIsSidebarResizing(true);
123
+ }}
124
+ onToggle={() => setIsSidebarCollapsed((current) => !current)}
125
+ />
126
+
127
+ {activePage === "demo" ? (
128
+ <DemoView />
129
+ ) : (
130
+ <main
131
+ className={`workspace ${isFilesOpen ? "workspace--files-open" : ""} ${
132
+ isFileSidebarResizing ? "workspace--resizing-files" : ""
133
+ }`}
134
+ style={{ "--active-file-sidebar-width": `${fileSidebarWidth}px` } as React.CSSProperties}
135
+ aria-label={t("shell.aria.workspace")}
136
+ >
137
+ <header className="workspace-toolbar" aria-label={t("shell.aria.toolbarActions")}>
138
+ <div className="session-title" aria-label={t("shell.aria.activeSession")}>
139
+ <span className="session-title__label">{t("shell.sessionLabel")}</span>
140
+ {currentSession?.id ? (
141
+ <span className="session-title__id">{currentSession.id.slice(0, 8)}</span>
142
+ ) : null}
143
+ {messages.length === 0 ? (
144
+ <span className="session-title__name">
145
+ {currentSession?.title || t("shell.defaultWorkspace")}
146
+ </span>
147
+ ) : null}
148
+ </div>
149
+ <div className="workspace-toolbar__actions">
150
+ <div className="workspace-view-tabs" role="tablist" aria-label={t("shell.aria.viewTabs")}>
151
+ <button
152
+ aria-selected={currentView === "chat"}
153
+ className={currentView === "chat" ? "is-active" : ""}
154
+ onClick={() => setCurrentView("chat")}
155
+ role="tab"
156
+ type="button"
157
+ >
158
+ <MessageSquare size={14} />
159
+ <span>{t("shell.view.chat")}</span>
160
+ </button>
161
+ <button
162
+ aria-selected={currentView === "agents"}
163
+ className={currentView === "agents" ? "is-active" : ""}
164
+ onClick={() => setCurrentView("agents")}
165
+ role="tab"
166
+ type="button"
167
+ >
168
+ <Bot size={14} />
169
+ <span>{t("shell.view.agents")}</span>
170
+ </button>
171
+ <button
172
+ aria-selected={currentView === "trace"}
173
+ className={currentView === "trace" ? "is-active" : ""}
174
+ onClick={() => setCurrentView("trace")}
175
+ role="tab"
176
+ type="button"
177
+ >
178
+ <GitBranch size={14} />
179
+ <span>{t("shell.view.trace")}</span>
180
+ </button>
181
+ </div>
182
+ {currentView === "chat" ? (
183
+ <IconButton
184
+ className={isRefreshingMessages ? "is-active" : ""}
185
+ label={t("shell.aria.refreshMessages")}
186
+ onClick={() => void refreshMessages()}
187
+ >
188
+ <RefreshCw size={14} />
189
+ </IconButton>
190
+ ) : null}
191
+ <SandboxStatus />
192
+ <IconButton
193
+ aria-pressed={isFilesOpen}
194
+ className={isFilesOpen ? "is-active" : ""}
195
+ label={isFilesOpen ? t("shell.files.close") : t("shell.files.open")}
196
+ onClick={() => setIsFilesOpen((current) => !current)}
197
+ >
198
+ <FolderOpen size={16} />
199
+ </IconButton>
200
+ </div>
201
+ </header>
202
+
203
+ {currentView === "chat" ? <PromptComposer /> : null}
204
+ {currentView === "agents" ? <AgentsPanel /> : null}
205
+ {currentView === "trace" ? <TracePanel /> : null}
206
+ <FileSidebar
207
+ isOpen={isFilesOpen}
208
+ onClose={() => setIsFilesOpen(false)}
209
+ onResize={setFileSidebarWidth}
210
+ onResizeEnd={() => setIsFileSidebarResizing(false)}
211
+ onResizeStart={() => setIsFileSidebarResizing(true)}
212
+ width={fileSidebarWidth}
213
+ />
214
+ </main>
215
+ )}
216
+
217
+ <SearchDialog isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
218
+ <SettingsDialog isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
219
+ {!sandboxOverlayDismissed && (operation === "creating" || operation === "rebuilding") ? (
220
+ <SandboxBuildingOverlay operation={operation} error={error} onDismiss={() => setSandboxOverlayDismissed(true)} />
221
+ ) : null}
222
+ <DiskQuotaWarningDialog
223
+ isOpen={isWarningOpen}
224
+ onClose={() => setIsWarningOpen(false)}
225
+ percentOfQuota={stats?.disk.percentOfQuota ?? 0}
226
+ />
227
+ <DiskQuotaCriticalDialog
228
+ isOpen={isCriticalOpen}
229
+ sandboxId={currentSandbox?.id ?? null}
230
+ workspaceUsedBytes={stats?.disk.workspaceUsedBytes ?? 0}
231
+ quotaBytes={stats?.disk.quotaBytes ?? 0}
232
+ percentOfQuota={stats?.disk.percentOfQuota ?? 0}
233
+ />
234
+ </div>
235
+ );
236
+ }
@@ -0,0 +1,73 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { SandboxOperation } from "../../contexts/SandboxContext";
3
+ import { useT } from "../../i18n/useT";
4
+
5
+ interface SandboxBuildingOverlayProps {
6
+ operation: SandboxOperation;
7
+ error: string | null;
8
+ onDismiss: () => void;
9
+ }
10
+
11
+ function getStatusKey(operation: SandboxOperation, timedOut: boolean) {
12
+ if (timedOut) {
13
+ return "sandbox.overlay.timeout";
14
+ }
15
+ switch (operation) {
16
+ case "creating":
17
+ return "sandbox.overlay.creating";
18
+ case "rebuilding":
19
+ return "sandbox.overlay.rebuilding";
20
+ default:
21
+ return "sandbox.overlay.preparing";
22
+ }
23
+ }
24
+
25
+ const TIMEOUT_MS = 30000;
26
+ const DISMISS_DELAY_MS = 2500;
27
+
28
+ export function SandboxBuildingOverlay({ operation, error, onDismiss }: SandboxBuildingOverlayProps) {
29
+ const t = useT();
30
+ const [timedOut, setTimedOut] = useState(false);
31
+
32
+ useEffect(() => {
33
+ setTimedOut(false);
34
+ const timeoutTimer = window.setTimeout(() => {
35
+ setTimedOut(true);
36
+ }, TIMEOUT_MS);
37
+ return () => window.clearTimeout(timeoutTimer);
38
+ }, [operation]);
39
+
40
+ useEffect(() => {
41
+ if (!timedOut) {
42
+ return;
43
+ }
44
+ const dismissTimer = window.setTimeout(() => {
45
+ onDismiss();
46
+ }, DISMISS_DELAY_MS);
47
+ return () => window.clearTimeout(dismissTimer);
48
+ }, [timedOut, onDismiss]);
49
+
50
+ return (
51
+ <div
52
+ className="sandbox-building-overlay"
53
+ role="dialog"
54
+ aria-live="polite"
55
+ aria-label={t("sandbox.overlay.aria")}
56
+ >
57
+ <div className="sandbox-building-panel">
58
+ {!timedOut ? (
59
+ <div className="sandbox-building__spinner" aria-hidden="true">
60
+ <span />
61
+ <span />
62
+ <span />
63
+ </div>
64
+ ) : null}
65
+ <h2 className="sandbox-building__title">{t("sandbox.overlay.title")}</h2>
66
+ <p className={`sandbox-building__status ${timedOut ? "is-timeout" : ""}`}>
67
+ {t(getStatusKey(operation, timedOut))}
68
+ </p>
69
+ {error ? <p className="sandbox-building__error">{error}</p> : null}
70
+ </div>
71
+ </div>
72
+ );
73
+ }
@@ -0,0 +1,287 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { useSandbox } from "../../contexts/SandboxContext";
3
+ import { useSessions } from "../../contexts/SessionContext";
4
+ import { useT } from "../../i18n/useT";
5
+ import { api } from "../../utils/api";
6
+ import { runtimeConfig } from "../../config";
7
+
8
+ type SandboxConnectionState = "disconnected" | "connected" | "connecting" | "creating";
9
+
10
+ const connectionLabelKey: Record<SandboxConnectionState, string> = {
11
+ disconnected: "sandbox.conn.disconnected",
12
+ connected: "sandbox.conn.connected",
13
+ connecting: "sandbox.conn.connecting",
14
+ creating: "sandbox.conn.creating",
15
+ };
16
+
17
+ function formatBytes(bytes?: number) {
18
+ if (!bytes) {
19
+ return "-";
20
+ }
21
+ const units = ["B", "KB", "MB", "GB"];
22
+ const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
23
+ const value = bytes / 1024 ** index;
24
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${units[index]}`;
25
+ }
26
+
27
+ function getConnectionState(status: string, isConnected: boolean): SandboxConnectionState {
28
+ if (status === "running" && isConnected) {
29
+ return "connected";
30
+ }
31
+ if (status === "running" && !isConnected) {
32
+ return "connecting";
33
+ }
34
+ if (status === "creating" || status === "rebuilding") {
35
+ return "creating";
36
+ }
37
+ if (status === "loading") {
38
+ return "connecting";
39
+ }
40
+ return "disconnected";
41
+ }
42
+
43
+ function getStatusKey(status: string, isConnected: boolean) {
44
+ switch (status) {
45
+ case "creating":
46
+ return { key: "sandbox.status.creating" };
47
+ case "rebuilding":
48
+ return { key: "sandbox.status.rebuilding" };
49
+ case "destroying":
50
+ return { key: "sandbox.status.destroying" };
51
+ case "missing":
52
+ return { key: "sandbox.status.missing" };
53
+ case "quota_exceeded":
54
+ return { key: "sandbox.status.quotaExceeded" };
55
+ case "error":
56
+ return { key: "sandbox.status.error" };
57
+ case "running":
58
+ return { key: isConnected ? "sandbox.status.running" : "sandbox.status.runningDisconnected" };
59
+ default:
60
+ return { key: "sandbox.status.fallback", vars: { status } };
61
+ }
62
+ }
63
+
64
+ export function SandboxStatus() {
65
+ const [isOpen, setIsOpen] = useState(false);
66
+ const [logs, setLogs] = useState("");
67
+ const [health, setHealth] = useState<Record<string, unknown> | null>(null);
68
+ const [detailsLoading, setDetailsLoading] = useState(false);
69
+ const rootRef = useRef<HTMLDivElement | null>(null);
70
+ const {
71
+ currentSandbox,
72
+ stats,
73
+ status,
74
+ operation,
75
+ error,
76
+ createSandbox,
77
+ rebuildSandbox,
78
+ destroySandbox,
79
+ refresh,
80
+ } = useSandbox();
81
+ const { isConnected } = useSessions();
82
+ const t = useT();
83
+ const effectiveStatus = operation === "idle" ? status : operation;
84
+ const connection = getConnectionState(effectiveStatus, isConnected);
85
+ const isLoading = operation !== "idle" && operation !== "loading";
86
+ const hasSandbox = !!currentSandbox;
87
+
88
+ const loadDetails = async () => {
89
+ if (!currentSandbox) {
90
+ return;
91
+ }
92
+ // Local single-user mode has no container logs/health endpoints — the
93
+ // runtime IS the sandbox. Skip the detail fetch (would hit nonexistent
94
+ // /api/sandbox/* routes and fall through to the SPA HTML fallback).
95
+ if (runtimeConfig.localMode) {
96
+ return;
97
+ }
98
+ setDetailsLoading(true);
99
+ try {
100
+ const [nextLogs, nextHealth] = await Promise.all([
101
+ api.sandbox.logs(currentSandbox.id, 80),
102
+ api.sandbox.health(currentSandbox.id),
103
+ ]);
104
+ setLogs(nextLogs);
105
+ setHealth(nextHealth);
106
+ } catch (detailError) {
107
+ setLogs(detailError instanceof Error ? detailError.message : t("sandbox.logs.failed"));
108
+ setHealth({ status: "error" });
109
+ } finally {
110
+ setDetailsLoading(false);
111
+ }
112
+ };
113
+
114
+ useEffect(() => {
115
+ const handlePointerDown = (event: PointerEvent) => {
116
+ if (!rootRef.current || rootRef.current.contains(event.target as Node)) {
117
+ return;
118
+ }
119
+ setIsOpen(false);
120
+ };
121
+ const handleKeyDown = (event: KeyboardEvent) => {
122
+ if (event.key === "Escape") {
123
+ setIsOpen(false);
124
+ }
125
+ };
126
+ document.addEventListener("pointerdown", handlePointerDown);
127
+ document.addEventListener("keydown", handleKeyDown);
128
+ return () => {
129
+ document.removeEventListener("pointerdown", handlePointerDown);
130
+ document.removeEventListener("keydown", handleKeyDown);
131
+ };
132
+ }, []);
133
+
134
+ useEffect(() => {
135
+ if (!isOpen || !currentSandbox) {
136
+ return;
137
+ }
138
+ void loadDetails();
139
+ }, [currentSandbox?.id, isOpen]);
140
+
141
+ return (
142
+ <div className="sandbox-status" ref={rootRef}>
143
+ <button
144
+ aria-controls="sandbox-status-popover"
145
+ aria-expanded={isOpen}
146
+ className={`sandbox-status__trigger sandbox-status__trigger--${connection}`}
147
+ onClick={() => setIsOpen((current) => !current)}
148
+ type="button"
149
+ >
150
+ <span className="sandbox-status__dot" aria-hidden="true" />
151
+ <span>Sandbox</span>
152
+ </button>
153
+
154
+ <div
155
+ aria-label={t("sandbox.aria.status")}
156
+ className={`sandbox-status__popover ${isOpen ? "is-open" : ""}`}
157
+ id="sandbox-status-popover"
158
+ role="dialog"
159
+ >
160
+ <div className="sandbox-status__header">
161
+ <span className="sandbox-status__eyebrow">Sandbox</span>
162
+ <strong>{t(connectionLabelKey[connection])}</strong>
163
+ </div>
164
+
165
+ <p className={`sandbox-status__notice ${isLoading ? "is-loading" : ""}`}>
166
+ {(() => {
167
+ const notice = getStatusKey(effectiveStatus, isConnected);
168
+ return t(notice.key, notice.vars);
169
+ })()}
170
+ </p>
171
+ {error ? <p className="sandbox-status__empty">{error}</p> : null}
172
+
173
+ {hasSandbox ? (
174
+ <>
175
+ <dl className="sandbox-status__grid">
176
+ <div>
177
+ <dt>{t("sandbox.label.name")}</dt>
178
+ <dd>{currentSandbox.name}</dd>
179
+ </div>
180
+ <div>
181
+ <dt>{t("sandbox.label.id")}</dt>
182
+ <dd>{currentSandbox.id}</dd>
183
+ </div>
184
+ <div>
185
+ <dt>{t("sandbox.label.container")}</dt>
186
+ <dd>{currentSandbox.containerName || "-"}</dd>
187
+ </div>
188
+ <div>
189
+ <dt>{t("sandbox.label.hostApi")}</dt>
190
+ <dd>{currentSandbox.hostApiUrl || "-"}</dd>
191
+ </div>
192
+ <div>
193
+ <dt>{t("sandbox.label.port")}</dt>
194
+ <dd>{currentSandbox.port ?? "-"}</dd>
195
+ </div>
196
+ <div>
197
+ <dt>{t("sandbox.label.created")}</dt>
198
+ <dd>{new Date(currentSandbox.createdAt).toLocaleString()}</dd>
199
+ </div>
200
+ </dl>
201
+
202
+ <div className="sandbox-status__metrics">
203
+ <div>
204
+ <span>{t("sandbox.label.memory")}</span>
205
+ <strong>
206
+ {formatBytes(stats?.memory.usedBytes)} / {formatBytes(stats?.memory.limitBytes)}
207
+ </strong>
208
+ <small>{stats?.memory.percent ?? 0}%</small>
209
+ </div>
210
+ <div>
211
+ <span>{t("sandbox.label.cpu")}</span>
212
+ <strong>{t("sandbox.cpuUsed", { percent: stats?.cpu.usedPercent ?? 0 })}</strong>
213
+ <small>
214
+ {t("sandbox.cpuQuota", { quota: stats?.cpu.quotaPercent ?? 0, cpus: stats?.cpu.onlineCpus ?? 0 })}
215
+ </small>
216
+ </div>
217
+ <div>
218
+ <span>{t("sandbox.label.pids")}</span>
219
+ <strong>
220
+ {stats?.pids.current ?? 0} / {stats?.pids.limit ?? t("sandbox.unlimited")}
221
+ </strong>
222
+ </div>
223
+ <div>
224
+ <span>{t("sandbox.label.disk")}</span>
225
+ <strong>
226
+ {formatBytes(stats?.disk.workspaceUsedBytes)} / {formatBytes(stats?.disk.quotaBytes)}
227
+ </strong>
228
+ <small>{stats?.disk.percentOfQuota ?? 0}%</small>
229
+ </div>
230
+ </div>
231
+
232
+ <div className="sandbox-status__health">
233
+ <div>
234
+ <span>{t("sandbox.label.health")}</span>
235
+ <strong>{String(health?.status ?? (detailsLoading ? t("sandbox.health.checking") : t("sandbox.health.unknown")))}</strong>
236
+ </div>
237
+ <div>
238
+ <span>{t("sandbox.label.runtime")}</span>
239
+ <strong>{health?.agent_runtime === false ? t("sandbox.health.offline") : health?.agent_runtime ? t("sandbox.health.online") : "-"}</strong>
240
+ </div>
241
+ <div>
242
+ <span>{t("sandbox.label.sse")}</span>
243
+ <strong>{isConnected ? t("sandbox.health.connected") : t("sandbox.health.offline")}</strong>
244
+ </div>
245
+ <div>
246
+ <span>{t("sandbox.label.checked")}</span>
247
+ <strong>{health?.checked_at ? new Date(String(health.checked_at)).toLocaleTimeString() : "-"}</strong>
248
+ </div>
249
+ </div>
250
+
251
+ <pre className="sandbox-status__logs">{detailsLoading ? t("sandbox.logs.loading") : logs || t("sandbox.logs.empty")}</pre>
252
+ </>
253
+ ) : (
254
+ <p className="sandbox-status__empty">{t("sandbox.empty")}</p>
255
+ )}
256
+
257
+ <div className="sandbox-status__actions">
258
+ {hasSandbox ? (
259
+ <>
260
+ <button disabled={isLoading} onClick={() => void rebuildSandbox()} type="button">
261
+ {t("sandbox.action.rebuild")}
262
+ </button>
263
+ <button disabled={isLoading} onClick={() => void refresh()} type="button">
264
+ {t("sandbox.action.refresh")}
265
+ </button>
266
+ <button disabled={detailsLoading} onClick={() => void loadDetails()} type="button">
267
+ {t("sandbox.action.logs")}
268
+ </button>
269
+ <button
270
+ className="sandbox-status__danger-action"
271
+ disabled={isLoading}
272
+ onClick={() => void destroySandbox()}
273
+ type="button"
274
+ >
275
+ {t("sandbox.action.delete")}
276
+ </button>
277
+ </>
278
+ ) : (
279
+ <button disabled={isLoading} onClick={() => void createSandbox()} type="button">
280
+ {t("sandbox.action.build")}
281
+ </button>
282
+ )}
283
+ </div>
284
+ </div>
285
+ </div>
286
+ );
287
+ }