@marimo-team/islands 0.24.3-dev81 → 0.24.3-dev83

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 (47) hide show
  1. package/dist/{ErrorBoundary-CCi-VkF7.js → ErrorBoundary-Uf_LvjvY.js} +1 -1
  2. package/dist/{cells-BIfOnGBK.js → cells-DYIkSxs6.js} +4 -0
  3. package/dist/{chat-ui-LWkjhObe.js → chat-ui-BXj3uYjy.js} +4 -4
  4. package/dist/{code-visibility-Bsfqbe2z.js → code-visibility-DxgQWu1j.js} +2 -2
  5. package/dist/{common-BnKC1tWB.js → common-ByAtncLr.js} +3 -3
  6. package/dist/{data-grid-overlay-editor-CJnlWykO.js → data-grid-overlay-editor-DL_blFyL.js} +1 -1
  7. package/dist/{error-entries-CR0oClSL.js → error-entries-C0NsUqXl.js} +1 -1
  8. package/dist/{feedback-button-MWISfIIf.js → feedback-button-AZcBqfoV.js} +4 -4
  9. package/dist/{glide-data-editor-dcmP8mKS.js → glide-data-editor-BJdZncJk.js} +3 -3
  10. package/dist/{globals-fCMBgbAD.js → globals-yWarpyN4.js} +1 -1
  11. package/dist/{html-to-image-_i03yR4q.js → html-to-image-CeYYvQNb.js} +1 -1
  12. package/dist/main.js +12 -11
  13. package/dist/{number-overlay-editor-DhNE6pBe.js → number-overlay-editor-BatBVYxr.js} +1 -1
  14. package/dist/{process-output-D3zQyTAS.js → process-output-BCTjlyuH.js} +2 -2
  15. package/dist/{reveal-component-Cdcgngkc.js → reveal-component-D02jcpiJ.js} +3 -3
  16. package/dist/style.css +1 -1
  17. package/package.json +1 -1
  18. package/src/components/editor/alerts/__tests__/connection-notice.test.tsx +186 -0
  19. package/src/components/editor/alerts/__tests__/startup-progress.test.tsx +185 -0
  20. package/src/components/editor/alerts/connection-notice.tsx +100 -54
  21. package/src/components/editor/alerts/startup-output.tsx +173 -0
  22. package/src/components/editor/alerts/startup-progress.tsx +204 -0
  23. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +53 -4
  24. package/src/components/editor/chrome/panels/__tests__/sandbox-details.test.tsx +358 -0
  25. package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +5 -2
  26. package/src/components/editor/chrome/panels/packages-panel.tsx +32 -21
  27. package/src/components/editor/chrome/panels/sandbox-details-state.ts +38 -0
  28. package/src/components/editor/chrome/panels/sandbox-panel.tsx +188 -131
  29. package/src/components/editor/chrome/panels/sandbox-toggle.tsx +52 -0
  30. package/src/components/editor/chrome/wrapper/app-chrome.tsx +7 -0
  31. package/src/components/editor/package-alert.tsx +112 -66
  32. package/src/core/alerts/__tests__/environment.test.ts +183 -0
  33. package/src/core/alerts/__tests__/state.test.tsx +240 -0
  34. package/src/core/alerts/environment.ts +57 -0
  35. package/src/core/alerts/state.ts +155 -99
  36. package/src/core/islands/bootstrap.ts +2 -1
  37. package/src/core/kernel/messages.ts +2 -2
  38. package/src/core/network/connection-notice.ts +45 -12
  39. package/src/core/network/connection.ts +19 -0
  40. package/src/core/network/useConnectionNotice.ts +53 -0
  41. package/src/core/packages/__tests__/useSandboxController.test.tsx +86 -5
  42. package/src/core/packages/sandbox-state.ts +56 -4
  43. package/src/core/packages/useSandboxController.ts +27 -7
  44. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +199 -8
  45. package/src/core/websocket/types.ts +1 -1
  46. package/src/core/websocket/useMarimoKernelConnection.tsx +31 -19
  47. package/src/stories/sandbox.stories.tsx +230 -50
@@ -2,40 +2,69 @@
2
2
  import { atom } from "jotai";
3
3
  import { kernelStartupErrorAtom } from "@/core/errors/state";
4
4
  import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
5
- import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
5
+ import {
6
+ type ConnectionPhase,
7
+ WebSocketClosedReason,
8
+ WebSocketState,
9
+ } from "@/core/websocket/types";
6
10
  import { connectionAtom } from "./connection";
7
11
 
8
- export const connectionNoticeAtom = atom((get) => {
12
+ export interface ConnectionNotice {
13
+ kind: "startup" | "sync" | "connection";
14
+ phase?: ConnectionPhase;
15
+ sandbox: boolean;
16
+ title: string;
17
+ description: string;
18
+ pending: boolean;
19
+ error: string | null;
20
+ }
21
+
22
+ export const connectionNoticeAtom = atom<ConnectionNotice | null>((get) => {
9
23
  const connection = get(connectionAtom);
10
24
  const backend = get(sandboxAtom)?.backend;
11
25
  const sandbox = Boolean(backend);
12
26
  const sync = get(sandboxSyncAtom);
27
+ const syncing = sync.kind === "running";
28
+ const restartRequired = sync.kind === "restart-required";
29
+ const syncError =
30
+ sync.kind === "failed"
31
+ ? sync.error
32
+ : sync.kind === "cancelled"
33
+ ? "Sandbox sync was interrupted."
34
+ : restartRequired
35
+ ? sync.reason
36
+ : null;
13
37
  if (
14
38
  sandbox &&
15
- (sync.pending || sync.error) &&
39
+ (syncing || syncError !== null) &&
16
40
  connection.state === WebSocketState.OPEN
17
41
  ) {
18
42
  return {
43
+ kind: "sync",
19
44
  sandbox,
20
- title: sync.pending ? "Syncing sandbox…" : "Sandbox sync failed",
21
- description: sync.pending
45
+ title: syncing
46
+ ? "Syncing sandbox…"
47
+ : restartRequired
48
+ ? "Sandbox restart required"
49
+ : "Sandbox sync failed",
50
+ description: syncing
22
51
  ? "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,
52
+ : restartRequired
53
+ ? "Restart the kernel to use the updated environment."
54
+ : "Review the error details, then try again.",
55
+ pending: syncing,
56
+ error: syncError,
26
57
  };
27
58
  }
28
59
  if (connection.state === WebSocketState.CONNECTING) {
29
60
  const messages = {
30
61
  "preparing-environment": {
31
- title: backend
32
- ? `Preparing ${backend} sandbox…`
33
- : "Preparing environment…",
62
+ title: "Preparing environment…",
34
63
  description:
35
64
  "Getting this notebook’s Python environment ready. The first start can take a few minutes.",
36
65
  },
37
66
  "starting-kernel": {
38
- title: "Starting notebook…",
67
+ title: "Starting kernel…",
39
68
  description:
40
69
  "The environment is prepared. Waiting for the Python kernel to connect to this notebook.",
41
70
  },
@@ -46,6 +75,8 @@ export const connectionNoticeAtom = atom((get) => {
46
75
  },
47
76
  };
48
77
  return {
78
+ kind: connection.phase === "reconnecting" ? "connection" : "startup",
79
+ phase: connection.phase,
49
80
  sandbox,
50
81
  ...(connection.phase
51
82
  ? messages[connection.phase]
@@ -63,6 +94,8 @@ export const connectionNoticeAtom = atom((get) => {
63
94
  const environmentFailed =
64
95
  startupFailed && connection.phase === "preparing-environment";
65
96
  return {
97
+ kind: startupFailed ? "startup" : "connection",
98
+ phase: connection.phase,
66
99
  sandbox,
67
100
  title: environmentFailed
68
101
  ? "Sandbox setup failed"
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { atom } from "jotai";
3
+ import { atomWithReducer } from "jotai/utils";
4
+ import type { NotificationMessageData } from "../kernel/messages";
3
5
  import { isNotebookPage } from "../mode";
4
6
  import { waitFor } from "../state/jotai";
5
7
  import { type ConnectionStatus, WebSocketState } from "../websocket/types";
@@ -12,6 +14,23 @@ export const connectionAtom = atom<ConnectionStatus>({
12
14
  state: WebSocketState.NOT_STARTED,
13
15
  });
14
16
 
17
+ type StartupProgress = NotificationMessageData<"startup-progress">;
18
+
19
+ export const startupProgressAtom = atomWithReducer<
20
+ Pick<StartupProgress, "phase" | "logs"> | null,
21
+ StartupProgress | null
22
+ >(null, (previous, update: StartupProgress | null) =>
23
+ update === null
24
+ ? null
25
+ : {
26
+ phase: update.phase,
27
+ logs:
28
+ update.log_mode === "append" && previous?.phase === update.phase
29
+ ? previous.logs + update.logs
30
+ : update.logs,
31
+ },
32
+ );
33
+
15
34
  export function waitForConnectionOpen() {
16
35
  return waitFor(connectionAtom, (value) => {
17
36
  return value.state === WebSocketState.OPEN;
@@ -0,0 +1,53 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { useAtomValue } from "jotai";
3
+ import { useEffect, useState } from "react";
4
+ import { useDelayElapsed } from "@/hooks/useDelayElapsed";
5
+ import { isConnectedAtom } from "./connection";
6
+ import {
7
+ type ConnectionNotice,
8
+ connectionNoticeAtom,
9
+ } from "./connection-notice";
10
+
11
+ export type DisplayConnectionNotice = ConnectionNotice & { ready: boolean };
12
+
13
+ /** Briefly acknowledge completion, but only for setup the user actually saw. */
14
+ export function useConnectionNotice(
15
+ delayMs = 500,
16
+ ): DisplayConnectionNotice | null {
17
+ const notice = useAtomValue(connectionNoticeAtom);
18
+ const connected = useAtomValue(isConnectedAtom);
19
+ const elapsed = useDelayElapsed(notice?.pending ? delayMs : 0);
20
+ const [previous, setPrevious] = useState<ConnectionNotice | null>(null);
21
+
22
+ useEffect(() => {
23
+ if (notice || !connected) {
24
+ setPrevious(elapsed ? notice : null);
25
+ return;
26
+ }
27
+ const timeout = setTimeout(() => setPrevious(null), 1500);
28
+ return () => clearTimeout(timeout);
29
+ }, [notice, connected, elapsed]);
30
+
31
+ if (notice) {
32
+ return elapsed ? { ...notice, ready: false } : null;
33
+ }
34
+ if (
35
+ connected &&
36
+ previous?.pending &&
37
+ previous.sandbox &&
38
+ previous.kind !== "connection"
39
+ ) {
40
+ return {
41
+ ...previous,
42
+ pending: false,
43
+ ready: true,
44
+ title:
45
+ previous.kind === "sync"
46
+ ? "Environment synced"
47
+ : "Your notebook is ready",
48
+ description: "The notebook is ready to run.",
49
+ error: null,
50
+ };
51
+ }
52
+ return null;
53
+ }
@@ -3,6 +3,11 @@ import { act, renderHook, waitFor } from "@testing-library/react";
3
3
  import { Provider } from "jotai";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { MockRequestClient } from "@/__mocks__/requests";
6
+ import {
7
+ emptyEnvironmentState,
8
+ type EnvironmentOperation,
9
+ } from "@/core/alerts/environment";
10
+ import { alertAtom } from "@/core/alerts/state";
6
11
  import { kernelStartupErrorAtom } from "@/core/errors/state";
7
12
  import { connectionAtom } from "@/core/network/connection";
8
13
  import { requestClientAtom } from "@/core/network/requests";
@@ -25,6 +30,8 @@ import { useSandboxController } from "../useSandboxController";
25
30
  vi.mock("../toast-components", () => ({
26
31
  showPackageRestartToast: vi.fn(),
27
32
  showSandboxSyncToast: vi.fn(),
33
+ RESTART_REQUIRED_DESCRIPTION:
34
+ "Restart the kernel to use the updated environment.",
28
35
  }));
29
36
 
30
37
  const manifest = 'dependencies = ["numpy"]';
@@ -35,8 +42,15 @@ const sandbox: SandboxResponse = {
35
42
  };
36
43
 
37
44
  beforeEach(() => {
45
+ store.set(alertAtom, (value) => ({
46
+ ...value,
47
+ environments: {
48
+ kernel: emptyEnvironmentState(),
49
+ server: emptyEnvironmentState(),
50
+ },
51
+ }));
38
52
  store.set(sandboxAtom, null);
39
- store.set(sandboxSyncAtom, { pending: false, error: null });
53
+ store.set(sandboxSyncAtom, { kind: "succeeded" });
40
54
  store.set(sandboxActionsAtom, null);
41
55
  store.set(connectionAtom, { state: WebSocketState.OPEN });
42
56
  store.set(kernelStartupErrorAtom, null);
@@ -65,6 +79,73 @@ function setup() {
65
79
  }
66
80
 
67
81
  describe("useSandboxController", () => {
82
+ it("preserves a restart-required response instead of reporting a failed sync", async () => {
83
+ const { client, result } = setup();
84
+ vi.mocked(client.syncSandbox).mockResolvedValue({
85
+ success: false,
86
+ restartRequired: true,
87
+ error: "Python version changed",
88
+ });
89
+ await act(async () => {
90
+ expect(await store.get(sandboxActionsAtom)?.sync()).toBe(false);
91
+ });
92
+ expect(store.get(sandboxSyncAtom)).toEqual({
93
+ kind: "restart-required",
94
+ reason: "Python version changed",
95
+ });
96
+ expect(result.current.pending).toBe(false);
97
+ expect(result.current.diagnostic).toBe("Python version changed");
98
+ });
99
+
100
+ it("restores sync progress and lets a retry replace the restored failure", async () => {
101
+ const operation: EnvironmentOperation = {
102
+ operation_id: "sync",
103
+ action: "sync",
104
+ source: "kernel",
105
+ status: { kind: "running" },
106
+ packages: {},
107
+ logs: {},
108
+ };
109
+ const restore = (status: EnvironmentOperation["status"]) =>
110
+ store.set(alertAtom, (value) => ({
111
+ ...value,
112
+ environments: {
113
+ ...value.environments,
114
+ kernel: {
115
+ restart_required: false,
116
+ operations: [{ ...operation, status }],
117
+ },
118
+ },
119
+ }));
120
+ restore({ kind: "running" });
121
+ const { client, result } = setup();
122
+ const actions = store.get(sandboxActionsAtom)!;
123
+ expect(result.current.pending).toBe(true);
124
+ await act(async () => {
125
+ expect(await actions.sync()).toBe(false);
126
+ });
127
+ expect(client.syncSandbox).not.toHaveBeenCalled();
128
+ act(() => restore({ kind: "failed", error: "Dependency conflict" }));
129
+ expect(result.current.diagnostic).toBe("Dependency conflict");
130
+
131
+ const { promise, resolve } = Promise.withResolvers<SyncSandboxResponse>();
132
+ vi.mocked(client.syncSandbox).mockReturnValue(promise);
133
+ let pending: Promise<boolean>;
134
+ act(() => {
135
+ pending = actions.sync();
136
+ });
137
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "running" });
138
+ act(() => restore({ kind: "running" }));
139
+ await act(async () => {
140
+ resolve({ success: true, reconnect: false });
141
+ expect(await pending).toBe(true);
142
+ });
143
+ // The response can arrive before the final websocket notification.
144
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "running" });
145
+ act(() => restore({ kind: "succeeded" }));
146
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "succeeded" });
147
+ });
148
+
68
149
  it("keeps a conflicting draft across closing and only discards it on explicit reload", async () => {
69
150
  const { result, client } = setup();
70
151
  vi.mocked(client.updateManifest).mockRejectedValue(
@@ -116,7 +197,7 @@ describe("useSandboxController", () => {
116
197
  act(() => {
117
198
  pending = actions.sync();
118
199
  });
119
- expect(store.get(sandboxSyncAtom)).toEqual({ pending: true, error: null });
200
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "running" });
120
201
  await act(async () => {
121
202
  expect(await actions.sync()).toBe(false);
122
203
  });
@@ -125,7 +206,7 @@ describe("useSandboxController", () => {
125
206
  resolve({ success: true, reconnect: false });
126
207
  expect(await pending).toBe(true);
127
208
  });
128
- expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
209
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "succeeded" });
129
210
  expect(store.get(packageDataVersionAtom)).toBe(1);
130
211
  expect(reconnect).not.toHaveBeenCalled();
131
212
  });
@@ -138,13 +219,13 @@ describe("useSandboxController", () => {
138
219
  expect(await actions.sync()).toBe(false);
139
220
  });
140
221
  expect(store.get(sandboxSyncAtom)).toEqual({
141
- pending: false,
222
+ kind: "failed",
142
223
  error: "offline",
143
224
  });
144
225
  await act(async () => {
145
226
  expect(await actions.sync()).toBe(true);
146
227
  });
147
- expect(store.get(sandboxSyncAtom)).toEqual({ pending: false, error: null });
228
+ expect(store.get(sandboxSyncAtom)).toEqual({ kind: "succeeded" });
148
229
  });
149
230
 
150
231
  it("ignores stale metadata after a filename change and unregisters actions on unmount", async () => {
@@ -1,12 +1,64 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
  import { atom } from "jotai";
3
+ import type { EnvironmentOperation } from "@/core/alerts/environment";
4
+ import { alertAtom } from "@/core/alerts/state";
3
5
  import type { SandboxResponse } from "@/core/network/types";
4
6
 
5
7
  export const sandboxAtom = atom<SandboxResponse | null>(null);
6
- export const sandboxSyncAtom = atom<{ pending: boolean; error: string | null }>(
7
- {
8
- pending: false,
9
- error: null,
8
+
9
+ export const preparationAtom = atom((get) =>
10
+ get(alertAtom).environments.kernel.operations.findLast(
11
+ (operation) => operation.action === "prepare",
12
+ ),
13
+ );
14
+
15
+ type SandboxSyncState = EnvironmentOperation["status"] | { kind: "idle" };
16
+
17
+ export const sandboxSyncOperationAtom = atom((get) => {
18
+ const operations = get(alertAtom).environments.kernel.operations;
19
+ return (
20
+ operations.findLast(
21
+ (operation) =>
22
+ operation.action === "sync" && operation.status.kind === "running",
23
+ ) ?? operations.findLast((operation) => operation.action === "sync")
24
+ );
25
+ });
26
+
27
+ const sandboxSyncRequestAtom = atom<{
28
+ state: SandboxSyncState;
29
+ operation: EnvironmentOperation | undefined;
30
+ } | null>(null);
31
+
32
+ export const sandboxSyncAtom = atom(
33
+ (get): SandboxSyncState => {
34
+ const operation = get(sandboxSyncOperationAtom);
35
+ const request = get(sandboxSyncRequestAtom);
36
+ // Local request status lasts until newer progress arrives from the server.
37
+ const state: SandboxSyncState =
38
+ request &&
39
+ request.operation === operation &&
40
+ operation?.status.kind !== "running"
41
+ ? request.state
42
+ : (operation?.status ?? { kind: "idle" });
43
+ // Later package operations can replace the sync result, but only a new
44
+ // environment snapshot can clear an outstanding restart.
45
+ if (
46
+ get(alertAtom).environments.kernel.restart_required &&
47
+ (state.kind === "idle" || state.kind === "succeeded")
48
+ ) {
49
+ return {
50
+ kind: "restart-required",
51
+ reason:
52
+ "Dependency changes are saved; restart the kernel to apply them.",
53
+ };
54
+ }
55
+ return state;
56
+ },
57
+ (get, set, state: SandboxSyncState) => {
58
+ set(sandboxSyncRequestAtom, {
59
+ state,
60
+ operation: get(sandboxSyncOperationAtom),
61
+ });
10
62
  },
11
63
  );
12
64
  export const sandboxActionsAtom = atom<{
@@ -17,6 +17,7 @@ import {
17
17
  sandboxSyncAtom,
18
18
  } from "./sandbox-state";
19
19
  import {
20
+ RESTART_REQUIRED_DESCRIPTION,
20
21
  showPackageRestartToast,
21
22
  showSandboxSyncToast,
22
23
  } from "./toast-components";
@@ -63,15 +64,24 @@ export function useSandboxController(onReconnect: () => Promise<void>) {
63
64
  }, [requests, filename, connection.state, setSandbox]);
64
65
 
65
66
  const sync = useEvent(async () => {
66
- if (inFlight.current || connection.state === WebSocketState.CONNECTING) {
67
+ if (
68
+ inFlight.current ||
69
+ operation.kind === "running" ||
70
+ connection.state === WebSocketState.CONNECTING
71
+ ) {
67
72
  return false;
68
73
  }
69
74
  inFlight.current = true;
70
- setOperation((value) => ({ ...value, pending: true, error: null }));
75
+ setOperation({ kind: "running" });
71
76
  try {
72
77
  const result = await requests.syncSandbox({ fileKey: filename });
73
78
  if (result.restartRequired) {
79
+ setOperation({
80
+ kind: "restart-required",
81
+ reason: result.error ?? RESTART_REQUIRED_DESCRIPTION,
82
+ });
74
83
  showPackageRestartToast();
84
+ return false;
75
85
  }
76
86
  if (!result.success) {
77
87
  throw new Error(result.error ?? "Sandbox sync failed.");
@@ -85,6 +95,7 @@ export function useSandboxController(onReconnect: () => Promise<void>) {
85
95
  value.state === WebSocketState.CLOSED,
86
96
  );
87
97
  if (status.state === WebSocketState.CLOSED) {
98
+ setOperation({ kind: "idle" });
88
99
  return false;
89
100
  }
90
101
  }
@@ -92,13 +103,13 @@ export function useSandboxController(onReconnect: () => Promise<void>) {
92
103
  invalidatePackageData();
93
104
  }
94
105
  showSandboxSyncToast();
106
+ setOperation({ kind: "succeeded" });
95
107
  return true;
96
108
  } catch (cause) {
97
- setOperation((value) => ({ ...value, error: prettyError(cause) }));
109
+ setOperation({ kind: "failed", error: prettyError(cause) });
98
110
  return false;
99
111
  } finally {
100
112
  inFlight.current = false;
101
- setOperation((value) => ({ ...value, pending: false }));
102
113
  }
103
114
  });
104
115
 
@@ -135,7 +146,7 @@ export function useSandboxController(onReconnect: () => Promise<void>) {
135
146
  }, [sync, editManifest, setActions]);
136
147
 
137
148
  const saveAndSync = async () => {
138
- if (!document || loading || saving || operation.pending) {
149
+ if (!document || loading || saving || operation.kind === "running") {
139
150
  return;
140
151
  }
141
152
  setSaving(true);
@@ -165,9 +176,18 @@ export function useSandboxController(onReconnect: () => Promise<void>) {
165
176
  const pending =
166
177
  saving ||
167
178
  loading ||
168
- operation.pending ||
179
+ operation.kind === "running" ||
169
180
  connection.state === WebSocketState.CONNECTING;
170
- const diagnostic = error ?? operation.error ?? startupError;
181
+ const diagnostic =
182
+ error ??
183
+ (operation.kind === "failed"
184
+ ? operation.error
185
+ : operation.kind === "restart-required"
186
+ ? operation.reason
187
+ : operation.kind === "cancelled"
188
+ ? "Sandbox sync was interrupted."
189
+ : null) ??
190
+ startupError;
171
191
  return {
172
192
  filename,
173
193
  open,