@marimo-team/islands 0.24.3-dev36 → 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-CWnP5Rod.js → common-eNTtBSo2.js} +2 -2
  2. package/dist/main.js +6 -2
  3. package/dist/{reveal-component-SdzpGkJs.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.24.3-dev36",
3
+ "version": "0.24.3-dev39",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -139,6 +139,13 @@ export const MockRequestClient = {
139
139
  getPackageList: vi.fn().mockResolvedValue({
140
140
  packages: [],
141
141
  }),
142
+ getSandbox: vi
143
+ .fn()
144
+ .mockResolvedValue({ backend: null, manifest: null, filename: null }),
145
+ updateManifest: vi
146
+ .fn()
147
+ .mockResolvedValue({ backend: null, manifest: null, filename: null }),
148
+ syncSandbox: vi.fn().mockResolvedValue({ success: true }),
142
149
  getDependencyTree: vi.fn().mockResolvedValue({
143
150
  tree: null,
144
151
  context: { kind: "package-manager", name: "pip" },
@@ -1,76 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { useAtomValue } from "jotai";
4
- import { LoadingEllipsis } from "@/components/icons/loading-ellipsis";
5
- import { Spinner } from "@/components/icons/spinner";
6
4
  import { Button } from "@/components/ui/button";
7
- import { DelayMount } from "@/components/utils/delay-mount";
8
- import {
9
- isClosedAtom,
10
- isConnectingAtom,
11
- isNotStartedAtom,
12
- } from "@/core/network/connection";
5
+ import { isNotStartedAtom } from "@/core/network/connection";
13
6
  import { useConnectToRuntime } from "@/core/runtime/config";
14
- import { Banner } from "@/plugins/impl/common/error-banner";
15
- import { Tooltip } from "../../ui/tooltip";
16
7
  import { FloatingAlert } from "./floating-alert";
17
8
 
18
- const SHORT_DELAY_MS = 1000; // 1 second
19
- const LONG_DELAY_MS = 5000; // 5 seconds
20
-
21
- export const ConnectingAlert: React.FC = () => {
22
- const isConnecting = useAtomValue(isConnectingAtom);
23
- const isClosed = useAtomValue(isClosedAtom);
24
-
25
- if (isConnecting) {
26
- const subtleNotification = (
27
- <Tooltip content="Connecting to a marimo runtime">
28
- <div className="flex items-center">
29
- <LoadingEllipsis size={5} className="text-yellow-500" />
30
- </div>
31
- </Tooltip>
32
- );
33
-
34
- const longNotification = (
35
- <Banner
36
- kind="info"
37
- className="flex flex-col rounded py-2 px-4 animate-in slide-in-from-top w-fit"
38
- >
39
- <div className="flex flex-col gap-4 justify-between items-start text-muted-foreground text-base">
40
- <div className="flex items-center gap-2">
41
- <Spinner className="h-4 w-4" />
42
- <p>Connecting to a marimo runtime ...</p>
43
- </div>
44
- </div>
45
- </Banner>
46
- );
47
-
48
- // This waits for 1 second to show the subtle notification, then shows the long notification after 5 seconds.
49
- return (
50
- <DelayMount milliseconds={SHORT_DELAY_MS}>
51
- <div className="m-0 flex items-center min-h-[28px] fixed top-5 left-1/2 transform -translate-x-1/2 z-200">
52
- <DelayMount
53
- milliseconds={LONG_DELAY_MS}
54
- fallback={subtleNotification}
55
- >
56
- {longNotification}
57
- </DelayMount>
58
- </div>
59
- </DelayMount>
60
- );
61
- }
62
-
63
- if (isClosed) {
64
- return (
65
- <FloatingAlert show={isClosed} kind="danger">
66
- <div className="flex items-center gap-2">
67
- <p>Failed to connect.</p>
68
- </div>
69
- </FloatingAlert>
70
- );
71
- }
72
- };
73
-
74
9
  export const NotStartedConnectionAlert: React.FC = () => {
75
10
  const isNotStarted = useAtomValue(isNotStartedAtom);
76
11
  const connectToRuntime = useConnectToRuntime();
@@ -0,0 +1,140 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { useAtomValue } from "jotai";
3
+ import { AlertCircleIcon, ArrowRightIcon } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { Spinner } from "@/components/icons/spinner";
6
+ import { Button } from "@/components/ui/button";
7
+ import { hasCellsAtom } from "@/core/cells/cells";
8
+ import type { AppConfig } from "@/core/config/config-schema";
9
+ import { connectionAtom } from "@/core/network/connection";
10
+ import { connectionNoticeAtom } from "@/core/network/connection-notice";
11
+ import { WebSocketState } from "@/core/websocket/types";
12
+ import { useDelayElapsed } from "@/hooks/useDelayElapsed";
13
+ import { useInterval } from "@/hooks/useInterval";
14
+ import { cn } from "@/utils/cn";
15
+ import { SandboxErrorOutput } from "../chrome/panels/sandbox-panel";
16
+ import { chromeAtom, useChromeActions } from "../chrome/state";
17
+ import { VerticalLayoutWrapper } from "../renderers/vertical-layout/vertical-layout-wrapper";
18
+
19
+ export const ConnectionNotice = ({
20
+ appConfig,
21
+ onRetry,
22
+ }: {
23
+ appConfig: AppConfig;
24
+ onRetry: () => void;
25
+ }) => {
26
+ const notice = useAtomValue(connectionNoticeAtom);
27
+ const connection = useAtomValue(connectionAtom);
28
+ const hasCells = useAtomValue(hasCellsAtom);
29
+ const chrome = useAtomValue(chromeAtom);
30
+ const packagesOpen =
31
+ (chrome.isSidebarOpen && chrome.selectedPanel === "packages") ||
32
+ (chrome.isDeveloperPanelOpen &&
33
+ chrome.selectedDeveloperPanelTab === "packages");
34
+ const elapsed = useDelayElapsed(notice?.pending ? 500 : 0);
35
+ const { openApplication } = useChromeActions();
36
+
37
+ // Reconnection uses the footer indicator while existing cells stay visible.
38
+ if (
39
+ !notice ||
40
+ !elapsed ||
41
+ (hasCells &&
42
+ connection.state === WebSocketState.CONNECTING &&
43
+ connection.phase === "reconnecting")
44
+ ) {
45
+ return null;
46
+ }
47
+
48
+ return (
49
+ <VerticalLayoutWrapper
50
+ appConfig={appConfig}
51
+ className="shrink-0 pb-0 sm:pb-0 print:hidden"
52
+ innerClassName="pb-0 sm:pb-0 pr-4"
53
+ >
54
+ <section
55
+ aria-label="Notebook startup"
56
+ className={cn(
57
+ "text-muted-foreground",
58
+ hasCells
59
+ ? "mt-4 mb-8 border rounded-lg px-5 py-5"
60
+ : "max-w-sm mx-auto mt-16 sm:mt-24 px-4",
61
+ hasCells && !notice.pending && "border-(--red-7)",
62
+ )}
63
+ >
64
+ <div role="status" className="flex items-center gap-2.5">
65
+ {notice.pending ? (
66
+ <Spinner className="size-4 shrink-0" aria-hidden={true} />
67
+ ) : (
68
+ <AlertCircleIcon
69
+ className="size-4 shrink-0 text-(--red-11)"
70
+ aria-hidden={true}
71
+ />
72
+ )}
73
+ <h2
74
+ className={cn(
75
+ "text-base",
76
+ notice.pending
77
+ ? "font-normal text-foreground"
78
+ : "font-medium text-(--red-11)",
79
+ )}
80
+ >
81
+ {notice.title}
82
+ </h2>
83
+ </div>
84
+ <div className="ml-6.5">
85
+ <p
86
+ className={cn(
87
+ "mt-3 text-sm leading-7",
88
+ !notice.pending && "text-foreground",
89
+ )}
90
+ >
91
+ {notice.description}
92
+ </p>
93
+ {!notice.sandbox && notice.error && (
94
+ <SandboxErrorOutput error={notice.error} />
95
+ )}
96
+ <div className="mt-5 flex flex-wrap items-center gap-x-6 gap-y-3">
97
+ {!packagesOpen && (notice.sandbox || notice.pending) && (
98
+ <Button
99
+ variant="text"
100
+ size="xs"
101
+ className="h-auto p-0 text-xs gap-1.5"
102
+ onClick={() => openApplication("packages")}
103
+ >
104
+ {notice.pending ? "View setup details" : "Open Packages"}
105
+ <ArrowRightIcon className="size-3" aria-hidden={true} />
106
+ </Button>
107
+ )}
108
+ {!notice.pending && !notice.sandbox && (
109
+ <Button variant="outline" size="xs" onClick={onRetry}>
110
+ Try again
111
+ </Button>
112
+ )}
113
+ {notice.pending && <StartupElapsedTime />}
114
+ </div>
115
+ </div>
116
+ </section>
117
+ </VerticalLayoutWrapper>
118
+ );
119
+ };
120
+
121
+ function StartupElapsedTime() {
122
+ const [startedAt] = useState(Date.now);
123
+ const [seconds, setSeconds] = useState(0);
124
+ useInterval(() => setSeconds(Math.floor((Date.now() - startedAt) / 1000)), {
125
+ delayMs: 1000,
126
+ whenVisible: true,
127
+ });
128
+ const duration =
129
+ seconds < 60
130
+ ? `${seconds}s`
131
+ : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
132
+ return (
133
+ <span
134
+ className="text-xs text-muted-foreground/70 tabular-nums"
135
+ aria-live="off"
136
+ >
137
+ Elapsed {duration}
138
+ </span>
139
+ );
140
+ }
@@ -4,8 +4,11 @@ import type React from "react";
4
4
  import type { PropsWithChildren } from "react";
5
5
  import type { AppConfig } from "@/core/config/config-schema";
6
6
  import { PyodideLoader } from "@/core/wasm/PyodideLoader";
7
- import { isAppClosed } from "@/core/websocket/connection-utils";
8
- import type { ConnectionStatus } from "@/core/websocket/types";
7
+ import {
8
+ type ConnectionStatus,
9
+ WebSocketClosedReason,
10
+ WebSocketState,
11
+ } from "@/core/websocket/types";
9
12
  import { cn } from "@/utils/cn";
10
13
  import { DynamicFavicon } from "./dynamic-favicon";
11
14
  import { StatusOverlay } from "./header/status";
@@ -44,7 +47,10 @@ export const AppContainer: React.FC<PropsWithChildren<Props>> = ({
44
47
  data-connection-state={connectionState}
45
48
  className={cn(
46
49
  "mathjax_ignore",
47
- isAppClosed(connectionState) && "disconnected",
50
+ connection.state === WebSocketState.CLOSED &&
51
+ connection.code !==
52
+ WebSocketClosedReason.KERNEL_STARTUP_ERROR &&
53
+ "disconnected",
48
54
  "bg-background w-full h-full text-textColor",
49
55
  "flex flex-col overflow-y-auto",
50
56
  width === "full" && "config-width-full",
@@ -1,6 +1,13 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { fireEvent, render, screen } from "@testing-library/react";
3
+ import {
4
+ act,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ within,
10
+ } from "@testing-library/react";
4
11
  import { Provider } from "jotai";
5
12
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
13
  import { MockRequestClient } from "@/__mocks__/requests";
@@ -10,6 +17,9 @@ import type {
10
17
  DependencyTreeNode,
11
18
  DependencyTreeResponse,
12
19
  } from "@/core/network/types";
20
+ import { withPackageInvalidation } from "@/core/packages/package-data";
21
+ import { useInstallPackages } from "@/core/packages/useInstallPackage";
22
+ import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
13
23
  import { store } from "@/core/state/jotai";
14
24
  import PackagesPanel from "../packages-panel";
15
25
 
@@ -51,16 +61,22 @@ function renderPanel(
51
61
  context: DependencyTreeResponse["context"],
52
62
  tree: DependencyTreeNode = emptyTree,
53
63
  ) {
54
- const getPackageList = vi.fn().mockResolvedValue({ packages: [] });
55
64
  store.set(
56
- requestClientAtom,
57
- MockRequestClient.create({
58
- getPackageList,
59
- getDependencyTree: vi.fn().mockResolvedValue({ tree, context }),
60
- }),
65
+ sandboxAtom,
66
+ context.kind === "sandbox"
67
+ ? { backend: context.backend, manifest: "", filename: "notebook.py" }
68
+ : null,
61
69
  );
70
+ store.set(sandboxSyncAtom, { pending: false, error: null });
71
+ const getPackageList = vi.fn().mockResolvedValue({ packages: [] });
72
+ const client = MockRequestClient.create({
73
+ getPackageList,
74
+ getDependencyTree: vi.fn().mockResolvedValue({ tree, context }),
75
+ });
76
+ store.set(requestClientAtom, client);
62
77
 
63
78
  return {
79
+ client,
64
80
  getPackageList,
65
81
  ...render(
66
82
  <Provider store={store}>
@@ -141,3 +157,94 @@ describe("PackagesPanel", () => {
141
157
  expect(getPackageList).toHaveBeenCalledOnce();
142
158
  });
143
159
  });
160
+
161
+ it("refreshes an open panel when a package is installed elsewhere, after installation succeeds", async () => {
162
+ const { promise, resolve } = Promise.withResolvers<{ success: boolean }>();
163
+ const client = MockRequestClient.create({
164
+ addPackage: vi.fn(() => promise),
165
+ getDependencyTree: vi
166
+ .fn()
167
+ .mockResolvedValueOnce({
168
+ tree: emptyTree,
169
+ context: { kind: "sandbox", backend: "uv" },
170
+ })
171
+ .mockResolvedValue({
172
+ tree: populatedTree,
173
+ context: { kind: "sandbox", backend: "uv" },
174
+ }),
175
+ });
176
+ store.set(sandboxAtom, {
177
+ backend: "uv",
178
+ manifest: "",
179
+ filename: "notebook.py",
180
+ });
181
+ store.set(sandboxSyncAtom, { pending: false, error: null });
182
+ store.set(requestClientAtom, withPackageInvalidation(client));
183
+ function InstallElsewhere() {
184
+ const { handleInstallPackages } = useInstallPackages();
185
+ return (
186
+ <button onClick={() => handleInstallPackages(["polars"])}>
187
+ Install from another panel
188
+ </button>
189
+ );
190
+ }
191
+ render(
192
+ <Provider store={store}>
193
+ <TooltipProvider>
194
+ <PackagesPanel />
195
+ <InstallElsewhere />
196
+ </TooltipProvider>
197
+ </Provider>,
198
+ );
199
+ await screen.findByPlaceholderText("Add packages to uv sandbox...");
200
+ fireEvent.click(
201
+ screen.getByRole("button", { name: "Install from another panel" }),
202
+ );
203
+ expect(client.getDependencyTree).toHaveBeenCalledTimes(1);
204
+ await act(async () => resolve({ success: true }));
205
+ await waitFor(() =>
206
+ expect(client.getDependencyTree).toHaveBeenCalledTimes(2),
207
+ );
208
+ expect(await screen.findByText("polars")).toBeInTheDocument();
209
+ });
210
+
211
+ it.each(["uv", "pixi", "list"] as const)(
212
+ "allows upgrading marimo but not removing it in the %s view",
213
+ async (view) => {
214
+ const tree = {
215
+ ...populatedTree,
216
+ dependencies: [
217
+ ...populatedTree.dependencies,
218
+ { name: "marimo", version: "0.24.0", tags: [], dependencies: [] },
219
+ ],
220
+ };
221
+ const { client, getPackageList } = renderPanel(
222
+ view === "list"
223
+ ? { kind: "package-manager", name: "pip" }
224
+ : { kind: "sandbox", backend: view },
225
+ tree,
226
+ );
227
+ getPackageList.mockResolvedValue({ packages: tree.dependencies });
228
+ vi.mocked(client.addPackage).mockResolvedValue({ success: true });
229
+ if (view === "list") {
230
+ fireEvent.click(await screen.findByRole("button", { name: "List" }));
231
+ }
232
+ const role = view === "list" ? "row" : "treeitem";
233
+ const marimo = within(await screen.findByRole(role, { name: /marimo/ }));
234
+ expect(marimo.getByText(/0\.24\.0/)).toBeInTheDocument();
235
+ expect(
236
+ marimo.queryByRole("button", { name: "Remove" }),
237
+ ).not.toBeInTheDocument();
238
+ const polars = within(screen.getByRole(role, { name: /polars/ }));
239
+ expect(polars.getByRole("button", { name: "Remove" })).toBeInTheDocument();
240
+ fireEvent.click(marimo.getByRole("button", { name: "Upgrade" }));
241
+ await waitFor(() =>
242
+ expect(client.addPackage).toHaveBeenCalledWith({
243
+ package: "marimo",
244
+ upgrade: true,
245
+ group: undefined,
246
+ }),
247
+ );
248
+ expect(client.removePackage).not.toHaveBeenCalled();
249
+ },
250
+ );
@@ -0,0 +1,165 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+ import {
4
+ act,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ within,
10
+ } from "@testing-library/react";
11
+ import { Provider } from "jotai";
12
+ import { beforeEach, expect, it, vi } from "vitest";
13
+ import { MockRequestClient } from "@/__mocks__/requests";
14
+ import { AppConfigSchema } from "@/core/config/config-schema";
15
+ import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
16
+ import { TooltipProvider } from "@/components/ui/tooltip";
17
+ import { kernelStartupErrorAtom } from "@/core/errors/state";
18
+ import { connectionAtom } from "@/core/network/connection";
19
+ import { requestClientAtom } from "@/core/network/requests";
20
+ import {
21
+ sandboxActionsAtom,
22
+ sandboxAtom,
23
+ sandboxSyncAtom,
24
+ } from "@/core/packages/sandbox-state";
25
+ import { filenameAtom } from "@/core/saving/file-state";
26
+ import { store } from "@/core/state/jotai";
27
+ import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
28
+ import { chromeAtom } from "../../state";
29
+ import PackagesPanel from "../packages-panel";
30
+ import { SandboxController } from "../sandbox-controller";
31
+
32
+ vi.mock("@/plugins/impl/code/LazyAnyLanguageCodeMirror", () => ({
33
+ LazyAnyLanguageCodeMirror: ({
34
+ value,
35
+ onChange,
36
+ editable,
37
+ }: {
38
+ value: string;
39
+ onChange: (value: string) => void;
40
+ editable: boolean;
41
+ }) => (
42
+ <textarea
43
+ aria-label="Notebook manifest"
44
+ value={value}
45
+ readOnly={!editable}
46
+ onChange={(event) => onChange(event.target.value)}
47
+ />
48
+ ),
49
+ }));
50
+
51
+ const manifest =
52
+ 'dependencies = ["numpy==0.0.0"]\n\n[tool.custom]\nlabel = "experiment"\n';
53
+ const failed = {
54
+ state: WebSocketState.CLOSED,
55
+ code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
56
+ phase: "preparing-environment",
57
+ reason: "Kernel startup failed",
58
+ } as const;
59
+ beforeEach(() => {
60
+ store.set(sandboxAtom, { backend: "uv", manifest, filename: "notebook.py" });
61
+ store.set(sandboxSyncAtom, { pending: false, error: null });
62
+ store.set(sandboxActionsAtom, null);
63
+ store.set(connectionAtom, failed);
64
+ store.set(kernelStartupErrorAtom, "resolver output\n no solution");
65
+ store.set(filenameAtom, "notebook.py");
66
+ store.set(chromeAtom, {
67
+ selectedPanel: "packages",
68
+ isSidebarOpen: false,
69
+ isDeveloperPanelOpen: false,
70
+ selectedDeveloperPanelTab: "errors",
71
+ });
72
+ });
73
+ function mount(
74
+ client: ReturnType<typeof MockRequestClient.create>,
75
+ reconnect = vi.fn(async () => {}),
76
+ ) {
77
+ store.set(requestClientAtom, client);
78
+ return render(
79
+ <Provider store={store}>
80
+ <TooltipProvider>
81
+ <SandboxController onReconnect={reconnect} />
82
+ <ConnectionNotice
83
+ appConfig={AppConfigSchema.parse({})}
84
+ onRetry={reconnect}
85
+ />
86
+ <PackagesPanel />
87
+ </TooltipProvider>
88
+ </Provider>,
89
+ );
90
+ }
91
+ function requests() {
92
+ return MockRequestClient.create({
93
+ getSandbox: vi.fn(async () => ({
94
+ backend: "uv",
95
+ manifest,
96
+ filename: "notebook.py",
97
+ })),
98
+ updateManifest: vi.fn(async ({ contents }) => ({
99
+ backend: "uv",
100
+ manifest: contents,
101
+ filename: "notebook.py",
102
+ })),
103
+ syncSandbox: vi.fn(async () => ({ success: true, reconnect: true })),
104
+ });
105
+ }
106
+
107
+ it("keeps a whole-manifest repair open through failure and closes only after sync succeeds", async () => {
108
+ const client = requests();
109
+ const { promise: saved, resolve: finishSave } = Promise.withResolvers<void>();
110
+ vi.mocked(client.updateManifest).mockImplementation(async ({ contents }) => {
111
+ await saved;
112
+ return { backend: "uv", manifest: contents, filename: "notebook.py" };
113
+ });
114
+ const reconnect = vi.fn(async () => {
115
+ store.set(kernelStartupErrorAtom, null);
116
+ store.set(connectionAtom, {
117
+ state: WebSocketState.CONNECTING,
118
+ phase: "preparing-environment",
119
+ });
120
+ });
121
+ mount(client, reconnect);
122
+ fireEvent.click(await screen.findByRole("button", { name: "Open Packages" }));
123
+ expect(store.get(chromeAtom).isSidebarOpen).toBe(true);
124
+ expect(screen.getByLabelText("Error details")).toHaveTextContent(
125
+ "resolver output",
126
+ );
127
+ fireEvent.click(screen.getByRole("button", { name: "Edit manifest…" }));
128
+ const editor = await screen.findByRole("textbox", {
129
+ name: "Notebook manifest",
130
+ });
131
+ const replacement =
132
+ '# A completely different manifest\n[tool.custom]\nlabel = "updated"\n';
133
+ fireEvent.change(editor, { target: { value: replacement } });
134
+ fireEvent.click(screen.getByRole("button", { name: "Save & sync" }));
135
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
136
+ expect(reconnect).not.toHaveBeenCalled();
137
+ await act(async () => finishSave());
138
+ await waitFor(() => expect(reconnect).toHaveBeenCalledTimes(1));
139
+ expect(client.updateManifest).toHaveBeenCalledWith({
140
+ fileKey: "notebook.py",
141
+ previous: manifest,
142
+ contents: replacement,
143
+ });
144
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
145
+ act(() => {
146
+ store.set(connectionAtom, failed);
147
+ store.set(kernelStartupErrorAtom, "another resolver failure");
148
+ });
149
+ const dialog = within(screen.getByRole("dialog"));
150
+ expect(await dialog.findByLabelText("Error details")).toHaveTextContent(
151
+ "another resolver failure",
152
+ );
153
+ expect(
154
+ dialog.getByRole("textbox", { name: "Notebook manifest" }),
155
+ ).toHaveValue(replacement);
156
+ await waitFor(() =>
157
+ expect(dialog.getByRole("button", { name: "Sync" })).toBeEnabled(),
158
+ );
159
+ fireEvent.click(dialog.getByRole("button", { name: "Sync" }));
160
+ await waitFor(() => expect(reconnect).toHaveBeenCalledTimes(2));
161
+ act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
162
+ await waitFor(() =>
163
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
164
+ );
165
+ });