@brainpilot/web 0.0.4 → 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,125 @@
1
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
+ import { setActiveLocale } from "../i18n/translate";
3
+
4
+ type ThemePreference = "light" | "dark" | "system";
5
+
6
+ interface PreferencesState {
7
+ theme: ThemePreference;
8
+ resolvedTheme: "light" | "dark";
9
+ language: "zh-CN" | "en-US";
10
+ security: {
11
+ confirmDangerousActions: boolean;
12
+ rememberSession: boolean;
13
+ };
14
+ notifications: {
15
+ agentDone: boolean;
16
+ errors: boolean;
17
+ };
18
+ }
19
+
20
+ interface PreferencesContextValue extends PreferencesState {
21
+ setTheme: (theme: ThemePreference) => void;
22
+ setLanguage: (language: PreferencesState["language"]) => void;
23
+ setSecurity: (security: PreferencesState["security"]) => void;
24
+ setNotifications: (notifications: PreferencesState["notifications"]) => void;
25
+ }
26
+
27
+ const STORAGE_KEY = "new-ui-preferences";
28
+
29
+ const defaultPreferences: PreferencesState = {
30
+ theme: "light",
31
+ resolvedTheme: "light",
32
+ language: "zh-CN",
33
+ security: {
34
+ confirmDangerousActions: true,
35
+ rememberSession: true,
36
+ },
37
+ notifications: {
38
+ agentDone: true,
39
+ errors: true,
40
+ },
41
+ };
42
+
43
+ const PreferencesContext = createContext<PreferencesContextValue | null>(null);
44
+
45
+ function loadPreferences(): PreferencesState {
46
+ const stored = localStorage.getItem(STORAGE_KEY);
47
+ if (!stored) {
48
+ return defaultPreferences;
49
+ }
50
+ try {
51
+ const parsed = JSON.parse(stored) as Partial<PreferencesState>;
52
+ return {
53
+ ...defaultPreferences,
54
+ ...parsed,
55
+ security: { ...defaultPreferences.security, ...parsed.security },
56
+ notifications: { ...defaultPreferences.notifications, ...parsed.notifications },
57
+ resolvedTheme: defaultPreferences.resolvedTheme,
58
+ };
59
+ } catch {
60
+ return defaultPreferences;
61
+ }
62
+ }
63
+
64
+ function resolveTheme(theme: ThemePreference): "light" | "dark" {
65
+ if (theme === "system") {
66
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
67
+ }
68
+ return theme;
69
+ }
70
+
71
+ export function PreferencesProvider({ children }: { children: ReactNode }) {
72
+ const [preferences, setPreferences] = useState<PreferencesState>(() => {
73
+ const loaded = loadPreferences();
74
+ return { ...loaded, resolvedTheme: resolveTheme(loaded.theme) };
75
+ });
76
+
77
+ useEffect(() => {
78
+ const root = document.documentElement;
79
+ root.dataset.theme = preferences.resolvedTheme;
80
+ root.lang = preferences.language;
81
+ setActiveLocale(preferences.language);
82
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ ...preferences, resolvedTheme: undefined }));
83
+ }, [preferences]);
84
+
85
+ useEffect(() => {
86
+ if (preferences.theme !== "system") {
87
+ return;
88
+ }
89
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
90
+ const listener = () => setPreferences((current) => ({ ...current, resolvedTheme: resolveTheme(current.theme) }));
91
+ media.addEventListener("change", listener);
92
+ return () => media.removeEventListener("change", listener);
93
+ }, [preferences.theme]);
94
+
95
+ const setTheme = useCallback((theme: ThemePreference) => {
96
+ setPreferences((current) => ({ ...current, theme, resolvedTheme: resolveTheme(theme) }));
97
+ }, []);
98
+
99
+ const setLanguage = useCallback((language: PreferencesState["language"]) => {
100
+ setPreferences((current) => ({ ...current, language }));
101
+ }, []);
102
+
103
+ const setSecurity = useCallback((security: PreferencesState["security"]) => {
104
+ setPreferences((current) => ({ ...current, security }));
105
+ }, []);
106
+
107
+ const setNotifications = useCallback((notifications: PreferencesState["notifications"]) => {
108
+ setPreferences((current) => ({ ...current, notifications }));
109
+ }, []);
110
+
111
+ const value = useMemo(
112
+ () => ({ ...preferences, setTheme, setLanguage, setSecurity, setNotifications }),
113
+ [preferences, setTheme, setLanguage, setSecurity, setNotifications],
114
+ );
115
+
116
+ return <PreferencesContext.Provider value={value}>{children}</PreferencesContext.Provider>;
117
+ }
118
+
119
+ export function usePreferences() {
120
+ const value = useContext(PreferencesContext);
121
+ if (!value) {
122
+ throw new Error("usePreferences must be used within PreferencesProvider");
123
+ }
124
+ return value;
125
+ }
@@ -0,0 +1,175 @@
1
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { runtimeConfig } from "../config";
3
+ import { WebSocketEvent, normalizeWebSocketEvent } from "../contracts/backend";
4
+ import { getSSEUrl } from "../utils/api";
5
+ import { tg } from "../i18n/translate";
6
+ import { useAuth } from "./AuthContext";
7
+ import { useSandbox } from "./SandboxContext";
8
+
9
+ type ConnectionStatus = "idle" | "connecting" | "open" | "error";
10
+
11
+ interface SSEContextValue {
12
+ /** Open (or reuse) an SSE stream for a session. */
13
+ connectSession: (sessionId: string) => void;
14
+ /** Close the SSE stream for a session. */
15
+ disconnectSession: (sessionId: string) => void;
16
+ /** Pending event queue per session (drained by consumer). */
17
+ queueRef: React.RefObject<Map<string, WebSocketEvent[]>>;
18
+ /** Tick counter — increments when new events arrive. */
19
+ tick: number;
20
+ /** Connection status per session. */
21
+ connections: Map<string, ConnectionStatus>;
22
+ }
23
+
24
+ const SSEContext = createContext<SSEContextValue | null>(null);
25
+
26
+ const RECONNECT_BASE_MS = 3000;
27
+ const RECONNECT_MAX_MS = 30000;
28
+
29
+ interface SessionConn {
30
+ source: EventSource;
31
+ reconnectAttempt: number;
32
+ reconnectTimer: number | null;
33
+ /** Whether disconnectSession was called — disable auto-reconnect. */
34
+ manuallyClosed: boolean;
35
+ }
36
+
37
+ export function SSEProvider({ children }: { children: ReactNode }) {
38
+ const { isAuthReady } = useAuth();
39
+ const { currentSandbox } = useSandbox();
40
+ const connsRef = useRef<Map<string, SessionConn>>(new Map());
41
+ const queueRef = useRef<Map<string, WebSocketEvent[]>>(new Map());
42
+ const [tick, setTick] = useState(0);
43
+ const [connections, setConnections] = useState<Map<string, ConnectionStatus>>(new Map());
44
+
45
+ const setStatus = useCallback((sessionId: string, status: ConnectionStatus) => {
46
+ setConnections((prev) => {
47
+ const next = new Map(prev);
48
+ next.set(sessionId, status);
49
+ return next;
50
+ });
51
+ }, []);
52
+
53
+ const openConnection = useCallback((sessionId: string) => {
54
+ if (runtimeConfig.useMockBackend) {
55
+ setStatus(sessionId, "open");
56
+ return;
57
+ }
58
+ const conn = connsRef.current.get(sessionId);
59
+ if (conn && !conn.manuallyClosed && conn.source.readyState !== EventSource.CLOSED) {
60
+ // Already connecting/open — nothing to do.
61
+ return;
62
+ }
63
+
64
+ console.log(`[SSE] openConnection: ${sessionId}`);
65
+ setStatus(sessionId, "connecting");
66
+ const source = new EventSource(getSSEUrl(sessionId));
67
+
68
+ const entry: SessionConn = {
69
+ source,
70
+ reconnectAttempt: 0,
71
+ reconnectTimer: null,
72
+ manuallyClosed: false,
73
+ };
74
+ connsRef.current.set(sessionId, entry);
75
+
76
+ source.onopen = () => {
77
+ entry.reconnectAttempt = 0;
78
+ console.log(`[SSE] onopen: ${sessionId}`);
79
+ setStatus(sessionId, "open");
80
+ };
81
+
82
+ source.onmessage = (ev) => {
83
+ try {
84
+ const parsed = JSON.parse(ev.data);
85
+ const normalized = normalizeWebSocketEvent(parsed);
86
+ // Drop heartbeats — they exist only to keep the connection alive.
87
+ if (normalized.type === "PING" || normalized.type === "ping") return;
88
+ console.log(`[SSE] onmessage: ${sessionId} type=${normalized.type}`, normalized);
89
+ const queue = queueRef.current.get(sessionId) || [];
90
+ queue.push(normalized);
91
+ queueRef.current.set(sessionId, queue);
92
+ setTick((t) => t + 1);
93
+ } catch {
94
+ console.error(`[SSE] onmessage: ${sessionId} 解析失败`, ev.data);
95
+ // Malformed payload — surface as a synthetic error event for the UI.
96
+ const queue = queueRef.current.get(sessionId) || [];
97
+ queue.push({
98
+ type: "error",
99
+ sessionId,
100
+ data: { error: { message: tg("ctx.sse.parseError") } },
101
+ });
102
+ queueRef.current.set(sessionId, queue);
103
+ setTick((t) => t + 1);
104
+ }
105
+ };
106
+
107
+ source.onerror = () => {
108
+ console.error(`[SSE] onerror: ${sessionId}, reconnectAttempt=${entry.reconnectAttempt + 1}`);
109
+ setStatus(sessionId, "error");
110
+ source.close();
111
+ if (entry.manuallyClosed) return;
112
+ // Exponential backoff for auto-reconnect.
113
+ entry.reconnectAttempt += 1;
114
+ const delay = Math.min(
115
+ RECONNECT_BASE_MS * Math.pow(2, entry.reconnectAttempt - 1),
116
+ RECONNECT_MAX_MS,
117
+ );
118
+ console.log(`[SSE] reconnect: ${sessionId} in ${delay}ms`);
119
+ entry.reconnectTimer = window.setTimeout(() => {
120
+ entry.reconnectTimer = null;
121
+ openConnection(sessionId);
122
+ }, delay);
123
+ };
124
+ }, [setStatus]);
125
+
126
+ const connectSession = useCallback((sessionId: string) => {
127
+ console.log(`[SSE] connectSession: ${sessionId}, authReady=${isAuthReady}, sandbox=${currentSandbox?.status}`);
128
+ if (!isAuthReady) return;
129
+ if (!runtimeConfig.useMockBackend && currentSandbox?.status !== "running") return;
130
+ openConnection(sessionId);
131
+ }, [isAuthReady, currentSandbox?.status, openConnection]);
132
+
133
+ const disconnectSession = useCallback((sessionId: string) => {
134
+ console.log(`[SSE] disconnectSession: ${sessionId}`);
135
+ const entry = connsRef.current.get(sessionId);
136
+ if (!entry) return;
137
+ entry.manuallyClosed = true;
138
+ if (entry.reconnectTimer !== null) {
139
+ window.clearTimeout(entry.reconnectTimer);
140
+ entry.reconnectTimer = null;
141
+ }
142
+ entry.source.close();
143
+ connsRef.current.delete(sessionId);
144
+ setStatus(sessionId, "idle");
145
+ }, [setStatus]);
146
+
147
+ // On unmount or auth/sandbox change, tear down every connection.
148
+ useEffect(() => {
149
+ return () => {
150
+ for (const [, entry] of connsRef.current) {
151
+ entry.manuallyClosed = true;
152
+ if (entry.reconnectTimer !== null) {
153
+ window.clearTimeout(entry.reconnectTimer);
154
+ }
155
+ entry.source.close();
156
+ }
157
+ connsRef.current.clear();
158
+ };
159
+ }, [isAuthReady, currentSandbox?.status]);
160
+
161
+ const value = useMemo<SSEContextValue>(
162
+ () => ({ connectSession, disconnectSession, queueRef, tick, connections }),
163
+ [connectSession, disconnectSession, tick, connections],
164
+ );
165
+
166
+ return <SSEContext.Provider value={value}>{children}</SSEContext.Provider>;
167
+ }
168
+
169
+ export function useSSE() {
170
+ const value = useContext(SSEContext);
171
+ if (!value) {
172
+ throw new Error("useSSE must be used within SSEProvider");
173
+ }
174
+ return value;
175
+ }
@@ -0,0 +1,310 @@
1
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Sandbox, SandboxStats } from "../contracts/backend";
3
+ import { api } from "../utils/api";
4
+ import { tg } from "../i18n/translate";
5
+ import { useAuth } from "./AuthContext";
6
+ import { runtimeConfig } from "../config";
7
+
8
+ export type SandboxOperation = "idle" | "loading" | "creating" | "rebuilding" | "destroying";
9
+
10
+ interface SandboxContextValue {
11
+ sandboxes: Sandbox[];
12
+ currentSandbox: Sandbox | null;
13
+ stats: SandboxStats | null;
14
+ status: string;
15
+ operation: SandboxOperation;
16
+ error: string | null;
17
+ logs: string;
18
+ refresh: () => Promise<void>;
19
+ refreshStats: () => Promise<void>;
20
+ createSandbox: () => Promise<void>;
21
+ rebuildSandbox: () => Promise<void>;
22
+ destroySandbox: () => Promise<void>;
23
+ reloadConfig: () => Promise<void>;
24
+ }
25
+
26
+ const SandboxContext = createContext<SandboxContextValue | null>(null);
27
+
28
+ function selectSandbox(sandboxes: Sandbox[]): Sandbox | null {
29
+ // Skip "error" rows: they're stale corpses from a previous container that
30
+ // the backend's _sync_running_status conservatively flipped to ERROR only
31
+ // after confirming the container was not_found / exited / dead / removing.
32
+ // Treating them as currentSandbox would block auto-create and leave the
33
+ // user stuck on "沙盒状态异常" until they manually clicked Build.
34
+ const live = sandboxes.filter((sandbox) => sandbox.status !== "error");
35
+ return live.find((sandbox) => sandbox.status === "running") ?? live[0] ?? null;
36
+ }
37
+
38
+ export function SandboxProvider({ children }: { children: ReactNode }) {
39
+ // Single-user local mode: there is no container lifecycle. The runtime
40
+ // launched by `brainpilot up` is always the sandbox, addressed per-session by
41
+ // the file routes. We expose a static "running" sandbox so the composer and
42
+ // file panels work, and never call /api/sandbox/* lifecycle endpoints (which
43
+ // the local backend does not implement). File operations resolve the real
44
+ // workspace by the active session id inside the file components themselves.
45
+ if (runtimeConfig.localMode) {
46
+ return <LocalSandboxProvider>{children}</LocalSandboxProvider>;
47
+ }
48
+ return <RemoteSandboxProvider>{children}</RemoteSandboxProvider>;
49
+ }
50
+
51
+ const LOCAL_SANDBOX: Sandbox = {
52
+ id: "local",
53
+ name: "local",
54
+ status: "running",
55
+ port: null,
56
+ userId: "local",
57
+ createdAt: new Date(0).toISOString(),
58
+ };
59
+
60
+ function LocalSandboxProvider({ children }: { children: ReactNode }) {
61
+ const noop = useCallback(async () => {}, []);
62
+ const value = useMemo<SandboxContextValue>(
63
+ () => ({
64
+ sandboxes: [LOCAL_SANDBOX],
65
+ currentSandbox: LOCAL_SANDBOX,
66
+ stats: null,
67
+ status: "running",
68
+ operation: "idle",
69
+ error: null,
70
+ logs: "",
71
+ refresh: noop,
72
+ refreshStats: noop,
73
+ createSandbox: noop,
74
+ rebuildSandbox: noop,
75
+ destroySandbox: noop,
76
+ reloadConfig: noop,
77
+ }),
78
+ [noop],
79
+ );
80
+ return <SandboxContext.Provider value={value}>{children}</SandboxContext.Provider>;
81
+ }
82
+
83
+ function RemoteSandboxProvider({ children }: { children: ReactNode }) {
84
+ const { isAuthReady } = useAuth();
85
+ const [sandboxes, setSandboxes] = useState<Sandbox[]>([]);
86
+ const [currentSandbox, setCurrentSandbox] = useState<Sandbox | null>(null);
87
+ const [stats, setStats] = useState<SandboxStats | null>(null);
88
+ const [operation, setOperation] = useState<SandboxOperation>("idle");
89
+ const [error, setError] = useState<string | null>(null);
90
+ const [logs, setLogs] = useState<string>("");
91
+ const [hasLoaded, setHasLoaded] = useState(false);
92
+ const shouldAutoStartRef = useRef(true);
93
+
94
+ const refresh = useCallback(async () => {
95
+ if (!isAuthReady) {
96
+ setSandboxes([]);
97
+ setCurrentSandbox(null);
98
+ setStats(null);
99
+ return;
100
+ }
101
+
102
+ setOperation((current) => (current === "idle" ? "loading" : current));
103
+ setError(null);
104
+ try {
105
+ const nextSandboxes = await api.sandbox.list();
106
+ setSandboxes(nextSandboxes);
107
+ setCurrentSandbox(selectSandbox(nextSandboxes));
108
+ setHasLoaded(true);
109
+ } catch (err) {
110
+ setError(err instanceof Error ? err.message : tg("ctx.sandbox.loadFailed"));
111
+ } finally {
112
+ setOperation((current) => (current === "loading" ? "idle" : current));
113
+ }
114
+ }, [isAuthReady]);
115
+
116
+ const refreshStats = useCallback(async () => {
117
+ if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
118
+ setStats(null);
119
+ return;
120
+ }
121
+
122
+ try {
123
+ setStats(await api.sandbox.stats(currentSandbox.id));
124
+ } catch {
125
+ setStats(null);
126
+ // 如果 stats 获取失败(容器可能已不存在),刷新列表以同步真实状态
127
+ if (currentSandbox?.status === "running" || currentSandbox?.status === "quota_exceeded") {
128
+ void refresh();
129
+ }
130
+ }
131
+ }, [currentSandbox, refresh]);
132
+
133
+ const fetchLogs = useCallback(async () => {
134
+ if (!currentSandbox?.id) return;
135
+ try {
136
+ setLogs(await api.sandbox.logs(currentSandbox.id));
137
+ } catch {
138
+ // 日志获取失败也可能是容器已停止的信号,刷新列表同步状态
139
+ if (currentSandbox?.status === "running") {
140
+ void refresh();
141
+ }
142
+ }
143
+ }, [currentSandbox?.id, refresh]);
144
+
145
+ useEffect(() => {
146
+ void refresh();
147
+ }, [refresh]);
148
+
149
+ // Reset auto-start flag once auth becomes ready.
150
+ useEffect(() => {
151
+ shouldAutoStartRef.current = true;
152
+ setHasLoaded(false);
153
+ }, [isAuthReady]);
154
+
155
+ useEffect(() => {
156
+ void refreshStats();
157
+ if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
158
+ return;
159
+ }
160
+ const timer = window.setInterval(() => void refreshStats(), 8000);
161
+ return () => window.clearInterval(timer);
162
+ }, [currentSandbox, refreshStats]);
163
+
164
+ // Poll logs every 3s when running or quota_exceeded
165
+ useEffect(() => {
166
+ void fetchLogs();
167
+ if (!currentSandbox || (currentSandbox.status !== "running" && currentSandbox.status !== "quota_exceeded")) {
168
+ return;
169
+ }
170
+ const timer = window.setInterval(() => void fetchLogs(), 3000);
171
+ return () => window.clearInterval(timer);
172
+ }, [currentSandbox, fetchLogs]);
173
+
174
+ const createSandbox = useCallback(async () => {
175
+ setOperation("creating");
176
+ setError(null);
177
+ try {
178
+ const sandbox = await api.sandbox.create("default");
179
+ setSandboxes((current) => [sandbox, ...current.filter((item) => item.id !== sandbox.id)]);
180
+ setCurrentSandbox(sandbox);
181
+ } catch (err) {
182
+ setError(err instanceof Error ? err.message : tg("ctx.sandbox.createFailed"));
183
+ throw err;
184
+ } finally {
185
+ setOperation("idle");
186
+ }
187
+ }, []);
188
+
189
+ const rebuildSandbox = useCallback(async () => {
190
+ if (!currentSandbox) {
191
+ return;
192
+ }
193
+ setOperation("rebuilding");
194
+ setError(null);
195
+ try {
196
+ const sandbox = await api.sandbox.rebuild(currentSandbox.id);
197
+ setSandboxes((current) => current.map((item) => (item.id === sandbox.id ? sandbox : item)));
198
+ setCurrentSandbox(sandbox);
199
+ } catch (err) {
200
+ setError(err instanceof Error ? err.message : tg("ctx.sandbox.rebuildFailed"));
201
+ throw err;
202
+ } finally {
203
+ setOperation("idle");
204
+ }
205
+ }, [currentSandbox]);
206
+
207
+ const destroySandbox = useCallback(async () => {
208
+ if (!currentSandbox) {
209
+ return;
210
+ }
211
+ setOperation("destroying");
212
+ setError(null);
213
+ try {
214
+ await api.sandbox.destroy(currentSandbox.id);
215
+ const remaining = sandboxes.filter((item) => item.id !== currentSandbox.id);
216
+ setSandboxes(remaining);
217
+ setCurrentSandbox(selectSandbox(remaining));
218
+ setStats(null);
219
+ } catch (err) {
220
+ setError(err instanceof Error ? err.message : tg("ctx.sandbox.deleteFailed"));
221
+ throw err;
222
+ } finally {
223
+ setOperation("idle");
224
+ }
225
+ }, [currentSandbox, sandboxes]);
226
+
227
+ // Reload sandbox config (hot-update) - from master
228
+ const reloadConfig = useCallback(async () => {
229
+ if (!currentSandbox) return;
230
+ try {
231
+ await api.sandbox.reloadConfig(currentSandbox.id);
232
+ } catch (err: any) {
233
+ setError(err instanceof Error ? err.message : tg("ctx.sandbox.reloadFailed"));
234
+ }
235
+ }, [currentSandbox]);
236
+
237
+ // Auto-create only when the user truly has no sandbox. Never auto-rebuild on
238
+ // page mount: an existing container is almost always still alive — the row
239
+ // may merely have been flipped to error by a transient Docker hiccup during
240
+ // a previous /api/sandbox/list. If it really did die, SandboxStatus shows
241
+ // "沙盒状态异常" + a manual Rebuild button. Creating / rebuilding are
242
+ // transient backend states and naturally need no action.
243
+ //
244
+ // hasLoaded gate: on initial mount the auto-start effect fires *before* the
245
+ // first refresh() completes (refresh's setOperation('loading') is an async
246
+ // state update, but the effect already runs in the same batch as the
247
+ // auth-ready change). Without this gate currentSandbox is null on first render and we
248
+ // mistakenly call createSandbox() → force-removes the same-named live
249
+ // container. Wait until at least one /api/sandbox/list has returned before
250
+ // making the call.
251
+ useEffect(() => {
252
+ if (!isAuthReady) return;
253
+ if (!hasLoaded) return;
254
+ if (operation !== "idle") return;
255
+ if (!shouldAutoStartRef.current) return;
256
+
257
+ shouldAutoStartRef.current = false;
258
+ if (!currentSandbox) {
259
+ void createSandbox();
260
+ }
261
+ }, [isAuthReady, hasLoaded, operation, currentSandbox, createSandbox]);
262
+
263
+ const value = useMemo(
264
+ () => ({
265
+ sandboxes,
266
+ currentSandbox,
267
+ stats,
268
+ status:
269
+ currentSandbox?.status ??
270
+ (operation === "creating"
271
+ ? "creating"
272
+ : operation === "loading"
273
+ ? "loading"
274
+ : "missing"),
275
+ operation,
276
+ error,
277
+ logs,
278
+ refresh,
279
+ refreshStats,
280
+ createSandbox,
281
+ rebuildSandbox,
282
+ destroySandbox,
283
+ reloadConfig,
284
+ }),
285
+ [
286
+ sandboxes,
287
+ currentSandbox,
288
+ stats,
289
+ operation,
290
+ error,
291
+ logs,
292
+ refresh,
293
+ refreshStats,
294
+ createSandbox,
295
+ rebuildSandbox,
296
+ destroySandbox,
297
+ reloadConfig,
298
+ ],
299
+ );
300
+
301
+ return <SandboxContext.Provider value={value}>{children}</SandboxContext.Provider>;
302
+ }
303
+
304
+ export function useSandbox() {
305
+ const value = useContext(SandboxContext);
306
+ if (!value) {
307
+ throw new Error("useSandbox must be used within SandboxProvider");
308
+ }
309
+ return value;
310
+ }