@marimo-team/islands 0.24.3-dev37 → 0.24.3-dev39

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 (45) hide show
  1. package/dist/{common-BsQjZnO0.js → common-eNTtBSo2.js} +2 -2
  2. package/dist/main.js +6 -2
  3. package/dist/{reveal-component-baBJ3nFu.js → reveal-component-CNqmC7b6.js} +1 -1
  4. package/dist/style.css +1 -1
  5. package/package.json +1 -1
  6. package/src/__mocks__/requests.ts +7 -0
  7. package/src/components/editor/alerts/connecting-alert.tsx +1 -66
  8. package/src/components/editor/alerts/connection-notice.tsx +140 -0
  9. package/src/components/editor/app-container.tsx +9 -3
  10. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +114 -7
  11. package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +165 -0
  12. package/src/components/editor/chrome/panels/packages-panel.tsx +93 -166
  13. package/src/components/editor/chrome/panels/sandbox-controller.tsx +106 -0
  14. package/src/components/editor/chrome/panels/sandbox-panel.tsx +173 -0
  15. package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +3 -1
  16. package/src/components/editor/header/__tests__/status.test.tsx +8 -20
  17. package/src/components/editor/header/status.tsx +5 -3
  18. package/src/components/editor/renderers/cell-array.tsx +1 -5
  19. package/src/core/MarimoApp.tsx +0 -2
  20. package/src/core/edit-app.tsx +8 -5
  21. package/src/core/errors/state.ts +1 -2
  22. package/src/core/islands/bootstrap.ts +1 -0
  23. package/src/core/islands/bridge.ts +3 -0
  24. package/src/core/network/connection-notice.ts +84 -0
  25. package/src/core/network/requests-lazy.ts +3 -0
  26. package/src/core/network/requests-network.ts +12 -0
  27. package/src/core/network/requests-static.ts +3 -0
  28. package/src/core/network/requests-toasting.tsx +3 -0
  29. package/src/core/network/resolve.ts +2 -1
  30. package/src/core/network/types.ts +7 -0
  31. package/src/core/packages/__tests__/usePackageAction.test.tsx +129 -0
  32. package/src/core/packages/__tests__/useSandboxController.test.tsx +165 -0
  33. package/src/core/packages/package-data.ts +35 -0
  34. package/src/core/packages/sandbox-state.ts +15 -0
  35. package/src/core/packages/toast-components.tsx +11 -0
  36. package/src/core/packages/useInstallPackage.ts +1 -1
  37. package/src/core/packages/usePackageAction.ts +51 -0
  38. package/src/core/packages/usePackageDependencies.ts +22 -0
  39. package/src/core/packages/useSandboxController.ts +188 -0
  40. package/src/core/run-app.tsx +2 -0
  41. package/src/core/wasm/bridge.ts +11 -0
  42. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +212 -2
  43. package/src/core/websocket/types.ts +11 -1
  44. package/src/core/websocket/useMarimoKernelConnection.tsx +43 -8
  45. package/src/stories/sandbox.stories.tsx +308 -0
@@ -42,7 +42,9 @@ export const BackendConnectionStatus: React.FC = () => {
42
42
  const { isFetching, error, data, refetch } = useAsyncData(async () => {
43
43
  // If the connection is not connected, return
44
44
  if (!isAppConnected(connection)) {
45
- setConnectionStatus("disconnected");
45
+ setConnectionStatus(
46
+ isAppConnecting(connection) ? "connecting" : "disconnected",
47
+ );
46
48
  return;
47
49
  }
48
50
 
@@ -66,24 +66,12 @@ describe("StatusOverlay disconnect indicator", () => {
66
66
  expect((button as HTMLButtonElement).disabled).toBe(true);
67
67
  });
68
68
 
69
- it.each([
70
- [
71
- WebSocketClosedReason.KERNEL_STARTUP_ERROR,
72
- "Failed to start kernel sandbox",
73
- ],
74
- ])(
75
- "renders a disabled button for non-recoverable close reason %s",
76
- (code, reason) => {
77
- const onReconnect = vi.fn();
78
- const { getByTestId } = renderOverlay(
79
- { state: WebSocketState.CLOSED, code, reason },
80
- onReconnect,
81
- );
82
-
83
- const button = getByTestId("disconnected-indicator") as HTMLButtonElement;
84
- expect(button.disabled).toBe(true);
85
- fireEvent.click(button);
86
- expect(onReconnect).not.toHaveBeenCalled();
87
- },
88
- );
69
+ it("leaves editor startup failures to the inline notice", () => {
70
+ const { queryByTestId } = renderOverlay({
71
+ state: WebSocketState.CLOSED,
72
+ code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
73
+ reason: "Failed to start kernel sandbox",
74
+ });
75
+ expect(queryByTestId("disconnected-indicator")).not.toBeInTheDocument();
76
+ });
89
77
  });
@@ -20,10 +20,12 @@ export const StatusOverlay: React.FC<{
20
20
  onReconnect?: () => void;
21
21
  }> = ({ connection, isRunning, onReconnect }) => {
22
22
  const { mode } = useAtomValue(viewStateAtom);
23
- const isClosed = connection.state === WebSocketState.CLOSED;
23
+ const isClosed =
24
+ connection.state === WebSocketState.CLOSED &&
25
+ (mode === "read" ||
26
+ connection.code !== WebSocketClosedReason.KERNEL_STARTUP_ERROR);
24
27
  const isOpen = connection.state === WebSocketState.OPEN;
25
- // Only KERNEL_DISCONNECTED is recoverable by a retry. KERNEL_STARTUP_ERROR
26
- // would deterministically fail the same way.
28
+ // Editor startup failures have their own recovery UI.
27
29
  const canReconnect =
28
30
  isClosed && connection.code === WebSocketClosedReason.KERNEL_DISCONNECTED;
29
31
 
@@ -43,10 +43,7 @@ import type { AppMode } from "../../../core/mode";
43
43
  import { useHotkey } from "../../../hooks/useHotkey";
44
44
  import { type Theme, useTheme } from "../../../theme/useTheme";
45
45
  import { AddCellWithAI } from "../ai/add-cell-with-ai";
46
- import {
47
- ConnectingAlert,
48
- NotStartedConnectionAlert,
49
- } from "../alerts/connecting-alert";
46
+ import { NotStartedConnectionAlert } from "../alerts/connecting-alert";
50
47
  import { FloatingOutline } from "../chrome/panels/outline/floating-outline";
51
48
  import { useChromeActions } from "../chrome/state";
52
49
  import { Column } from "../columns/cell-column";
@@ -140,7 +137,6 @@ const CellArrayInternal: React.FC<CellArrayProps> = ({
140
137
  <PackageAlert />
141
138
  <StartupLogsAlert />
142
139
  <StdinBlockingAlert />
143
- <ConnectingAlert />
144
140
  <NotebookBanner width={appConfig.width} />
145
141
  {/* Only show if not cells, otherwise running a single cell will start the connection */}
146
142
  {cellIds.idLength === 0 && <NotStartedConnectionAlert />}
@@ -12,7 +12,6 @@ import { getInitialAppMode } from "@/core/mode";
12
12
  import { CssVariables } from "@/theme/ThemeProvider";
13
13
  import { reactLazyWithPreload } from "@/utils/lazy";
14
14
  import { ErrorBoundary } from "../components/editor/boundary/ErrorBoundary";
15
- import { KernelStartupErrorModal } from "../components/editor/KernelStartupErrorModal";
16
15
  import { TracebackModalContainer } from "../components/editor/TracebackModalContainer";
17
16
  import { ModalProvider } from "../components/modal/ImperativeModal";
18
17
  import { Toaster } from "../components/ui/toaster";
@@ -105,7 +104,6 @@ const Providers = memo(({ children }: PropsWithChildren) => {
105
104
  {children}
106
105
  <Toaster />
107
106
  <TailwindIndicator />
108
- <KernelStartupErrorModal />
109
107
  <TracebackModalContainer />
110
108
  </ModalProvider>
111
109
  </LocaleProvider>
@@ -9,7 +9,8 @@ import { useAtomValue, useSetAtom } from "jotai";
9
9
  import { useEffect } from "react";
10
10
  import { NotStartedConnectionAlert } from "@/components/editor/alerts/connecting-alert";
11
11
  import { Controls } from "@/components/editor/controls/Controls";
12
- import { AppHeader } from "@/components/editor/header/app-header";
12
+ import { SandboxController } from "@/components/editor/chrome/panels/sandbox-controller";
13
+ import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
13
14
  import { FilenameForm } from "@/components/editor/header/filename-form";
14
15
  import { MultiCellActionToolbar } from "@/components/editor/navigation/multi-cell-action-toolbar";
15
16
  import { ViewerBanner } from "@/components/editor/viewer-banner";
@@ -158,6 +159,7 @@ export const EditApp: React.FC<AppProps> = ({
158
159
 
159
160
  return (
160
161
  <>
162
+ <SandboxController onReconnect={reconnect} />
161
163
  <AppContainer
162
164
  connection={connection}
163
165
  isRunning={isRunning}
@@ -165,10 +167,9 @@ export const EditApp: React.FC<AppProps> = ({
165
167
  onReconnect={reconnect}
166
168
  >
167
169
  <ViewerBanner />
168
- <AppHeader
169
- connection={connection}
170
+ <div
170
171
  className={cn(
171
- "pt-4 sm:pt-12 pb-2 mb-4 print:hidden z-50",
172
+ "pt-4 sm:pt-12 pb-6 print:hidden z-50",
172
173
  // Keep the header sticky when scrolling horizontally, for column mode
173
174
  "sticky left-0",
174
175
  )}
@@ -178,7 +179,9 @@ export const EditApp: React.FC<AppProps> = ({
178
179
  <FilenameForm filename={filename} />
179
180
  </div>
180
181
  )}
181
- </AppHeader>
182
+ </div>
183
+
184
+ <ConnectionNotice appConfig={appConfig} onRetry={reconnect} />
182
185
 
183
186
  {/* Don't render until we have a single cell. NotStartedConnectionAlert
184
187
  still covers the "no remote runtime started" prompt. */}
@@ -7,8 +7,7 @@ import { generateUUID } from "@/utils/uuid";
7
7
  import type { Banner } from "../kernel/messages";
8
8
 
9
9
  /**
10
- * Atom for storing kernel startup error message.
11
- * When set to a non-null value, shows a modal with the error details.
10
+ * Full startup diagnostics, retained until a new connection attempt or readiness.
12
11
  */
13
12
  export const kernelStartupErrorAtom = atom<string | null>(null);
14
13
 
@@ -201,6 +201,7 @@ function handleMessage(
201
201
  case "storage-download-ready":
202
202
  case "secret-keys-result":
203
203
  case "startup-logs":
204
+ case "startup-progress":
204
205
  case "interrupted":
205
206
  case "reconnected":
206
207
  case "cache-cleared":
@@ -370,6 +370,9 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
370
370
  addPackage = throwNotImplemented;
371
371
  removePackage = throwNotImplemented;
372
372
  getPackageList = throwNotImplemented;
373
+ getSandbox = throwNotImplemented;
374
+ updateManifest = throwNotImplemented;
375
+ syncSandbox = throwNotImplemented;
373
376
  getDependencyTree = throwNotImplemented;
374
377
  getRecentFiles = throwNotImplemented;
375
378
  getWorkspaceFiles = throwNotImplemented;
@@ -0,0 +1,84 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { atom } from "jotai";
3
+ import { kernelStartupErrorAtom } from "@/core/errors/state";
4
+ import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
5
+ import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
6
+ import { connectionAtom } from "./connection";
7
+
8
+ export const connectionNoticeAtom = atom((get) => {
9
+ const connection = get(connectionAtom);
10
+ const backend = get(sandboxAtom)?.backend;
11
+ const sandbox = Boolean(backend);
12
+ const sync = get(sandboxSyncAtom);
13
+ if (
14
+ sandbox &&
15
+ (sync.pending || sync.error) &&
16
+ connection.state === WebSocketState.OPEN
17
+ ) {
18
+ return {
19
+ sandbox,
20
+ title: sync.pending ? "Syncing sandbox…" : "Sandbox sync failed",
21
+ description: sync.pending
22
+ ? "Applying the saved manifest to this notebook’s environment."
23
+ : "Review the error details, then try again.",
24
+ pending: sync.pending,
25
+ error: sync.error,
26
+ };
27
+ }
28
+ if (connection.state === WebSocketState.CONNECTING) {
29
+ const messages = {
30
+ "preparing-environment": {
31
+ title: backend
32
+ ? `Preparing ${backend} sandbox…`
33
+ : "Preparing environment…",
34
+ description:
35
+ "Getting this notebook’s Python environment ready. The first start can take a few minutes.",
36
+ },
37
+ "starting-kernel": {
38
+ title: "Starting notebook…",
39
+ description:
40
+ "The environment is prepared. Waiting for the Python kernel to connect to this notebook.",
41
+ },
42
+ reconnecting: {
43
+ title: "Reconnecting…",
44
+ description:
45
+ "The connection was interrupted. Your notebook and existing outputs stay visible while the kernel reconnects.",
46
+ },
47
+ };
48
+ return {
49
+ sandbox,
50
+ ...(connection.phase
51
+ ? messages[connection.phase]
52
+ : {
53
+ title: "Connecting…",
54
+ description: "Waiting for the notebook’s kernel to connect.",
55
+ }),
56
+ pending: true,
57
+ error: null,
58
+ };
59
+ }
60
+ if (connection.state === WebSocketState.CLOSED) {
61
+ const startupFailed =
62
+ connection.code === WebSocketClosedReason.KERNEL_STARTUP_ERROR;
63
+ const environmentFailed =
64
+ startupFailed && connection.phase === "preparing-environment";
65
+ return {
66
+ sandbox,
67
+ title: environmentFailed
68
+ ? "Sandbox setup failed"
69
+ : startupFailed
70
+ ? "Kernel failed to start"
71
+ : connection.reason,
72
+ description: environmentFailed
73
+ ? "The notebook can’t run yet."
74
+ : startupFailed
75
+ ? "The Python kernel could not start. Check the error details, then try again."
76
+ : "The connection to the notebook’s kernel was lost.",
77
+ pending: false,
78
+ error: startupFailed
79
+ ? (get(kernelStartupErrorAtom) ?? connection.reason)
80
+ : null,
81
+ };
82
+ }
83
+ return null;
84
+ });
@@ -93,6 +93,9 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
93
93
  saveUserConfig: "throwError",
94
94
  sendShutdown: "throwError",
95
95
  getPackageList: "throwError",
96
+ getSandbox: "serverOnly",
97
+ updateManifest: "serverOnly",
98
+ syncSandbox: "serverOnly",
96
99
  getDependencyTree: "throwError",
97
100
  addPackage: "throwError",
98
101
  removePackage: "throwError",
@@ -532,6 +532,18 @@ export function createNetworkRequests(): EditRequests & RunRequests {
532
532
  })
533
533
  .then(handleResponseReturnNull);
534
534
  },
535
+ getSandbox: (request) =>
536
+ getClient()
537
+ .POST("/api/packages/sandbox", { body: request, params: getParams() })
538
+ .then(handleResponse),
539
+ updateManifest: (request) =>
540
+ getClient()
541
+ .POST("/api/packages/manifest", { body: request, params: getParams() })
542
+ .then(handleResponse),
543
+ syncSandbox: (request) =>
544
+ getClient()
545
+ .POST("/api/packages/sync", { body: request, params: getParams() })
546
+ .then(handleResponse),
535
547
  addPackage: (request) => {
536
548
  return getClient()
537
549
  .POST("/api/packages/add", {
@@ -98,6 +98,9 @@ export function createStaticRequests(): EditRequests & RunRequests {
98
98
  addPackage: throwNotInEditMode,
99
99
  removePackage: throwNotInEditMode,
100
100
  getPackageList: throwNotInEditMode,
101
+ getSandbox: throwNotInEditMode,
102
+ updateManifest: throwNotInEditMode,
103
+ syncSandbox: throwNotInEditMode,
101
104
  getDependencyTree: throwNotInEditMode,
102
105
  listSecretKeys: throwNotInEditMode,
103
106
  writeSecret: throwNotInEditMode,
@@ -79,6 +79,9 @@ export function createErrorToastingRequests(
79
79
  addPackage: "Failed to add package",
80
80
  removePackage: "Failed to remove package",
81
81
  getPackageList: "Failed to get package list",
82
+ getSandbox: "",
83
+ updateManifest: "",
84
+ syncSandbox: "",
82
85
  getDependencyTree: "Failed to get dependency tree",
83
86
  listSecretKeys: "Failed to fetch secrets",
84
87
  writeSecret: "Failed to write secret",
@@ -1,4 +1,5 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { withPackageInvalidation } from "../packages/package-data";
2
3
  import { getRuntimeManager } from "../runtime/config";
3
4
  import { isStaticNotebook } from "../static/static-state";
4
5
  import { PyodideBridge } from "../wasm/bridge";
@@ -20,5 +21,5 @@ export function resolveRequestClient(): EditRequests & RunRequests {
20
21
  getRuntimeManager(),
21
22
  );
22
23
  }
23
- return createErrorToastingRequests(base);
24
+ return withPackageInvalidation(createErrorToastingRequests(base));
24
25
  }
@@ -69,6 +69,10 @@ export type ListPackagesResponse = schemas["ListPackagesResponse"];
69
69
  export type DependencyTreeResponse = schemas["DependencyTreeResponse"];
70
70
  export type DependencyTreeNode = schemas["DependencyTreeNode"];
71
71
 
72
+ export type SandboxRequest = schemas["SandboxRequest"];
73
+ export type SandboxResponse = schemas["SandboxResponse"];
74
+ export type UpdateManifestRequest = schemas["UpdateManifestRequest"];
75
+ export type SyncSandboxResponse = schemas["SyncSandboxResponse"];
72
76
  export type PackageOperationResponse = schemas["PackageOperationResponse"];
73
77
  export type InstantiateNotebookRequest = schemas["InstantiateNotebookRequest"];
74
78
  export type MarimoConfig = schemas["MarimoConfig"];
@@ -246,6 +250,9 @@ export interface EditRequests {
246
250
  autoExportAsMarkdown: (request: AutoExportAsMarkdownRequest) => Promise<null>;
247
251
  autoExportAsIPYNB: (request: AutoExportAsIPYNBRequest) => Promise<null>;
248
252
  updateCellOutputs: (request: UpdateCellOutputsRequest) => Promise<null>;
253
+ getSandbox: (request: SandboxRequest) => Promise<SandboxResponse>;
254
+ updateManifest: (request: UpdateManifestRequest) => Promise<SandboxResponse>;
255
+ syncSandbox: (request: SandboxRequest) => Promise<SyncSandboxResponse>;
249
256
  // Package requests
250
257
  getPackageList: () => Promise<ListPackagesResponse>;
251
258
  getDependencyTree: () => Promise<DependencyTreeResponse>;
@@ -0,0 +1,129 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook } from "@testing-library/react";
3
+ import { createStore, Provider } from "jotai";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MockRequestClient } from "@/__mocks__/requests";
6
+ import { toast } from "@/components/ui/use-toast";
7
+ import { requestClientAtom } from "@/core/network/requests";
8
+ import type { PackageOperationResponse } from "@/core/network/types";
9
+ import { usePackageAction } from "../usePackageAction";
10
+
11
+ vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
12
+
13
+ function setup(action: "upgrade" | "remove") {
14
+ const client = MockRequestClient.create();
15
+ const store = createStore();
16
+ store.set(requestClientAtom, client);
17
+ return {
18
+ request: vi.mocked(
19
+ action === "upgrade" ? client.addPackage : client.removePackage,
20
+ ),
21
+ ...renderHook(
22
+ () =>
23
+ usePackageAction(action, "numpy", [{ kind: "group", value: "dev" }]),
24
+ {
25
+ wrapper: ({ children }) => (
26
+ <Provider store={store}>{children}</Provider>
27
+ ),
28
+ },
29
+ ),
30
+ };
31
+ }
32
+
33
+ describe("usePackageAction", () => {
34
+ beforeEach(() => vi.clearAllMocks());
35
+
36
+ it.each([
37
+ { action: "upgrade", title: "Package upgraded", upgrade: true },
38
+ { action: "remove", title: "Package removed" },
39
+ ] as const)(
40
+ "preserves dependency groups and prevents duplicate $action requests",
41
+ async ({ action, title, ...options }) => {
42
+ const { result, request } = setup(action);
43
+ const { promise, resolve } =
44
+ Promise.withResolvers<PackageOperationResponse>();
45
+ request.mockReturnValue(promise);
46
+ let pending: Promise<void>;
47
+ act(() => {
48
+ pending = result.current.run();
49
+ });
50
+ expect(result.current.loading).toBe(true);
51
+ await act(async () => {
52
+ await result.current.run();
53
+ });
54
+ expect(request).toHaveBeenCalledExactlyOnceWith({
55
+ package: "numpy",
56
+ group: "dev",
57
+ ...options,
58
+ });
59
+ await act(async () => {
60
+ resolve({ success: true });
61
+ await pending;
62
+ });
63
+ expect(result.current.loading).toBe(false);
64
+ expect(toast).toHaveBeenCalledExactlyOnceWith(
65
+ expect.objectContaining({ title }),
66
+ );
67
+ },
68
+ );
69
+
70
+ it("reports saved changes requiring a restart without claiming success", async () => {
71
+ const { result, request } = setup("upgrade");
72
+ request.mockResolvedValue({ success: false, restartRequired: true });
73
+ await act(async () => {
74
+ await result.current.run();
75
+ });
76
+ expect(toast).toHaveBeenCalledExactlyOnceWith(
77
+ expect.objectContaining({
78
+ title: "Changes saved — restart required",
79
+ }),
80
+ );
81
+ });
82
+
83
+ it.each([
84
+ {
85
+ response: { success: false, error: "resolver failed" },
86
+ description: "resolver failed",
87
+ },
88
+ { response: { success: false }, description: expect.any(String) },
89
+ ])(
90
+ "reports failed responses as errors: $response",
91
+ async ({ response, description }) => {
92
+ const { result, request } = setup("remove");
93
+ request.mockResolvedValue(response);
94
+ await act(async () => {
95
+ await result.current.run();
96
+ });
97
+ expect(toast).toHaveBeenCalledExactlyOnceWith(
98
+ expect.objectContaining({
99
+ title: "Failed to remove package",
100
+ description,
101
+ variant: "danger",
102
+ }),
103
+ );
104
+ },
105
+ );
106
+
107
+ it("reports request errors and allows a retry", async () => {
108
+ const { result, request } = setup("remove");
109
+ request.mockRejectedValueOnce(new Error("offline"));
110
+ await act(async () => {
111
+ await result.current.run();
112
+ });
113
+ expect(toast).toHaveBeenCalledExactlyOnceWith(
114
+ expect.objectContaining({
115
+ title: "Failed to remove package",
116
+ description: "offline",
117
+ variant: "danger",
118
+ }),
119
+ );
120
+ expect(result.current.loading).toBe(false);
121
+ request.mockResolvedValue({ success: true });
122
+ await act(async () => {
123
+ await result.current.run();
124
+ });
125
+ expect(toast).toHaveBeenLastCalledWith(
126
+ expect.objectContaining({ title: "Package removed" }),
127
+ );
128
+ });
129
+ });
@@ -0,0 +1,165 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import { Provider } from "jotai";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { MockRequestClient } from "@/__mocks__/requests";
6
+ import { kernelStartupErrorAtom } from "@/core/errors/state";
7
+ import { connectionAtom } from "@/core/network/connection";
8
+ import { requestClientAtom } from "@/core/network/requests";
9
+ import type {
10
+ SandboxResponse,
11
+ SyncSandboxResponse,
12
+ } from "@/core/network/types";
13
+ import { filenameAtom } from "@/core/saving/file-state";
14
+ import { store } from "@/core/state/jotai";
15
+ import { WebSocketState } from "@/core/websocket/types";
16
+ import { HTTPError } from "@/utils/errors";
17
+ import { packageDataVersionAtom } from "../package-data";
18
+ import {
19
+ sandboxActionsAtom,
20
+ sandboxAtom,
21
+ sandboxSyncAtom,
22
+ } from "../sandbox-state";
23
+ import { useSandboxController } from "../useSandboxController";
24
+
25
+ vi.mock("../toast-components", () => ({
26
+ showPackageRestartToast: vi.fn(),
27
+ showSandboxSyncToast: vi.fn(),
28
+ }));
29
+
30
+ const manifest = 'dependencies = ["numpy"]';
31
+ const sandbox: SandboxResponse = {
32
+ backend: "uv",
33
+ manifest,
34
+ filename: "notebook.py",
35
+ };
36
+
37
+ beforeEach(() => {
38
+ store.set(sandboxAtom, null);
39
+ store.set(sandboxSyncAtom, { pending: false, error: null });
40
+ store.set(sandboxActionsAtom, null);
41
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
42
+ store.set(kernelStartupErrorAtom, null);
43
+ store.set(filenameAtom, "notebook.py");
44
+ store.set(packageDataVersionAtom, 0);
45
+ });
46
+
47
+ function setup() {
48
+ const client = MockRequestClient.create({
49
+ getSandbox: vi.fn().mockResolvedValue(sandbox),
50
+ updateManifest: vi.fn(async ({ contents }) => ({
51
+ ...sandbox,
52
+ manifest: contents,
53
+ })),
54
+ syncSandbox: vi.fn().mockResolvedValue({ success: true, reconnect: false }),
55
+ });
56
+ store.set(requestClientAtom, client);
57
+ const reconnect = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
58
+ return {
59
+ client,
60
+ reconnect,
61
+ ...renderHook(() => useSandboxController(reconnect), {
62
+ wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
63
+ }),
64
+ };
65
+ }
66
+
67
+ describe("useSandboxController", () => {
68
+ it("keeps a conflicting draft across closing and only discards it on explicit reload", async () => {
69
+ const { result, client } = setup();
70
+ vi.mocked(client.updateManifest).mockRejectedValue(
71
+ new HTTPError(409, "Conflict", { detail: "Manifest changed on disk" }),
72
+ );
73
+ await act(async () => {
74
+ await store.get(sandboxActionsAtom)?.editManifest();
75
+ });
76
+ act(() => result.current.setDraft("dependencies = []"));
77
+ await act(async () => {
78
+ await result.current.saveAndSync();
79
+ });
80
+ expect(client.updateManifest).toHaveBeenCalledExactlyOnceWith({
81
+ fileKey: "notebook.py",
82
+ previous: manifest,
83
+ contents: "dependencies = []",
84
+ });
85
+ expect(result.current).toMatchObject({
86
+ open: true,
87
+ draft: "dependencies = []",
88
+ dirty: true,
89
+ conflict: true,
90
+ diagnostic: "Manifest changed on disk",
91
+ pending: false,
92
+ });
93
+ expect(client.syncSandbox).not.toHaveBeenCalled();
94
+ act(() => result.current.setOpen(false));
95
+ await act(async () => {
96
+ await store.get(sandboxActionsAtom)?.editManifest();
97
+ });
98
+ expect(result.current.draft).toBe("dependencies = []");
99
+ await act(async () => {
100
+ await result.current.loadManifest();
101
+ });
102
+ expect(result.current).toMatchObject({
103
+ draft: manifest,
104
+ dirty: false,
105
+ conflict: false,
106
+ diagnostic: null,
107
+ });
108
+ });
109
+
110
+ it("shares sync progress across callers and refreshes packages without reconnecting", async () => {
111
+ const { client, reconnect } = setup();
112
+ const { promise, resolve } = Promise.withResolvers<SyncSandboxResponse>();
113
+ vi.mocked(client.syncSandbox).mockReturnValue(promise);
114
+ const actions = store.get(sandboxActionsAtom)!;
115
+ let pending: Promise<boolean>;
116
+ act(() => {
117
+ pending = actions.sync();
118
+ });
119
+ expect(store.get(sandboxSyncAtom)).toEqual({ pending: true, error: null });
120
+ await act(async () => {
121
+ expect(await actions.sync()).toBe(false);
122
+ });
123
+ expect(client.syncSandbox).toHaveBeenCalledTimes(1);
124
+ await act(async () => {
125
+ resolve({ success: true, reconnect: false });
126
+ expect(await pending).toBe(true);
127
+ });
128
+ expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
129
+ expect(store.get(packageDataVersionAtom)).toBe(1);
130
+ expect(reconnect).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("reports sync failure and clears it when retrying", async () => {
134
+ const { client } = setup();
135
+ const actions = store.get(sandboxActionsAtom)!;
136
+ vi.mocked(client.syncSandbox).mockRejectedValueOnce(new Error("offline"));
137
+ await act(async () => {
138
+ expect(await actions.sync()).toBe(false);
139
+ });
140
+ expect(store.get(sandboxSyncAtom)).toEqual({
141
+ pending: false,
142
+ error: "offline",
143
+ });
144
+ await act(async () => {
145
+ expect(await actions.sync()).toBe(true);
146
+ });
147
+ expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
148
+ });
149
+
150
+ it("ignores stale metadata after a filename change and unregisters actions on unmount", async () => {
151
+ const { client, unmount } = setup();
152
+ await waitFor(() => expect(store.get(sandboxAtom)).toEqual(sandbox));
153
+ const { promise, resolve } = Promise.withResolvers<SandboxResponse>();
154
+ vi.mocked(client.getSandbox).mockReturnValueOnce(promise);
155
+ act(() => store.set(filenameAtom, "old.py"));
156
+ const current = { ...sandbox, filename: "current.py" };
157
+ vi.mocked(client.getSandbox).mockResolvedValue(current);
158
+ act(() => store.set(filenameAtom, "current.py"));
159
+ await waitFor(() => expect(store.get(sandboxAtom)).toEqual(current));
160
+ await act(async () => resolve({ ...sandbox, filename: "old.py" }));
161
+ expect(store.get(sandboxAtom)).toEqual(current);
162
+ unmount();
163
+ expect(store.get(sandboxActionsAtom)).toBeNull();
164
+ });
165
+ });