@marimo-team/islands 0.24.3-dev82 → 0.24.3-dev87

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 (52) hide show
  1. package/dist/{ErrorBoundary-CegtSjfd.js → ErrorBoundary-Bc52FkKN.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-Blq7oGrC.js → code-visibility-CI3ySb6F.js} +2 -2
  5. package/dist/{common-BQZDWy8O.js → common-BOJoCp6P.js} +3 -3
  6. package/dist/{data-grid-overlay-editor-DsU0Zpgt.js → data-grid-overlay-editor-BsT4_auq.js} +1 -1
  7. package/dist/{error-entries-CR0oClSL.js → error-entries-C0NsUqXl.js} +1 -1
  8. package/dist/{feedback-button-pGaDcCJS.js → feedback-button-B7KS_3RP.js} +4 -4
  9. package/dist/{glide-data-editor-DtJQCRsf.js → glide-data-editor-DtckYC_c.js} +3 -3
  10. package/dist/{globals-DBAC4Ihd.js → globals-CruHzAUQ.js} +1 -1
  11. package/dist/{html-to-image-_i03yR4q.js → html-to-image-CeYYvQNb.js} +1 -1
  12. package/dist/main.js +25 -14
  13. package/dist/{number-overlay-editor-9M7FnzxL.js → number-overlay-editor-CV1RN5Cg.js} +1 -1
  14. package/dist/{process-output-D3zQyTAS.js → process-output-BCTjlyuH.js} +2 -2
  15. package/dist/{reveal-component-ovaRyk0R.js → reveal-component-C47L1uNq.js} +3 -3
  16. package/dist/style.css +1 -1
  17. package/package.json +1 -1
  18. package/src/__tests__/fixtures/pair-preview.ts +1 -1
  19. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +6 -6
  20. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +1 -1
  21. package/src/components/editor/alerts/__tests__/connection-notice.test.tsx +186 -0
  22. package/src/components/editor/alerts/__tests__/startup-progress.test.tsx +185 -0
  23. package/src/components/editor/alerts/connection-notice.tsx +100 -54
  24. package/src/components/editor/alerts/startup-output.tsx +173 -0
  25. package/src/components/editor/alerts/startup-progress.tsx +204 -0
  26. package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +53 -4
  27. package/src/components/editor/chrome/panels/__tests__/sandbox-details.test.tsx +358 -0
  28. package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +5 -2
  29. package/src/components/editor/chrome/panels/packages-panel.tsx +32 -21
  30. package/src/components/editor/chrome/panels/sandbox-details-state.ts +38 -0
  31. package/src/components/editor/chrome/panels/sandbox-panel.tsx +188 -131
  32. package/src/components/editor/chrome/panels/sandbox-toggle.tsx +52 -0
  33. package/src/components/editor/chrome/wrapper/app-chrome.tsx +7 -0
  34. package/src/components/editor/package-alert.tsx +112 -66
  35. package/src/core/alerts/__tests__/environment.test.ts +183 -0
  36. package/src/core/alerts/__tests__/state.test.tsx +240 -0
  37. package/src/core/alerts/environment.ts +57 -0
  38. package/src/core/alerts/state.ts +155 -99
  39. package/src/core/islands/bootstrap.ts +2 -1
  40. package/src/core/kernel/messages.ts +2 -2
  41. package/src/core/network/connection-notice.ts +45 -12
  42. package/src/core/network/connection.ts +19 -0
  43. package/src/core/network/useConnectionNotice.ts +53 -0
  44. package/src/core/packages/__tests__/useSandboxController.test.tsx +86 -5
  45. package/src/core/packages/sandbox-state.ts +56 -4
  46. package/src/core/packages/useSandboxController.ts +27 -7
  47. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +199 -8
  48. package/src/core/websocket/types.ts +1 -1
  49. package/src/core/websocket/useMarimoKernelConnection.tsx +31 -19
  50. package/src/plugins/impl/FileBrowserPlugin.tsx +10 -4
  51. package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +50 -1
  52. package/src/stories/sandbox.stories.tsx +230 -50
@@ -0,0 +1,358 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
3
+ import { createStore, Provider } from "jotai";
4
+ import { expect, it, vi } from "vitest";
5
+ import { MockRequestClient } from "@/__mocks__/requests";
6
+ import { ModalProvider } from "@/components/modal/ImperativeModal";
7
+ import { TooltipProvider } from "@/components/ui/tooltip";
8
+ import { alertAtom } from "@/core/alerts/state";
9
+ import type { EnvironmentOperation } from "@/core/alerts/environment";
10
+ import { kernelStartupErrorAtom } from "@/core/errors/state";
11
+ import { connectionAtom, startupProgressAtom } from "@/core/network/connection";
12
+ import { requestClientAtom } from "@/core/network/requests";
13
+ import { sandboxAtom } from "@/core/packages/sandbox-state";
14
+ import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
15
+ import PackagesPanel from "../packages-panel";
16
+ import { PanelSectionProvider } from "../panel-context";
17
+ import { SandboxToggle } from "../sandbox-toggle";
18
+
19
+ vi.mock("@/utils/reload-safe", () => ({ reloadSafe: vi.fn() }));
20
+
21
+ const preparation: EnvironmentOperation = {
22
+ operation_id: "prepare-1",
23
+ action: "prepare",
24
+ source: "kernel",
25
+ status: { kind: "succeeded" },
26
+ packages: {},
27
+ logs: {
28
+ environment:
29
+ "Using Python\nResolved dependencies\nDownloaded numpy\nInstalled numpy\n",
30
+ },
31
+ };
32
+
33
+ function mount({
34
+ preparing = false,
35
+ backend = "uv",
36
+ }: { preparing?: boolean; backend?: "uv" | "pixi" | null } = {}) {
37
+ const store = createStore();
38
+ store.set(sandboxAtom, {
39
+ backend,
40
+ manifest: "dependencies = []",
41
+ filename: "notebook.py",
42
+ });
43
+ store.set(
44
+ connectionAtom,
45
+ preparing
46
+ ? { state: WebSocketState.CONNECTING, phase: "preparing-environment" }
47
+ : { state: WebSocketState.OPEN },
48
+ );
49
+ store.set(alertAtom, (state) => ({
50
+ ...state,
51
+ environments: {
52
+ ...state.environments,
53
+ kernel: {
54
+ restart_required: false,
55
+ operations: backend ? [preparation] : [],
56
+ },
57
+ },
58
+ }));
59
+ if (backend) {
60
+ store.set(startupProgressAtom, {
61
+ phase: preparing ? "preparing-environment" : "starting-kernel",
62
+ logs: preparing
63
+ ? ""
64
+ : "Loading runtime\nLaunching kernel\nKernel connected\n",
65
+ log_mode: "replace",
66
+ });
67
+ }
68
+ const client = MockRequestClient.create({
69
+ sendRestart: vi.fn(async () => null),
70
+ getDependencyTree: vi.fn(async () => ({
71
+ context: backend
72
+ ? { kind: "sandbox" as const, backend }
73
+ : { kind: "package-manager" as const, name: "pip" },
74
+ tree: {
75
+ name: "<root>",
76
+ version: null,
77
+ tags: [],
78
+ dependencies: [
79
+ { name: "numpy", version: "2.0.0", tags: [], dependencies: [] },
80
+ ],
81
+ },
82
+ })),
83
+ getPackageList: vi.fn(async () => ({
84
+ packages: [{ name: "numpy", version: "2.0.0" }],
85
+ })),
86
+ });
87
+ store.set(requestClientAtom, client);
88
+ const { unmount } = render(
89
+ <Provider store={store}>
90
+ <TooltipProvider>
91
+ <ModalProvider>
92
+ <SandboxToggle section="sidebar" />
93
+ <PanelSectionProvider value="sidebar">
94
+ <PackagesPanel />
95
+ </PanelSectionProvider>
96
+ </ModalProvider>
97
+ </TooltipProvider>
98
+ </Provider>,
99
+ );
100
+ return { store, client, unmount };
101
+ }
102
+
103
+ it("opens restored preparation and kernel logs without reopening completed startup on refresh", async () => {
104
+ mount();
105
+ await screen.findByText("numpy");
106
+ const toggle = screen.getByRole("button", { name: "uv sandbox" });
107
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
108
+ expect(
109
+ screen.queryByRole("region", { name: "Sandbox details" }),
110
+ ).not.toBeInTheDocument();
111
+ fireEvent.click(toggle);
112
+ const details = within(
113
+ screen.getByRole("region", { name: "Sandbox details" }),
114
+ );
115
+ expect(details.getByText("Environment prepared")).toBeVisible();
116
+ expect(details.getByText("Kernel started")).toBeVisible();
117
+ fireEvent.click(
118
+ details.getByRole("button", {
119
+ name: "Expand environment preparation output",
120
+ }),
121
+ );
122
+ fireEvent.click(
123
+ details.getByRole("button", { name: "Expand kernel startup output" }),
124
+ );
125
+ expect(
126
+ details.getByLabelText("environment preparation output").textContent,
127
+ ).toBe(preparation.logs.environment);
128
+ expect(details.getByLabelText("kernel startup output")).toHaveTextContent(
129
+ "Loading runtime",
130
+ );
131
+ fireEvent.click(toggle);
132
+ fireEvent.click(toggle);
133
+ expect(
134
+ details.getByLabelText("environment preparation output"),
135
+ ).toBeVisible();
136
+ expect(details.getByLabelText("kernel startup output")).toBeVisible();
137
+ });
138
+
139
+ it("restores a restart-required sync with the kernel restart action", async () => {
140
+ const { store, client } = mount();
141
+ act(() =>
142
+ store.set(alertAtom, (state) => ({
143
+ ...state,
144
+ environments: {
145
+ ...state.environments,
146
+ kernel: {
147
+ restart_required: true,
148
+ operations: [
149
+ preparation,
150
+ {
151
+ operation_id: "sync-python",
152
+ action: "sync",
153
+ source: "kernel",
154
+ status: {
155
+ kind: "restart-required",
156
+ reason: "Python version changed",
157
+ },
158
+ packages: {},
159
+ logs: { environment: "Resolved Python 3.14\n" },
160
+ },
161
+ ],
162
+ },
163
+ },
164
+ })),
165
+ );
166
+ await screen.findByText("numpy");
167
+ expect(screen.getByText("Sandbox restart required")).toBeVisible();
168
+ expect(
169
+ screen.queryByRole("button", { name: "Retry sync" }),
170
+ ).not.toBeInTheDocument();
171
+ expect(screen.getByLabelText("Error details")).toHaveTextContent(
172
+ "Python version changed",
173
+ );
174
+ // A later package operation can displace the sync, but not its restart.
175
+ act(() =>
176
+ store.set(alertAtom, (state) => ({
177
+ ...state,
178
+ environments: {
179
+ ...state.environments,
180
+ kernel: {
181
+ restart_required: true,
182
+ operations: [
183
+ preparation,
184
+ {
185
+ operation_id: "install-1",
186
+ action: "install",
187
+ source: "kernel",
188
+ status: { kind: "succeeded" },
189
+ packages: { numpy: "succeeded" },
190
+ logs: {},
191
+ },
192
+ ],
193
+ },
194
+ },
195
+ })),
196
+ );
197
+ expect(screen.getByText("Sandbox restart required")).toBeVisible();
198
+ fireEvent.click(screen.getByRole("button", { name: "Restart Kernel" }));
199
+ expect(screen.getByRole("alertdialog")).toHaveTextContent("Restart Kernel");
200
+ expect(client.sendRestart).not.toHaveBeenCalled();
201
+ fireEvent.click(screen.getByRole("button", { name: "Confirm Restart" }));
202
+ expect(client.sendRestart).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("keeps sync output open through completion and restores it quietly after refresh", () => {
206
+ const { store, unmount } = mount();
207
+ const restore = (status: EnvironmentOperation["status"], logs: string) =>
208
+ store.set(alertAtom, (state) => ({
209
+ ...state,
210
+ environments: {
211
+ ...state.environments,
212
+ kernel: {
213
+ restart_required: false,
214
+ operations: [
215
+ preparation,
216
+ {
217
+ operation_id: "sync-1",
218
+ action: "sync",
219
+ source: "kernel",
220
+ status,
221
+ packages: {},
222
+ logs: { environment: logs },
223
+ },
224
+ ],
225
+ },
226
+ },
227
+ }));
228
+ act(() => restore({ kind: "running" }, "Resolving dependencies\n"));
229
+ const preview = screen.getByRole("button", {
230
+ name: "Expand sandbox sync output",
231
+ });
232
+ fireEvent.pointerDown(preview);
233
+ fireEvent.click(preview);
234
+ const output = screen.getByLabelText("sandbox sync output");
235
+ act(() =>
236
+ restore({ kind: "running" }, "Resolving dependencies\nDownloading numpy\n"),
237
+ );
238
+ expect(output).toHaveTextContent("Downloading numpy");
239
+ act(() =>
240
+ restore(
241
+ { kind: "succeeded" },
242
+ "Resolving dependencies\nDownloading numpy\nDone\n",
243
+ ),
244
+ );
245
+ expect(output).toBeVisible();
246
+ expect(output).toHaveTextContent("Done");
247
+ expect(screen.getByText("Environment prepared")).toBeVisible();
248
+ expect(screen.getByText("Kernel started")).toBeVisible();
249
+
250
+ const snapshot = store.get(alertAtom);
251
+ unmount();
252
+ const refreshed = mount();
253
+ act(() => refreshed.store.set(alertAtom, snapshot));
254
+ const toggle = screen.getByRole("button", { name: "uv sandbox" });
255
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
256
+ fireEvent.click(toggle);
257
+ fireEvent.click(
258
+ screen.getByRole("button", { name: "Expand sandbox sync output" }),
259
+ );
260
+ expect(screen.getByLabelText("sandbox sync output").textContent).toBe(
261
+ "Resolving dependencies\nDownloading numpy\nDone\n",
262
+ );
263
+ });
264
+
265
+ it.each([false, true])(
266
+ "retains the progression after completion with inspecting=%s",
267
+ async (inspecting) => {
268
+ const { store, client } = mount({ preparing: true });
269
+ const toggle = screen.getByRole("button", { name: "uv sandbox" });
270
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
271
+ expect(client.getDependencyTree).not.toHaveBeenCalled();
272
+ if (inspecting) {
273
+ const preview = screen.getByRole("button", {
274
+ name: "Expand environment preparation output",
275
+ });
276
+ fireEvent.pointerDown(preview);
277
+ fireEvent.click(preview);
278
+ }
279
+ act(() => {
280
+ store.set(startupProgressAtom, {
281
+ phase: "starting-kernel",
282
+ logs: "Launching kernel\n",
283
+ log_mode: "replace",
284
+ });
285
+ store.set(connectionAtom, {
286
+ state: WebSocketState.CONNECTING,
287
+ phase: "starting-kernel",
288
+ });
289
+ });
290
+ act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
291
+ await screen.findByText("numpy");
292
+ expect(toggle).toHaveAttribute("aria-expanded", String(inspecting));
293
+ if (!inspecting) {
294
+ fireEvent.click(toggle);
295
+ }
296
+ expect(screen.getByText("Environment prepared")).toBeVisible();
297
+ expect(screen.getByText("Kernel started")).toBeVisible();
298
+ expect(
299
+ screen.getByRole("button", {
300
+ name: `${inspecting ? "Collapse" : "Expand"} environment preparation output`,
301
+ }),
302
+ ).toBeVisible();
303
+ expect(
304
+ screen.getByRole("button", { name: "Expand kernel startup output" }),
305
+ ).toHaveTextContent("Launching kernel");
306
+ },
307
+ );
308
+
309
+ it("opens a startup error even after collapsing preparation and keeps both step outputs available", () => {
310
+ const { store } = mount({ preparing: true, backend: "pixi" });
311
+ const toggle = screen.getByRole("button", { name: "pixi sandbox" });
312
+ fireEvent.click(toggle);
313
+ act(() => {
314
+ store.set(startupProgressAtom, {
315
+ phase: "starting-kernel",
316
+ logs: "Loading runtime\nImport failed\n",
317
+ log_mode: "replace",
318
+ });
319
+ store.set(kernelStartupErrorAtom, "Could not import the runtime");
320
+ store.set(connectionAtom, {
321
+ state: WebSocketState.CLOSED,
322
+ phase: "starting-kernel",
323
+ code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
324
+ reason: "Kernel startup failed",
325
+ });
326
+ });
327
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
328
+ expect(within(toggle).getByRole("status")).toHaveAccessibleName(
329
+ "Kernel failed to start",
330
+ );
331
+ expect(screen.getByLabelText("Error details")).toHaveTextContent(
332
+ "Could not import the runtime",
333
+ );
334
+ expect(screen.getByText("Environment prepared")).toBeVisible();
335
+ expect(
336
+ screen.getByRole("button", {
337
+ name: "Expand environment preparation output",
338
+ }),
339
+ ).toHaveTextContent("Installed numpy");
340
+ expect(
341
+ screen.getByRole("button", { name: "Expand kernel startup output" }),
342
+ ).toHaveTextContent("Import failed");
343
+ fireEvent.click(toggle);
344
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
345
+ fireEvent.click(toggle);
346
+ expect(screen.getByLabelText("Error details")).toBeVisible();
347
+ });
348
+
349
+ it("does not add a toggle or startup details to an existing environment", async () => {
350
+ mount({ backend: null });
351
+ await screen.findByText("numpy");
352
+ expect(
353
+ screen.queryByRole("button", { name: /sandbox/ }),
354
+ ).not.toBeInTheDocument();
355
+ expect(
356
+ screen.queryByRole("region", { name: "Sandbox details" }),
357
+ ).not.toBeInTheDocument();
358
+ });
@@ -27,6 +27,7 @@ import { store } from "@/core/state/jotai";
27
27
  import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
28
28
  import { chromeAtom } from "../../state";
29
29
  import PackagesPanel from "../packages-panel";
30
+ import { PanelSectionProvider } from "../panel-context";
30
31
  import { SandboxController } from "../sandbox-controller";
31
32
 
32
33
  vi.mock("@/plugins/impl/code/LazyAnyLanguageCodeMirror", () => ({
@@ -58,7 +59,7 @@ const failed = {
58
59
  } as const;
59
60
  beforeEach(() => {
60
61
  store.set(sandboxAtom, { backend: "uv", manifest, filename: "notebook.py" });
61
- store.set(sandboxSyncAtom, { pending: false, error: null });
62
+ store.set(sandboxSyncAtom, { kind: "succeeded" });
62
63
  store.set(sandboxActionsAtom, null);
63
64
  store.set(connectionAtom, failed);
64
65
  store.set(kernelStartupErrorAtom, "resolver output\n no solution");
@@ -83,7 +84,9 @@ function mount(
83
84
  appConfig={AppConfigSchema.parse({})}
84
85
  onRetry={reconnect}
85
86
  />
86
- <PackagesPanel />
87
+ <PanelSectionProvider value="sidebar">
88
+ <PackagesPanel />
89
+ </PanelSectionProvider>
87
90
  </TooltipProvider>
88
91
  </Provider>,
89
92
  );
@@ -7,7 +7,7 @@ import {
7
7
  HelpCircleIcon,
8
8
  } from "lucide-react";
9
9
  import React from "react";
10
- import { connectionNoticeAtom } from "@/core/network/connection-notice";
10
+ import { useConnectionNotice } from "@/core/network/useConnectionNotice";
11
11
  import { useOpenSettingsToTab } from "@/components/app-config/state";
12
12
  import { Spinner } from "@/components/icons/spinner";
13
13
  import { SearchInput } from "@/components/ui/input";
@@ -36,7 +36,7 @@ import { ErrorBanner } from "@/plugins/impl/common/error-banner";
36
36
  import { cn } from "@/utils/cn";
37
37
  import { copyToClipboard } from "@/utils/copy";
38
38
  import { Events } from "@/utils/events";
39
- import { SandboxFooter, SandboxStartupPanel } from "./sandbox-panel";
39
+ import { SandboxDetails } from "./sandbox-panel";
40
40
  import { PanelEmptyState } from "./empty-state";
41
41
  import { PACKAGES_INPUT_ID, packagesToInstallAtom } from "./packages-utils";
42
42
 
@@ -71,17 +71,12 @@ const PackageActionButton: React.FC<{
71
71
  const PackagesPanel: React.FC = () => {
72
72
  const sandbox = useAtomValue(sandboxAtom);
73
73
  const connected = useAtomValue(isConnectedAtom);
74
- const notice = useAtomValue(connectionNoticeAtom);
75
- const operation = useAtomValue(sandboxSyncAtom);
74
+ const notice = useConnectionNotice(0);
76
75
  if (sandbox?.backend) {
77
76
  return (
78
77
  <div className="flex flex-col flex-1 min-h-0 overflow-hidden">
79
- {notice && (!operation.pending || !connected) ? (
80
- <SandboxStartupPanel />
81
- ) : (
82
- <PackageContents />
83
- )}
84
- <SandboxFooter />
78
+ <SandboxDetails />
79
+ {connected && <PackageContents />}
85
80
  </div>
86
81
  );
87
82
  }
@@ -104,13 +99,18 @@ const PackagesPanel: React.FC = () => {
104
99
  };
105
100
 
106
101
  const PackageContents: React.FC = () => {
107
- const { pending: syncing } = useAtomValue(sandboxSyncAtom);
102
+ const syncing = useAtomValue(sandboxSyncAtom).kind === "running";
108
103
  const [userViewMode, setUserViewMode] = React.useState<ViewMode | null>(null);
109
104
  const { data: dependencies, error, isPending } = usePackageDependencies();
110
105
 
111
106
  // Only show on the first load
112
107
  if (isPending) {
113
- return <Spinner size="medium" centered={true} />;
108
+ return (
109
+ <output className="flex flex-1 items-center justify-center gap-2 text-xs text-muted-foreground">
110
+ <Spinner className="size-4" />
111
+ Loading installed packages…
112
+ </output>
113
+ );
114
114
  }
115
115
 
116
116
  if (error) {
@@ -179,15 +179,26 @@ const PackageContents: React.FC = () => {
179
179
  </div>
180
180
  </div>
181
181
  )}
182
- {viewMode === "list" ? (
183
- <PackagesList packages={dependencies.list} />
184
- ) : (
185
- <DependencyTree
186
- tree={dependencies.tree}
187
- error={error}
188
- sandboxBackend={sandboxBackend}
189
- />
190
- )}
182
+ <fieldset
183
+ disabled={syncing}
184
+ className="flex flex-col flex-1 min-h-0"
185
+ aria-busy={syncing}
186
+ >
187
+ {syncing && (
188
+ <p className="px-3 py-2 text-xs text-muted-foreground">
189
+ Showing packages from the last sync.
190
+ </p>
191
+ )}
192
+ {viewMode === "list" ? (
193
+ <PackagesList packages={dependencies.list} />
194
+ ) : (
195
+ <DependencyTree
196
+ tree={dependencies.tree}
197
+ error={error}
198
+ sandboxBackend={sandboxBackend}
199
+ />
200
+ )}
201
+ </fieldset>
191
202
  </div>
192
203
  );
193
204
  };
@@ -0,0 +1,38 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { atom } from "jotai";
3
+ import { connectionNoticeAtom } from "@/core/network/connection-notice";
4
+ import { preparationAtom } from "@/core/packages/sandbox-state";
5
+
6
+ const preferenceAtom = atom<{
7
+ preparationId: string | undefined;
8
+ error: string | null;
9
+ expanded: boolean;
10
+ } | null>(null);
11
+
12
+ const disclosureContextAtom = atom((get) => {
13
+ const notice = get(connectionNoticeAtom);
14
+ return {
15
+ preparationId: get(preparationAtom)?.operation_id,
16
+ error: notice && !notice.pending ? (notice.error ?? notice.title) : null,
17
+ };
18
+ });
19
+
20
+ export const sandboxDetailsExpandedAtom = atom(
21
+ (get) => {
22
+ const context = get(disclosureContextAtom);
23
+ const preference = get(preferenceAtom);
24
+ if (
25
+ preference &&
26
+ preference.preparationId === context.preparationId &&
27
+ preference.error === context.error
28
+ ) {
29
+ return preference.expanded;
30
+ }
31
+ // New work and failures open automatically. Completed startup stays quiet
32
+ // unless the user chose to inspect it.
33
+ return get(connectionNoticeAtom) !== null;
34
+ },
35
+ (get, set, expanded: boolean) => {
36
+ set(preferenceAtom, { ...get(disclosureContextAtom), expanded });
37
+ },
38
+ );