@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,173 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { ArrowDownIcon, ChevronUpIcon } from "lucide-react";
3
+ import { useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { CopyClipboardIcon } from "@/components/icons/copy-icon";
5
+ import { useEventListener } from "@/hooks/useEventListener";
6
+ import { cn } from "@/utils/cn";
7
+
8
+ function hasSelection(element: HTMLElement) {
9
+ const selection = element.ownerDocument.getSelection();
10
+ return (
11
+ selection && !selection.isCollapsed && selection.containsNode(element, true)
12
+ );
13
+ }
14
+
15
+ export type StartupOutputState = "running" | "succeeded" | "failed";
16
+
17
+ export function StartupOutput({
18
+ logs,
19
+ label,
20
+ state = "running",
21
+ }: {
22
+ logs: string;
23
+ label: string;
24
+ /** Running keeps the newest line bright; failed keeps all of it bright. */
25
+ state?: StartupOutputState;
26
+ }) {
27
+ const [expanded, setExpanded] = useState(false);
28
+ const [following, setFollowing] = useState(true);
29
+ const outputRef = useRef<HTMLPreElement>(null);
30
+ const toggleRef = useRef<HTMLButtonElement>(null);
31
+ const outputId = useId();
32
+ const lines = logs.trimEnd().split(/\r?\n/);
33
+ const emphasis =
34
+ state === "failed"
35
+ ? "text-foreground"
36
+ : state === "running"
37
+ ? "last:text-foreground"
38
+ : undefined;
39
+
40
+ useEventListener(outputRef, "keydown", (event) => {
41
+ if (event.key === "Escape") {
42
+ event.preventDefault();
43
+ setExpanded(false);
44
+ toggleRef.current?.focus();
45
+ }
46
+ });
47
+
48
+ useEventListener(document, "selectionchange", () => {
49
+ if (outputRef.current && hasSelection(outputRef.current)) {
50
+ setFollowing(false);
51
+ }
52
+ });
53
+
54
+ useLayoutEffect(() => {
55
+ const output = outputRef.current;
56
+ if (!output) {
57
+ return;
58
+ }
59
+ const previous = output.textContent ?? "";
60
+ // Append without replacing selected text nodes as more output arrives.
61
+ if (logs.startsWith(previous)) {
62
+ if (logs.length > previous.length) {
63
+ output.append(document.createTextNode(logs.slice(previous.length)));
64
+ }
65
+ } else {
66
+ output.textContent = logs;
67
+ }
68
+ if (expanded && following && !hasSelection(output)) {
69
+ output.scrollTop = output.scrollHeight;
70
+ }
71
+ }, [logs, expanded, following]);
72
+
73
+ if (!logs.trim()) {
74
+ return null;
75
+ }
76
+
77
+ return (
78
+ <div
79
+ className={cn(
80
+ "relative mt-2.5 min-w-0 contain-inline-size rounded text-muted-foreground",
81
+ // One surface: it washes on hover and the wash stays while open.
82
+ expanded ? "bg-muted/40" : "hover:bg-muted/40",
83
+ )}
84
+ >
85
+ <div className={cn(expanded && "flex items-center pr-1")}>
86
+ <button
87
+ ref={toggleRef}
88
+ type="button"
89
+ aria-label={`${expanded ? "Collapse" : "Expand"} ${label}`}
90
+ aria-expanded={expanded}
91
+ aria-controls={outputId}
92
+ title={expanded ? "Collapse output" : "Expand output"}
93
+ className={cn(
94
+ "flex w-full min-w-0 rounded px-2.5 py-1 text-left focus-visible:outline-2 focus-visible:outline-ring",
95
+ expanded &&
96
+ "items-center justify-between gap-2 text-xs hover:text-foreground",
97
+ )}
98
+ onClick={() => {
99
+ setExpanded(!expanded);
100
+ setFollowing(true);
101
+ }}
102
+ >
103
+ {expanded ? (
104
+ <>
105
+ <span className="leading-5">Logs</span>
106
+ <ChevronUpIcon className="size-3.5 shrink-0" aria-hidden={true} />
107
+ </>
108
+ ) : (
109
+ <span
110
+ className={cn(
111
+ "min-w-0 flex-1 font-mono text-[11px] leading-5",
112
+ // Fade the top of the tail so it reads as a stream, not as
113
+ // greyed-out prose.
114
+ lines.length > 1 &&
115
+ "[mask-image:linear-gradient(to_bottom,transparent,#000_70%)]",
116
+ )}
117
+ aria-hidden={true}
118
+ >
119
+ {lines.slice(-3).map((line, index) => (
120
+ <span key={index} className={cn("block truncate", emphasis)}>
121
+ {line || "\u00A0"}
122
+ </span>
123
+ ))}
124
+ </span>
125
+ )}
126
+ </button>
127
+ {expanded && (
128
+ <CopyClipboardIcon
129
+ value={logs}
130
+ ariaLabel={`Copy ${label}`}
131
+ tooltip="Copy output"
132
+ className="size-3.5"
133
+ buttonClassName="size-6 shrink-0 flex items-center justify-center rounded hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
134
+ />
135
+ )}
136
+ </div>
137
+ <pre
138
+ id={outputId}
139
+ ref={outputRef}
140
+ hidden={!expanded}
141
+ tabIndex={0}
142
+ aria-label={label}
143
+ className="max-h-48 overflow-auto overscroll-contain whitespace-pre px-2.5 pb-2.5 font-mono text-[11px] leading-5 scrollbar-thin focus-visible:outline-2 focus-visible:outline-ring"
144
+ onScroll={(event) => {
145
+ const output = event.currentTarget;
146
+ setFollowing(
147
+ !hasSelection(output) &&
148
+ output.scrollHeight - output.scrollTop - output.clientHeight < 12,
149
+ );
150
+ }}
151
+ />
152
+ {expanded && !following && (
153
+ <button
154
+ type="button"
155
+ className="absolute bottom-2 left-1/2 -translate-x-1/2 flex items-center gap-1 whitespace-nowrap rounded-full border bg-background px-2 py-1 text-xs shadow-xs hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
156
+ onClick={() => {
157
+ const output = outputRef.current;
158
+ if (output) {
159
+ if (hasSelection(output)) {
160
+ output.ownerDocument.getSelection()?.removeAllRanges();
161
+ }
162
+ output.focus({ preventScroll: true });
163
+ }
164
+ setFollowing(true);
165
+ }}
166
+ >
167
+ <ArrowDownIcon className="size-3" aria-hidden={true} />
168
+ Jump to latest
169
+ </button>
170
+ )}
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,204 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import { useAtomValue } from "jotai";
3
+ import { AlertCircleIcon, CheckIcon, CircleIcon } from "lucide-react";
4
+ import { Spinner } from "@/components/icons/spinner";
5
+ import { preparationAtom } from "@/core/packages/sandbox-state";
6
+ import { startupProgressAtom } from "@/core/network/connection";
7
+ import type { DisplayConnectionNotice } from "@/core/network/useConnectionNotice";
8
+ import { cn } from "@/utils/cn";
9
+ import { StartupOutput, type StartupOutputState } from "./startup-output";
10
+
11
+ const STEPS = [
12
+ {
13
+ phase: "preparing-environment",
14
+ titles: {
15
+ complete: "Environment prepared",
16
+ failed: "Environment setup failed",
17
+ pending: "Preparing environment",
18
+ waiting: "Prepare environment",
19
+ },
20
+ descriptions: {
21
+ complete: "Python and dependencies are available.",
22
+ failed: "Setup could not finish.",
23
+ pending: "Set up Python and dependencies.",
24
+ waiting: "Set up Python and dependencies.",
25
+ },
26
+ },
27
+ {
28
+ phase: "starting-kernel",
29
+ titles: {
30
+ complete: "Kernel started",
31
+ failed: "Kernel failed to start",
32
+ pending: "Starting kernel",
33
+ waiting: "Start kernel",
34
+ },
35
+ descriptions: {
36
+ complete: "The notebook is ready to run.",
37
+ failed: "The kernel could not connect.",
38
+ pending: "Connecting to this notebook.",
39
+ waiting: "After the environment is ready.",
40
+ },
41
+ },
42
+ ] as const;
43
+
44
+ interface StartupProgressProps {
45
+ notice: DisplayConnectionNotice;
46
+ surface: "notebook" | "sidebar";
47
+ }
48
+
49
+ function StartupStepOutput({
50
+ phase,
51
+ state,
52
+ }: {
53
+ phase: (typeof STEPS)[number]["phase"];
54
+ state: StartupOutputState;
55
+ }) {
56
+ const preparation = useAtomValue(preparationAtom);
57
+ const progress = useAtomValue(startupProgressAtom);
58
+ const preparing = phase === "preparing-environment";
59
+ const logs = preparing
60
+ ? (preparation?.logs.environment ?? "")
61
+ : progress?.phase === phase
62
+ ? progress.logs
63
+ : "";
64
+
65
+ return (
66
+ <StartupOutput
67
+ key={preparation?.operation_id}
68
+ logs={logs}
69
+ state={state}
70
+ label={
71
+ preparing ? "environment preparation output" : "kernel startup output"
72
+ }
73
+ />
74
+ );
75
+ }
76
+
77
+ function StartupSummary({ notice }: { notice: DisplayConnectionNotice }) {
78
+ return (
79
+ <header>
80
+ <output className="sr-only">{notice.title}</output>
81
+ <h2 className="text-foreground font-heading text-2xl leading-8 tracking-tight font-normal">
82
+ {notice.ready
83
+ ? "Your notebook is ready"
84
+ : "Getting your notebook ready"}
85
+ </h2>
86
+ <p className="text-muted-foreground mt-3 text-sm leading-6">
87
+ {notice.ready
88
+ ? "Your environment and kernel are ready."
89
+ : notice.pending
90
+ ? "Preparing Python and dependencies for this notebook."
91
+ : "Review the setup details below to continue."}
92
+ </p>
93
+ </header>
94
+ );
95
+ }
96
+
97
+ export function ConnectionStatusIcon({
98
+ notice,
99
+ }: {
100
+ notice: DisplayConnectionNotice;
101
+ }) {
102
+ if (notice.ready) {
103
+ return (
104
+ <CheckIcon
105
+ className="size-4 shrink-0 text-(--grass-11)"
106
+ aria-hidden={true}
107
+ />
108
+ );
109
+ }
110
+ if (notice.pending) {
111
+ return (
112
+ <Spinner
113
+ className="size-4 shrink-0 motion-reduce:animate-none"
114
+ aria-hidden={true}
115
+ />
116
+ );
117
+ }
118
+ return (
119
+ <AlertCircleIcon
120
+ className="size-4 shrink-0 text-(--red-11)"
121
+ aria-hidden={true}
122
+ />
123
+ );
124
+ }
125
+
126
+ export function StartupProgress({ notice, surface }: StartupProgressProps) {
127
+ return (
128
+ <>
129
+ {surface === "notebook" && <StartupSummary notice={notice} />}
130
+ <ol
131
+ className={cn("space-y-6", surface === "notebook" && "mt-8")}
132
+ aria-label="Notebook startup stages"
133
+ >
134
+ {STEPS.map(({ phase, titles, descriptions }, index) => {
135
+ const complete =
136
+ notice.ready || (index === 0 && notice.phase === "starting-kernel");
137
+ const active = !notice.ready && notice.phase === phase;
138
+ const failed = active && !notice.pending;
139
+ const state = complete
140
+ ? "complete"
141
+ : failed
142
+ ? "failed"
143
+ : active
144
+ ? "pending"
145
+ : "waiting";
146
+ return (
147
+ <li
148
+ key={phase}
149
+ aria-current={active ? "step" : undefined}
150
+ className="relative flex items-start gap-3"
151
+ >
152
+ {index === 0 && (
153
+ <span
154
+ aria-hidden={true}
155
+ className="absolute left-2 top-6 -bottom-4 border-l border-border"
156
+ />
157
+ )}
158
+ <span
159
+ className={cn(
160
+ "mt-0.5",
161
+ complete ? "text-(--grass-11)" : "text-muted-foreground",
162
+ )}
163
+ >
164
+ {complete ? (
165
+ <CheckIcon className="size-4" aria-hidden={true} />
166
+ ) : active ? (
167
+ <ConnectionStatusIcon notice={notice} />
168
+ ) : (
169
+ <CircleIcon
170
+ className="size-4 text-muted-foreground/50"
171
+ aria-hidden={true}
172
+ />
173
+ )}
174
+ </span>
175
+ <div className="min-w-0 flex-1">
176
+ <div
177
+ className={cn(
178
+ "text-sm",
179
+ failed
180
+ ? "text-(--red-11)"
181
+ : complete || active
182
+ ? "text-foreground"
183
+ : "text-muted-foreground",
184
+ )}
185
+ >
186
+ {titles[state]}
187
+ </div>
188
+ <p className="mt-1 text-xs text-muted-foreground">
189
+ {descriptions[state]}
190
+ </p>
191
+ {surface === "sidebar" && (
192
+ <StartupStepOutput
193
+ phase={phase}
194
+ state={failed ? "failed" : active ? "running" : "succeeded"}
195
+ />
196
+ )}
197
+ </div>
198
+ </li>
199
+ );
200
+ })}
201
+ </ol>
202
+ </>
203
+ );
204
+ }
@@ -12,6 +12,7 @@ import { Provider } from "jotai";
12
12
  import { beforeEach, describe, expect, it, vi } from "vitest";
13
13
  import { MockRequestClient } from "@/__mocks__/requests";
14
14
  import { TooltipProvider } from "@/components/ui/tooltip";
15
+ import { connectionAtom } from "@/core/network/connection";
15
16
  import { requestClientAtom } from "@/core/network/requests";
16
17
  import type {
17
18
  DependencyTreeNode,
@@ -21,7 +22,10 @@ import { withPackageInvalidation } from "@/core/packages/package-data";
21
22
  import { useInstallPackages } from "@/core/packages/useInstallPackage";
22
23
  import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
23
24
  import { store } from "@/core/state/jotai";
25
+ import { WebSocketState } from "@/core/websocket/types";
24
26
  import PackagesPanel from "../packages-panel";
27
+ import { PanelSectionProvider } from "../panel-context";
28
+ import { SandboxToggle } from "../sandbox-toggle";
25
29
 
26
30
  const { openSettings } = vi.hoisted(() => ({
27
31
  openSettings: vi.fn(),
@@ -67,7 +71,8 @@ function renderPanel(
67
71
  ? { backend: context.backend, manifest: "", filename: "notebook.py" }
68
72
  : null,
69
73
  );
70
- store.set(sandboxSyncAtom, { pending: false, error: null });
74
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
75
+ store.set(sandboxSyncAtom, { kind: "succeeded" });
71
76
  const getPackageList = vi.fn().mockResolvedValue({ packages: [] });
72
77
  const client = MockRequestClient.create({
73
78
  getPackageList,
@@ -81,7 +86,10 @@ function renderPanel(
81
86
  ...render(
82
87
  <Provider store={store}>
83
88
  <TooltipProvider>
84
- <PackagesPanel />
89
+ <PanelSectionProvider value="sidebar">
90
+ <SandboxToggle section="sidebar" />
91
+ <PackagesPanel />
92
+ </PanelSectionProvider>
85
93
  </TooltipProvider>
86
94
  </Provider>,
87
95
  ),
@@ -178,7 +186,8 @@ it("refreshes an open panel when a package is installed elsewhere, after install
178
186
  manifest: "",
179
187
  filename: "notebook.py",
180
188
  });
181
- store.set(sandboxSyncAtom, { pending: false, error: null });
189
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
190
+ store.set(sandboxSyncAtom, { kind: "succeeded" });
182
191
  store.set(requestClientAtom, withPackageInvalidation(client));
183
192
  function InstallElsewhere() {
184
193
  const { handleInstallPackages } = useInstallPackages();
@@ -191,7 +200,10 @@ it("refreshes an open panel when a package is installed elsewhere, after install
191
200
  render(
192
201
  <Provider store={store}>
193
202
  <TooltipProvider>
194
- <PackagesPanel />
203
+ <PanelSectionProvider value="sidebar">
204
+ <SandboxToggle section="sidebar" />
205
+ <PackagesPanel />
206
+ </PanelSectionProvider>
195
207
  <InstallElsewhere />
196
208
  </TooltipProvider>
197
209
  </Provider>,
@@ -208,6 +220,43 @@ it("refreshes an open panel when a package is installed elsewhere, after install
208
220
  expect(await screen.findByText("polars")).toBeInTheDocument();
209
221
  });
210
222
 
223
+ it("keeps packages and the install draft visible while blocking mutations during sync", async () => {
224
+ store.set(connectionAtom, { state: WebSocketState.OPEN });
225
+ renderPanel({ kind: "sandbox", backend: "pixi" }, populatedTree);
226
+ await screen.findByRole("treeitem", { name: /polars/ });
227
+ const input = screen.getByPlaceholderText("Add packages to pixi sandbox...");
228
+ fireEvent.change(input, { target: { value: "altair" } });
229
+ act(() => store.set(sandboxSyncAtom, { kind: "running" }));
230
+ const row = screen.getByRole("treeitem", { name: /polars/ });
231
+ expect(
232
+ screen.getByPlaceholderText("Add packages to pixi sandbox..."),
233
+ ).toBeDisabled();
234
+ expect(within(row).getByRole("button", { name: "Remove" })).toBeDisabled();
235
+ expect(
236
+ screen.queryByRole("list", { name: "Notebook startup stages" }),
237
+ ).not.toBeInTheDocument();
238
+ const toggle = screen.getByRole("button", { name: "pixi sandbox" });
239
+ fireEvent.click(toggle);
240
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
241
+ act(() =>
242
+ store.set(sandboxSyncAtom, {
243
+ kind: "failed",
244
+ error: "Could not resolve dependencies",
245
+ }),
246
+ );
247
+ expect(screen.getByRole("treeitem", { name: /polars/ })).toBeInTheDocument();
248
+ expect(screen.getByLabelText("Error details")).toHaveTextContent(
249
+ "Could not resolve dependencies",
250
+ );
251
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
252
+ expect(screen.getByRole("button", { name: "Retry sync" })).toBeVisible();
253
+ const restoredInput = screen.getByPlaceholderText(
254
+ "Add packages to pixi sandbox...",
255
+ );
256
+ expect(restoredInput).toHaveValue("altair");
257
+ expect(restoredInput).toBeEnabled();
258
+ });
259
+
211
260
  it.each(["uv", "pixi", "list"] as const)(
212
261
  "allows upgrading marimo but not removing it in the %s view",
213
262
  async (view) => {