@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.
Files changed (114) hide show
  1. package/dist/assets/index-Br55rkHb.css +1 -0
  2. package/dist/assets/index-CeUzk-ej.js +445 -0
  3. package/dist/index.html +2 -2
  4. package/index.html +13 -0
  5. package/package.json +12 -3
  6. package/src/App.tsx +10 -0
  7. package/src/__tests__/agentsReducer.test.ts +67 -0
  8. package/src/__tests__/api.test.ts +221 -0
  9. package/src/__tests__/chatScrollMemory.test.ts +49 -0
  10. package/src/__tests__/demoConversation.test.ts +73 -0
  11. package/src/__tests__/demoReset.test.ts +24 -0
  12. package/src/__tests__/messageGroups.test.ts +80 -0
  13. package/src/__tests__/newUiComponents.test.tsx +101 -0
  14. package/src/__tests__/newUiEvents.test.ts +236 -0
  15. package/src/__tests__/runningToast.test.ts +29 -0
  16. package/src/__tests__/tokenUsage.test.ts +48 -0
  17. package/src/__tests__/toolDisplay.test.ts +55 -0
  18. package/src/__tests__/traceReducer.test.ts +62 -0
  19. package/src/components/chat/AskUserCard.tsx +123 -0
  20. package/src/components/chat/AutoRetryIndicator.tsx +71 -0
  21. package/src/components/chat/ComposerInput.tsx +73 -0
  22. package/src/components/chat/ComposerSendButton.tsx +26 -0
  23. package/src/components/chat/MarkdownMessage.tsx +24 -0
  24. package/src/components/chat/MessageStream.tsx +505 -0
  25. package/src/components/chat/PromptComposer.tsx +489 -0
  26. package/src/components/chat/SystemMessageBubble.tsx +46 -0
  27. package/src/components/chat/chatScrollMemory.ts +49 -0
  28. package/src/components/demo/DemoFileTree.tsx +146 -0
  29. package/src/components/demo/DemoView.tsx +730 -0
  30. package/src/components/demo/TraceNodeModal.tsx +80 -0
  31. package/src/components/demo/demoBundle.ts +223 -0
  32. package/src/components/demo/demoCache.ts +42 -0
  33. package/src/components/demo/demoReset.ts +16 -0
  34. package/src/components/files/FilePreviewView.tsx +153 -0
  35. package/src/components/files/FileSidebar.tsx +664 -0
  36. package/src/components/files/filePreview.ts +113 -0
  37. package/src/components/primitives/CustomSelect.tsx +200 -0
  38. package/src/components/primitives/IconButton.tsx +27 -0
  39. package/src/components/quota/DiskQuotaCriticalDialog.tsx +56 -0
  40. package/src/components/quota/DiskQuotaWarningDialog.tsx +65 -0
  41. package/src/components/quota/QuotaFileManager.tsx +197 -0
  42. package/src/components/search/SearchDialog.tsx +101 -0
  43. package/src/components/session/AgentNetwork.tsx +1233 -0
  44. package/src/components/session/AgentTraceViews.tsx +346 -0
  45. package/src/components/session/AnalyticsTab.tsx +220 -0
  46. package/src/components/session/GlobalOverview.tsx +108 -0
  47. package/src/components/session/NodeTooltip.tsx +127 -0
  48. package/src/components/session/TimelineTab.tsx +320 -0
  49. package/src/components/session/TraceGraphView.tsx +307 -0
  50. package/src/components/session/TraceNodeDetail.tsx +179 -0
  51. package/src/components/session/agentAnalytics.ts +397 -0
  52. package/src/components/session/agentNetworkShared.ts +339 -0
  53. package/src/components/session/traceLayout.ts +182 -0
  54. package/src/components/settings/SettingsDialog.tsx +737 -0
  55. package/src/components/shell/DesktopShell.tsx +261 -0
  56. package/src/components/shell/SandboxBuildingOverlay.tsx +73 -0
  57. package/src/components/shell/SandboxStatus.tsx +287 -0
  58. package/src/components/shell/TerminalDrawer.tsx +387 -0
  59. package/src/components/sidebar/Sidebar.tsx +191 -0
  60. package/src/config.ts +10 -0
  61. package/src/contexts/AppProviders.tsx +20 -0
  62. package/src/contexts/AuthContext.tsx +61 -0
  63. package/src/contexts/PreferencesContext.tsx +125 -0
  64. package/src/contexts/SSEContext.tsx +264 -0
  65. package/src/contexts/SandboxContext.tsx +310 -0
  66. package/src/contexts/SessionContext.tsx +919 -0
  67. package/src/contexts/agentsReducer.ts +49 -0
  68. package/src/contexts/draftStore.ts +103 -0
  69. package/src/contexts/messageFilters.ts +29 -0
  70. package/src/contexts/messageGroups.ts +77 -0
  71. package/src/contexts/messageReducer.ts +401 -0
  72. package/src/contexts/newUiEvents.ts +190 -0
  73. package/src/contexts/runningToast.ts +33 -0
  74. package/src/contexts/traceReducer.ts +62 -0
  75. package/src/contexts/turnTimer.test.ts +97 -0
  76. package/src/contexts/turnTimer.ts +108 -0
  77. package/src/contexts/useTurnTimer.ts +104 -0
  78. package/src/contracts/backend.ts +897 -0
  79. package/src/contracts/demoBundle.ts +83 -0
  80. package/src/i18n/messages/analytics.ts +106 -0
  81. package/src/i18n/messages/chat.ts +130 -0
  82. package/src/i18n/messages/contexts.ts +42 -0
  83. package/src/i18n/messages/demo.ts +80 -0
  84. package/src/i18n/messages/files.ts +82 -0
  85. package/src/i18n/messages/network.ts +190 -0
  86. package/src/i18n/messages/profile.ts +44 -0
  87. package/src/i18n/messages/quota.ts +36 -0
  88. package/src/i18n/messages/sandbox.ts +116 -0
  89. package/src/i18n/messages/search.ts +16 -0
  90. package/src/i18n/messages/settings.ts +188 -0
  91. package/src/i18n/messages/shell.ts +38 -0
  92. package/src/i18n/messages/sidebar.ts +52 -0
  93. package/src/i18n/messages/terminal.ts +22 -0
  94. package/src/i18n/messages/trace.ts +136 -0
  95. package/src/i18n/messages.ts +32 -0
  96. package/src/i18n/translate.ts +46 -0
  97. package/src/i18n/types.ts +15 -0
  98. package/src/i18n/useT.ts +15 -0
  99. package/src/main.tsx +13 -0
  100. package/src/mocks/backend.ts +729 -0
  101. package/src/styles/global.css +7578 -0
  102. package/src/styles/tokens.css +161 -0
  103. package/src/utils/api.ts +724 -0
  104. package/src/utils/download.ts +18 -0
  105. package/src/utils/format.ts +7 -0
  106. package/src/utils/toolDisplay.ts +74 -0
  107. package/src/utils/zip.ts +119 -0
  108. package/src/vite-env.d.ts +1 -0
  109. package/tsconfig.app.json +22 -0
  110. package/tsconfig.json +7 -0
  111. package/tsconfig.node.json +13 -0
  112. package/vite.config.ts +13 -0
  113. package/dist/assets/index-Cd0Mi_WU.css +0 -1
  114. package/dist/assets/index-FGg-DeYR.js +0 -448
@@ -0,0 +1,261 @@
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 { runtimeConfig } from "../../config";
8
+ import { PromptComposer } from "../chat/PromptComposer";
9
+ import { DemoView } from "../demo/DemoView";
10
+ import { FileSidebar } from "../files/FileSidebar";
11
+ import { IconButton } from "../primitives/IconButton";
12
+ import { SearchDialog } from "../search/SearchDialog";
13
+ import { SettingsDialog } from "../settings/SettingsDialog";
14
+ import { AgentsPanel, TracePanel } from "../session/AgentTraceViews";
15
+ import { SandboxBuildingOverlay } from "./SandboxBuildingOverlay";
16
+ import { SandboxStatus } from "./SandboxStatus";
17
+ import { Sidebar } from "../sidebar/Sidebar";
18
+ import { DiskQuotaWarningDialog } from "../quota/DiskQuotaWarningDialog";
19
+ import { DiskQuotaCriticalDialog } from "../quota/DiskQuotaCriticalDialog";
20
+
21
+ const MIN_SIDEBAR_WIDTH = 220;
22
+ const MAX_SIDEBAR_WIDTH = 420;
23
+
24
+ export function DesktopShell() {
25
+ const { isAuthReady } = useAuth();
26
+ const { currentSandbox, operation, error, stats } = useSandbox();
27
+ const { currentSession, currentView, isRefreshingMessages, refreshMessages, setCurrentView } = useSessions();
28
+ const t = useT();
29
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
30
+ const [activePage, setActivePage] = useState<"workspace" | "demo">("workspace");
31
+ // Bumped on every sidebar "Live Demo" click so DemoView returns to its
32
+ // session-selection landing even when the demo page is already open (#111).
33
+ const [demoResetSignal, setDemoResetSignal] = useState(0);
34
+ const [sidebarWidth, setSidebarWidth] = useState(268);
35
+ const [isSidebarResizing, setIsSidebarResizing] = useState(false);
36
+ const [isSearchOpen, setIsSearchOpen] = useState(false);
37
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
38
+ const [isFilesOpen, setIsFilesOpen] = useState(false);
39
+ const [fileSidebarWidth, setFileSidebarWidth] = useState(420);
40
+ const [isFileSidebarResizing, setIsFileSidebarResizing] = useState(false);
41
+ const [sandboxOverlayDismissed, setSandboxOverlayDismissed] = useState(false);
42
+ const [isWarningOpen, setIsWarningOpen] = useState(false);
43
+ const hasWarnedRef = useRef(false);
44
+ const sidebarResizeRef = useRef<{ pointerX: number; width: number } | null>(null);
45
+
46
+ useEffect(() => {
47
+ if (operation === "creating" || operation === "rebuilding") {
48
+ setSandboxOverlayDismissed(false);
49
+ }
50
+ }, [operation]);
51
+
52
+ // Show warning dialog once per page session when disk usage is >= 90% but < 100%
53
+ useEffect(() => {
54
+ const percent = stats?.disk.percentOfQuota ?? 0;
55
+ if (percent >= 90 && percent < 100 && !hasWarnedRef.current) {
56
+ hasWarnedRef.current = true;
57
+ setIsWarningOpen(true);
58
+ }
59
+ }, [stats]);
60
+
61
+ const isCriticalOpen = stats ? stats.disk.percentOfQuota >= 100 : false;
62
+
63
+ useEffect(() => {
64
+ const handlePointerMove = (event: PointerEvent) => {
65
+ if (!sidebarResizeRef.current) {
66
+ return;
67
+ }
68
+
69
+ const delta = event.clientX - sidebarResizeRef.current.pointerX;
70
+ const nextWidth = Math.max(
71
+ MIN_SIDEBAR_WIDTH,
72
+ Math.min(MAX_SIDEBAR_WIDTH, sidebarResizeRef.current.width + delta),
73
+ );
74
+ setSidebarWidth(nextWidth);
75
+ };
76
+
77
+ const handlePointerUp = () => {
78
+ if (!sidebarResizeRef.current) {
79
+ return;
80
+ }
81
+
82
+ sidebarResizeRef.current = null;
83
+ setIsSidebarResizing(false);
84
+ };
85
+
86
+ window.addEventListener("pointermove", handlePointerMove);
87
+ window.addEventListener("pointerup", handlePointerUp);
88
+ return () => {
89
+ window.removeEventListener("pointermove", handlePointerMove);
90
+ window.removeEventListener("pointerup", handlePointerUp);
91
+ };
92
+ }, []);
93
+
94
+ // Trust-front: while the upstream identity is resolving (GET /api/auth/me),
95
+ // show a lightweight splash. On failure AuthProvider redirects to the hosted
96
+ // login, so we never render the app for an unauthenticated request.
97
+ if (!isAuthReady) {
98
+ return (
99
+ <div className="app-bootstrapping" role="status" aria-live="polite">
100
+ <span className="sandbox-status__eyebrow">BrainPilot</span>
101
+ <p>{t("shell.bootstrapping")}</p>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div
108
+ className={`desktop-shell ${isSidebarCollapsed ? "desktop-shell--sidebar-collapsed" : ""} ${
109
+ isSidebarResizing ? "desktop-shell--resizing-sidebar" : ""
110
+ }`}
111
+ style={{ "--active-sidebar-width": `${sidebarWidth}px` } as React.CSSProperties}
112
+ >
113
+ <Sidebar
114
+ isCollapsed={isSidebarCollapsed}
115
+ activePage={activePage}
116
+ onOpenDemo={() => {
117
+ setActivePage("demo");
118
+ setDemoResetSignal((n) => n + 1);
119
+ }}
120
+ onGoWorkspace={() => setActivePage("workspace")}
121
+ onOpenSettings={() => setIsSettingsOpen(true)}
122
+ onOpenSearch={() => setIsSearchOpen(true)}
123
+ onResizeStart={(pointerX) => {
124
+ if (isSidebarCollapsed) {
125
+ return;
126
+ }
127
+
128
+ sidebarResizeRef.current = { pointerX, width: sidebarWidth };
129
+ setIsSidebarResizing(true);
130
+ }}
131
+ onToggle={() => setIsSidebarCollapsed((current) => !current)}
132
+ />
133
+
134
+ {activePage === "demo" ? (
135
+ <DemoView resetSignal={demoResetSignal} />
136
+ ) : (
137
+ <main
138
+ className={`workspace ${isFilesOpen ? "workspace--files-open" : ""} ${
139
+ isFileSidebarResizing ? "workspace--resizing-files" : ""
140
+ }`}
141
+ style={{ "--active-file-sidebar-width": `${fileSidebarWidth}px` } as React.CSSProperties}
142
+ aria-label={t("shell.aria.workspace")}
143
+ >
144
+ <header className="workspace-toolbar" aria-label={t("shell.aria.toolbarActions")}>
145
+ <div className="session-title" aria-label={t("shell.aria.activeSession")}>
146
+ {/* #105: foreground the human-readable session title (same source as
147
+ the sidebar). The id is debug-only metadata now — surfaced as a
148
+ hover tooltip + muted short id, never the primary label. Falls
149
+ back to `Session <id8>` when the title is missing. */}
150
+ <span
151
+ className="session-title__name"
152
+ title={currentSession?.id ?? undefined}
153
+ >
154
+ {currentSession?.title ||
155
+ (currentSession?.id
156
+ ? `${t("shell.sessionLabel")} ${currentSession.id.slice(0, 8)}`
157
+ : t("shell.defaultWorkspace"))}
158
+ </span>
159
+ {currentSession?.id ? (
160
+ <span className="session-title__id">{currentSession.id.slice(0, 8)}</span>
161
+ ) : null}
162
+ </div>
163
+ <div className="workspace-toolbar__actions">
164
+ {/* #104: icon-only nav. The label stays in the DOM (visually
165
+ hidden) so it remains the button's accessible name, and `title`
166
+ gives a hover/focus tooltip — no separate aria-label needed. */}
167
+ <div className="workspace-view-tabs workspace-view-tabs--icon-only" role="tablist" aria-label={t("shell.aria.viewTabs")}>
168
+ <button
169
+ aria-selected={currentView === "chat"}
170
+ className={currentView === "chat" ? "is-active" : ""}
171
+ onClick={() => setCurrentView("chat")}
172
+ role="tab"
173
+ title={t("shell.view.chat")}
174
+ type="button"
175
+ >
176
+ <MessageSquare size={14} />
177
+ <span className="sr-only">{t("shell.view.chat")}</span>
178
+ </button>
179
+ <button
180
+ aria-selected={currentView === "agents"}
181
+ className={currentView === "agents" ? "is-active" : ""}
182
+ onClick={() => setCurrentView("agents")}
183
+ role="tab"
184
+ title={t("shell.view.agents")}
185
+ type="button"
186
+ >
187
+ <Bot size={14} />
188
+ <span className="sr-only">{t("shell.view.agents")}</span>
189
+ </button>
190
+ <button
191
+ aria-selected={currentView === "trace"}
192
+ className={currentView === "trace" ? "is-active" : ""}
193
+ onClick={() => setCurrentView("trace")}
194
+ role="tab"
195
+ title={t("shell.view.trace")}
196
+ type="button"
197
+ >
198
+ <GitBranch size={14} />
199
+ <span className="sr-only">{t("shell.view.trace")}</span>
200
+ </button>
201
+ </div>
202
+ {currentView === "chat" ? (
203
+ <IconButton
204
+ className={isRefreshingMessages ? "is-active" : ""}
205
+ label={t("shell.aria.refreshMessages")}
206
+ onClick={() => void refreshMessages()}
207
+ >
208
+ <RefreshCw size={14} />
209
+ </IconButton>
210
+ ) : null}
211
+ {/* #100: in local single-user mode there is no Docker sandbox to
212
+ inspect — the runtime IS the workspace, so the Sandbox status
213
+ popover would only show empty container metrics and read like a
214
+ fault. Hide it here; downstream multi-user Docker builds set
215
+ VITE_LOCAL_MODE=0 and keep the real container UI. */}
216
+ {runtimeConfig.localMode ? null : <SandboxStatus />}
217
+ <IconButton
218
+ aria-pressed={isFilesOpen}
219
+ className={isFilesOpen ? "is-active" : ""}
220
+ label={isFilesOpen ? t("shell.files.close") : t("shell.files.open")}
221
+ onClick={() => setIsFilesOpen((current) => !current)}
222
+ >
223
+ <FolderOpen size={16} />
224
+ </IconButton>
225
+ </div>
226
+ </header>
227
+
228
+ {currentView === "chat" ? <PromptComposer /> : null}
229
+ {currentView === "agents" ? <AgentsPanel /> : null}
230
+ {currentView === "trace" ? <TracePanel /> : null}
231
+ <FileSidebar
232
+ isOpen={isFilesOpen}
233
+ onClose={() => setIsFilesOpen(false)}
234
+ onResize={setFileSidebarWidth}
235
+ onResizeEnd={() => setIsFileSidebarResizing(false)}
236
+ onResizeStart={() => setIsFileSidebarResizing(true)}
237
+ width={fileSidebarWidth}
238
+ />
239
+ </main>
240
+ )}
241
+
242
+ <SearchDialog isOpen={isSearchOpen} onClose={() => setIsSearchOpen(false)} />
243
+ <SettingsDialog isOpen={isSettingsOpen} onClose={() => setIsSettingsOpen(false)} />
244
+ {!sandboxOverlayDismissed && (operation === "creating" || operation === "rebuilding") ? (
245
+ <SandboxBuildingOverlay operation={operation} error={error} onDismiss={() => setSandboxOverlayDismissed(true)} />
246
+ ) : null}
247
+ <DiskQuotaWarningDialog
248
+ isOpen={isWarningOpen}
249
+ onClose={() => setIsWarningOpen(false)}
250
+ percentOfQuota={stats?.disk.percentOfQuota ?? 0}
251
+ />
252
+ <DiskQuotaCriticalDialog
253
+ isOpen={isCriticalOpen}
254
+ sandboxId={currentSandbox?.id ?? null}
255
+ workspaceUsedBytes={stats?.disk.workspaceUsedBytes ?? 0}
256
+ quotaBytes={stats?.disk.quotaBytes ?? 0}
257
+ percentOfQuota={stats?.disk.percentOfQuota ?? 0}
258
+ />
259
+ </div>
260
+ );
261
+ }
@@ -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
+ }