@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.
- package/dist/{ErrorBoundary-CegtSjfd.js → ErrorBoundary-Bc52FkKN.js} +1 -1
- package/dist/{cells-BIfOnGBK.js → cells-DYIkSxs6.js} +4 -0
- package/dist/{chat-ui-LWkjhObe.js → chat-ui-BXj3uYjy.js} +4 -4
- package/dist/{code-visibility-Blq7oGrC.js → code-visibility-CI3ySb6F.js} +2 -2
- package/dist/{common-BQZDWy8O.js → common-BOJoCp6P.js} +3 -3
- package/dist/{data-grid-overlay-editor-DsU0Zpgt.js → data-grid-overlay-editor-BsT4_auq.js} +1 -1
- package/dist/{error-entries-CR0oClSL.js → error-entries-C0NsUqXl.js} +1 -1
- package/dist/{feedback-button-pGaDcCJS.js → feedback-button-B7KS_3RP.js} +4 -4
- package/dist/{glide-data-editor-DtJQCRsf.js → glide-data-editor-DtckYC_c.js} +3 -3
- package/dist/{globals-DBAC4Ihd.js → globals-CruHzAUQ.js} +1 -1
- package/dist/{html-to-image-_i03yR4q.js → html-to-image-CeYYvQNb.js} +1 -1
- package/dist/main.js +25 -14
- package/dist/{number-overlay-editor-9M7FnzxL.js → number-overlay-editor-CV1RN5Cg.js} +1 -1
- package/dist/{process-output-D3zQyTAS.js → process-output-BCTjlyuH.js} +2 -2
- package/dist/{reveal-component-ovaRyk0R.js → reveal-component-C47L1uNq.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/fixtures/pair-preview.ts +1 -1
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +6 -6
- package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +1 -1
- package/src/components/editor/alerts/__tests__/connection-notice.test.tsx +186 -0
- package/src/components/editor/alerts/__tests__/startup-progress.test.tsx +185 -0
- package/src/components/editor/alerts/connection-notice.tsx +100 -54
- package/src/components/editor/alerts/startup-output.tsx +173 -0
- package/src/components/editor/alerts/startup-progress.tsx +204 -0
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +53 -4
- package/src/components/editor/chrome/panels/__tests__/sandbox-details.test.tsx +358 -0
- package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +5 -2
- package/src/components/editor/chrome/panels/packages-panel.tsx +32 -21
- package/src/components/editor/chrome/panels/sandbox-details-state.ts +38 -0
- package/src/components/editor/chrome/panels/sandbox-panel.tsx +188 -131
- package/src/components/editor/chrome/panels/sandbox-toggle.tsx +52 -0
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +7 -0
- package/src/components/editor/package-alert.tsx +112 -66
- package/src/core/alerts/__tests__/environment.test.ts +183 -0
- package/src/core/alerts/__tests__/state.test.tsx +240 -0
- package/src/core/alerts/environment.ts +57 -0
- package/src/core/alerts/state.ts +155 -99
- package/src/core/islands/bootstrap.ts +2 -1
- package/src/core/kernel/messages.ts +2 -2
- package/src/core/network/connection-notice.ts +45 -12
- package/src/core/network/connection.ts +19 -0
- package/src/core/network/useConnectionNotice.ts +53 -0
- package/src/core/packages/__tests__/useSandboxController.test.tsx +86 -5
- package/src/core/packages/sandbox-state.ts +56 -4
- package/src/core/packages/useSandboxController.ts +27 -7
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +199 -8
- package/src/core/websocket/types.ts +1 -1
- package/src/core/websocket/useMarimoKernelConnection.tsx +31 -19
- package/src/plugins/impl/FileBrowserPlugin.tsx +10 -4
- package/src/plugins/impl/__tests__/FileBrowserPlugin.test.tsx +50 -1
- package/src/stories/sandbox.stories.tsx +230 -50
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@ import { pairPreviewSchema } from "@/core/config/pair";
|
|
|
6
6
|
|
|
7
7
|
// Read the Python-owned resource only in tests to check the shared contract.
|
|
8
8
|
export const PAIR_PREVIEW = pairPreviewSchema.parse({
|
|
9
|
-
command: "
|
|
9
|
+
command: "uvx marimo@latest",
|
|
10
10
|
templates: JSON.parse(
|
|
11
11
|
readFileSync(
|
|
12
12
|
path.resolve(
|
|
@@ -129,7 +129,7 @@ describe("getTerminalCommand", () => {
|
|
|
129
129
|
PAIR_PREVIEW,
|
|
130
130
|
),
|
|
131
131
|
).toBe(
|
|
132
|
-
String.raw`codex "$(MARIMO_PAIR_NEXT=1
|
|
132
|
+
String.raw`codex "$(MARIMO_PAIR_NEXT=1 uvx marimo@latest pair prompt \
|
|
133
133
|
--url http://localhost:8000)"`,
|
|
134
134
|
);
|
|
135
135
|
} finally {
|
|
@@ -152,7 +152,7 @@ describe("getTerminalCommand", () => {
|
|
|
152
152
|
PAIR_PREVIEW,
|
|
153
153
|
),
|
|
154
154
|
).toBe(
|
|
155
|
-
String.raw`${cli} "$(MARIMO_PAIR_NEXT=1
|
|
155
|
+
String.raw`${cli} "$(MARIMO_PAIR_NEXT=1 uvx marimo@latest pair prompt \
|
|
156
156
|
--url http://localhost:8000 \
|
|
157
157
|
--file notebooks/example.py \
|
|
158
158
|
--session s_ab12cd \
|
|
@@ -264,8 +264,8 @@ describe("getRawPrompt", () => {
|
|
|
264
264
|
expect(getRawPrompt(connection, null, PAIR_PREVIEW)).toBe(
|
|
265
265
|
"Pair with me on this running marimo notebook.\n\n" +
|
|
266
266
|
`URL: http://localhost:8000\n${fields}\n` +
|
|
267
|
-
"Run `
|
|
268
|
-
"Use `
|
|
267
|
+
"Run `uvx marimo@latest pair --help` first.\n" +
|
|
268
|
+
"Use `uvx marimo@latest` for all marimo commands.\n\n" +
|
|
269
269
|
"Once connected, send a fun toast using `mo.status.toast(...)` (`import marimo as mo`).",
|
|
270
270
|
);
|
|
271
271
|
});
|
|
@@ -278,8 +278,8 @@ describe("getRawPrompt", () => {
|
|
|
278
278
|
"URL: http://localhost:8000\n" +
|
|
279
279
|
"File: notebooks/example.py\n" +
|
|
280
280
|
"Session: s_ab12cd\n\n" +
|
|
281
|
-
"Run `
|
|
282
|
-
"Use `
|
|
281
|
+
"Run `uvx marimo@latest pair --help` first.\n" +
|
|
282
|
+
"Use `uvx marimo@latest` for all marimo commands.\n\n" +
|
|
283
283
|
"Once connected, send a fun toast using `mo.status.toast(...)` (`import marimo as mo`).",
|
|
284
284
|
);
|
|
285
285
|
});
|
|
@@ -203,7 +203,7 @@ describe("PairWithAgentModal", () => {
|
|
|
203
203
|
);
|
|
204
204
|
await waitFor(() =>
|
|
205
205
|
expect(copyToClipboard).toHaveBeenCalledWith(
|
|
206
|
-
String.raw`claude "$(MARIMO_PAIR_NEXT=1
|
|
206
|
+
String.raw`claude "$(MARIMO_PAIR_NEXT=1 uvx marimo@latest pair prompt \
|
|
207
207
|
--url http://localhost:8000/ \
|
|
208
208
|
--file notebook.py \
|
|
209
209
|
--session ${getSessionId()} \
|
|
@@ -0,0 +1,186 @@
|
|
|
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 { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
5
|
+
import { MockNotebook } from "@/__mocks__/notebook";
|
|
6
|
+
import type { EnvironmentOperation } from "@/core/alerts/environment";
|
|
7
|
+
import { alertAtom, getPackageAlert } from "@/core/alerts/state";
|
|
8
|
+
import { notebookAtom } from "@/core/cells/cells";
|
|
9
|
+
import { CellId } from "@/core/cells/ids";
|
|
10
|
+
import { AppConfigSchema } from "@/core/config/config-schema";
|
|
11
|
+
import { connectionAtom } from "@/core/network/connection";
|
|
12
|
+
import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
|
|
13
|
+
import { WebSocketClosedReason, WebSocketState } from "@/core/websocket/types";
|
|
14
|
+
import { chromeAtom } from "../../chrome/state";
|
|
15
|
+
import { ConnectionNotice } from "../connection-notice";
|
|
16
|
+
|
|
17
|
+
beforeEach(() => vi.useFakeTimers());
|
|
18
|
+
afterEach(() => vi.useRealTimers());
|
|
19
|
+
|
|
20
|
+
function mount(existingCells = false) {
|
|
21
|
+
const store = createStore();
|
|
22
|
+
store.set(sandboxAtom, {
|
|
23
|
+
backend: "pixi",
|
|
24
|
+
manifest: "",
|
|
25
|
+
filename: "notebook.py",
|
|
26
|
+
});
|
|
27
|
+
store.set(connectionAtom, {
|
|
28
|
+
state: WebSocketState.CONNECTING,
|
|
29
|
+
phase: "preparing-environment",
|
|
30
|
+
});
|
|
31
|
+
if (existingCells) {
|
|
32
|
+
store.set(
|
|
33
|
+
notebookAtom,
|
|
34
|
+
MockNotebook.notebookState({
|
|
35
|
+
cellData: { [CellId.create()]: { code: "1 + 1" } },
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
render(
|
|
40
|
+
<Provider store={store}>
|
|
41
|
+
<ConnectionNotice
|
|
42
|
+
appConfig={AppConfigSchema.parse({})}
|
|
43
|
+
onRetry={vi.fn()}
|
|
44
|
+
/>
|
|
45
|
+
</Provider>,
|
|
46
|
+
);
|
|
47
|
+
return store;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
it("shows both stages in an empty notebook, checks them off, then dismisses Ready", () => {
|
|
51
|
+
const store = mount();
|
|
52
|
+
act(() => vi.advanceTimersByTime(500));
|
|
53
|
+
const steps = within(
|
|
54
|
+
screen.getByRole("list", { name: "Notebook startup stages" }),
|
|
55
|
+
);
|
|
56
|
+
expect(steps.getByText("Preparing environment")).toBeInTheDocument();
|
|
57
|
+
expect(steps.getByText("Start kernel")).toBeInTheDocument();
|
|
58
|
+
act(() =>
|
|
59
|
+
store.set(connectionAtom, {
|
|
60
|
+
state: WebSocketState.CONNECTING,
|
|
61
|
+
phase: "starting-kernel",
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(steps.getByText("Environment prepared")).toBeInTheDocument();
|
|
65
|
+
expect(steps.getByText("Starting kernel")).toBeInTheDocument();
|
|
66
|
+
act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
|
|
67
|
+
expect(
|
|
68
|
+
screen.getByRole("heading", { name: "Your notebook is ready" }),
|
|
69
|
+
).toBeInTheDocument();
|
|
70
|
+
expect(steps.getByText("Kernel started")).toBeInTheDocument();
|
|
71
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
72
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
73
|
+
"Your notebook is ready",
|
|
74
|
+
);
|
|
75
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
76
|
+
expect(
|
|
77
|
+
screen.queryByRole("region", { name: "Notebook startup" }),
|
|
78
|
+
).not.toBeInTheDocument();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("opens Packages from the minimal inline status when cells exist", () => {
|
|
82
|
+
const store = mount(true);
|
|
83
|
+
act(() => vi.advanceTimersByTime(500));
|
|
84
|
+
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
|
85
|
+
fireEvent.click(screen.getByRole("button", { name: /Open Packages/ }));
|
|
86
|
+
expect(store.get(chromeAtom)).toMatchObject({
|
|
87
|
+
isSidebarOpen: true,
|
|
88
|
+
selectedPanel: "packages",
|
|
89
|
+
});
|
|
90
|
+
act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
|
|
91
|
+
expect(screen.getByRole("status")).toHaveTextContent(/^Ready$/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("does not flash Ready for a startup that finished before the notice appeared", () => {
|
|
95
|
+
const store = mount(true);
|
|
96
|
+
act(() => vi.advanceTimersByTime(200));
|
|
97
|
+
act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
|
|
98
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
99
|
+
act(() => vi.advanceTimersByTime(2000));
|
|
100
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("replaces Ready with a new attempt and never dismisses a failure", () => {
|
|
104
|
+
const store = mount();
|
|
105
|
+
act(() => vi.advanceTimersByTime(500));
|
|
106
|
+
act(() => store.set(connectionAtom, { state: WebSocketState.OPEN }));
|
|
107
|
+
act(() => vi.advanceTimersByTime(1000));
|
|
108
|
+
act(() =>
|
|
109
|
+
store.set(connectionAtom, {
|
|
110
|
+
state: WebSocketState.CONNECTING,
|
|
111
|
+
phase: "starting-kernel",
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
114
|
+
act(() => vi.advanceTimersByTime(500));
|
|
115
|
+
expect(screen.getByText("Starting kernel")).toBeInTheDocument();
|
|
116
|
+
act(() =>
|
|
117
|
+
store.set(connectionAtom, {
|
|
118
|
+
state: WebSocketState.CLOSED,
|
|
119
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
120
|
+
phase: "starting-kernel",
|
|
121
|
+
reason: "Kernel startup failed",
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
act(() => vi.advanceTimersByTime(10_000));
|
|
125
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
126
|
+
"Kernel failed to start",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("shows a sync as a single operation, without pretending to restart the kernel", () => {
|
|
131
|
+
const store = mount(true);
|
|
132
|
+
act(() => {
|
|
133
|
+
store.set(connectionAtom, { state: WebSocketState.OPEN });
|
|
134
|
+
store.set(sandboxSyncAtom, { kind: "running" });
|
|
135
|
+
});
|
|
136
|
+
act(() => vi.advanceTimersByTime(500));
|
|
137
|
+
expect(screen.getByRole("status")).toHaveTextContent("Syncing sandbox");
|
|
138
|
+
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
|
139
|
+
act(() => store.set(sandboxSyncAtom, { kind: "succeeded" }));
|
|
140
|
+
expect(screen.getByRole("status")).toHaveTextContent("Environment synced");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("restores syncing in the existing status and stays quiet for a completed snapshot", () => {
|
|
144
|
+
const store = mount(true);
|
|
145
|
+
const operation: EnvironmentOperation = {
|
|
146
|
+
operation_id: "sync",
|
|
147
|
+
action: "sync",
|
|
148
|
+
source: "kernel",
|
|
149
|
+
status: { kind: "succeeded" },
|
|
150
|
+
packages: {},
|
|
151
|
+
logs: { environment: "Sync complete\n" },
|
|
152
|
+
};
|
|
153
|
+
const restore = (status: EnvironmentOperation["status"]) =>
|
|
154
|
+
store.set(alertAtom, (value) => ({
|
|
155
|
+
...value,
|
|
156
|
+
environments: {
|
|
157
|
+
...value.environments,
|
|
158
|
+
kernel: {
|
|
159
|
+
restart_required: false,
|
|
160
|
+
operations: [{ ...operation, status }],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}));
|
|
164
|
+
act(() => {
|
|
165
|
+
store.set(connectionAtom, { state: WebSocketState.OPEN });
|
|
166
|
+
restore({ kind: "succeeded" });
|
|
167
|
+
});
|
|
168
|
+
act(() => vi.advanceTimersByTime(2000));
|
|
169
|
+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
170
|
+
|
|
171
|
+
act(() => restore({ kind: "running" }));
|
|
172
|
+
act(() => vi.advanceTimersByTime(500));
|
|
173
|
+
expect(screen.getByRole("status")).toHaveTextContent("Syncing sandbox");
|
|
174
|
+
expect(getPackageAlert(store.get(alertAtom))).toBeNull();
|
|
175
|
+
for (const [status, title] of [
|
|
176
|
+
[{ kind: "failed", error: "Dependency conflict" }, "Sandbox sync failed"],
|
|
177
|
+
[
|
|
178
|
+
{ kind: "restart-required", reason: "Restart the kernel" },
|
|
179
|
+
"Sandbox restart required",
|
|
180
|
+
],
|
|
181
|
+
[{ kind: "cancelled" }, "Sandbox sync failed"],
|
|
182
|
+
] as const) {
|
|
183
|
+
act(() => restore(status));
|
|
184
|
+
expect(screen.getByRole("status")).toHaveTextContent(title);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { createStore, Provider } from "jotai";
|
|
4
|
+
import { expect, it } from "vitest";
|
|
5
|
+
import { TooltipProvider } from "@/components/ui/tooltip";
|
|
6
|
+
import type { EnvironmentOperation } from "@/core/alerts/environment";
|
|
7
|
+
import { alertAtom } from "@/core/alerts/state";
|
|
8
|
+
import { startupProgressAtom } from "@/core/network/connection";
|
|
9
|
+
import type { DisplayConnectionNotice } from "@/core/network/useConnectionNotice";
|
|
10
|
+
import { StartupOutput } from "../startup-output";
|
|
11
|
+
import { StartupProgress } from "../startup-progress";
|
|
12
|
+
|
|
13
|
+
const notice: DisplayConnectionNotice = {
|
|
14
|
+
kind: "startup",
|
|
15
|
+
phase: "starting-kernel",
|
|
16
|
+
sandbox: true,
|
|
17
|
+
title: "Starting kernel",
|
|
18
|
+
description: "Connecting to this notebook.",
|
|
19
|
+
pending: true,
|
|
20
|
+
ready: false,
|
|
21
|
+
error: null,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const preparation: EnvironmentOperation = {
|
|
25
|
+
operation_id: "prepare-1",
|
|
26
|
+
action: "prepare",
|
|
27
|
+
source: "kernel",
|
|
28
|
+
status: { kind: "succeeded" },
|
|
29
|
+
packages: {},
|
|
30
|
+
logs: {
|
|
31
|
+
environment:
|
|
32
|
+
"Using Python\nResolving dependencies\nDownloading numpy\nInstalled 24 packages\n",
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function restorePreparation(
|
|
37
|
+
store: ReturnType<typeof createStore>,
|
|
38
|
+
operation: EnvironmentOperation,
|
|
39
|
+
) {
|
|
40
|
+
store.set(alertAtom, (state) => ({
|
|
41
|
+
...state,
|
|
42
|
+
environments: {
|
|
43
|
+
...state.environments,
|
|
44
|
+
kernel: { restart_required: false, operations: [operation] },
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
it("restores separate compact previews and keeps expanded output open through snapshots and live updates", () => {
|
|
50
|
+
const store = createStore();
|
|
51
|
+
restorePreparation(store, preparation);
|
|
52
|
+
store.set(startupProgressAtom, {
|
|
53
|
+
phase: "starting-kernel",
|
|
54
|
+
logs: "Using Python\nLoading runtime\nLaunching kernel\nConnecting\n",
|
|
55
|
+
log_mode: "replace",
|
|
56
|
+
});
|
|
57
|
+
render(
|
|
58
|
+
<Provider store={store}>
|
|
59
|
+
<StartupProgress notice={notice} surface="sidebar" />
|
|
60
|
+
</Provider>,
|
|
61
|
+
{ wrapper: TooltipProvider },
|
|
62
|
+
);
|
|
63
|
+
const prepareButton = screen.getByRole("button", {
|
|
64
|
+
name: "Expand environment preparation output",
|
|
65
|
+
});
|
|
66
|
+
expect(prepareButton).toHaveTextContent("Installed 24 packages");
|
|
67
|
+
expect(prepareButton).toHaveTextContent("Resolving dependencies");
|
|
68
|
+
expect(prepareButton).toHaveTextContent("Downloading numpy");
|
|
69
|
+
expect(prepareButton).not.toHaveTextContent("Using Python");
|
|
70
|
+
const kernelButton = screen.getByRole("button", {
|
|
71
|
+
name: "Expand kernel startup output",
|
|
72
|
+
});
|
|
73
|
+
expect(kernelButton).toHaveAttribute("aria-expanded", "false");
|
|
74
|
+
expect(kernelButton).toHaveTextContent(
|
|
75
|
+
"Loading runtimeLaunching kernelConnecting",
|
|
76
|
+
);
|
|
77
|
+
expect(kernelButton).not.toHaveTextContent("Using Python");
|
|
78
|
+
|
|
79
|
+
fireEvent.click(prepareButton);
|
|
80
|
+
const prepareOutput = screen.getByLabelText("environment preparation output");
|
|
81
|
+
expect(prepareOutput).toBeVisible();
|
|
82
|
+
expect(prepareOutput.textContent).toBe(preparation.logs.environment);
|
|
83
|
+
act(() => restorePreparation(store, { ...preparation }));
|
|
84
|
+
expect(prepareOutput).toBeVisible();
|
|
85
|
+
|
|
86
|
+
fireEvent.click(kernelButton);
|
|
87
|
+
act(() =>
|
|
88
|
+
store.set(startupProgressAtom, {
|
|
89
|
+
phase: "starting-kernel",
|
|
90
|
+
logs: "Kernel connected\n",
|
|
91
|
+
log_mode: "append",
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
const output = screen.getByLabelText("kernel startup output");
|
|
95
|
+
expect(output).toBeVisible();
|
|
96
|
+
expect(output.textContent).toBe(
|
|
97
|
+
"Using Python\nLoading runtime\nLaunching kernel\nConnecting\nKernel connected\n",
|
|
98
|
+
);
|
|
99
|
+
expect(prepareOutput.textContent).toBe(preparation.logs.environment);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("starts a new preparation compact and offers no disclosure before output arrives", () => {
|
|
103
|
+
const store = createStore();
|
|
104
|
+
const { rerender } = render(
|
|
105
|
+
<Provider store={store}>
|
|
106
|
+
<StartupProgress
|
|
107
|
+
notice={{ ...notice, phase: "preparing-environment" }}
|
|
108
|
+
surface="sidebar"
|
|
109
|
+
/>
|
|
110
|
+
</Provider>,
|
|
111
|
+
{ wrapper: TooltipProvider },
|
|
112
|
+
);
|
|
113
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
114
|
+
act(() => restorePreparation(store, preparation));
|
|
115
|
+
fireEvent.click(screen.getByRole("button", { name: /Expand environment/ }));
|
|
116
|
+
act(() =>
|
|
117
|
+
restorePreparation(store, {
|
|
118
|
+
...preparation,
|
|
119
|
+
operation_id: "prepare-2",
|
|
120
|
+
status: { kind: "running" },
|
|
121
|
+
logs: { environment: "Trying again\n" },
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
expect(
|
|
125
|
+
screen.getByRole("button", { name: /Expand environment/ }),
|
|
126
|
+
).toHaveTextContent("Trying again");
|
|
127
|
+
expect(
|
|
128
|
+
screen.getByLabelText("environment preparation output"),
|
|
129
|
+
).not.toBeVisible();
|
|
130
|
+
|
|
131
|
+
rerender(
|
|
132
|
+
<Provider store={store}>
|
|
133
|
+
<StartupProgress notice={notice} surface="notebook" />
|
|
134
|
+
</Provider>,
|
|
135
|
+
);
|
|
136
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("preserves reading position and selected text while output streams, then resumes following", () => {
|
|
140
|
+
const logs = "Resolving dependencies\nDownloading numpy\n";
|
|
141
|
+
const { rerender } = render(
|
|
142
|
+
<StartupOutput logs={logs} label="preparation output" />,
|
|
143
|
+
{ wrapper: TooltipProvider },
|
|
144
|
+
);
|
|
145
|
+
fireEvent.click(
|
|
146
|
+
screen.getByRole("button", { name: "Expand preparation output" }),
|
|
147
|
+
);
|
|
148
|
+
const output = screen.getByLabelText("preparation output");
|
|
149
|
+
Object.defineProperties(output, {
|
|
150
|
+
scrollHeight: { value: 1000 },
|
|
151
|
+
clientHeight: { value: 200 },
|
|
152
|
+
});
|
|
153
|
+
fireEvent.scroll(output, { target: { scrollTop: 100 } });
|
|
154
|
+
expect(screen.getByRole("button", { name: "Jump to latest" })).toBeVisible();
|
|
155
|
+
|
|
156
|
+
const selection = document.getSelection();
|
|
157
|
+
const range = document.createRange();
|
|
158
|
+
range.selectNodeContents(output);
|
|
159
|
+
act(() => {
|
|
160
|
+
selection?.removeAllRanges();
|
|
161
|
+
selection?.addRange(range);
|
|
162
|
+
document.dispatchEvent(new Event("selectionchange"));
|
|
163
|
+
});
|
|
164
|
+
rerender(
|
|
165
|
+
<StartupOutput
|
|
166
|
+
logs={`${logs}Downloaded numpy\n`}
|
|
167
|
+
label="preparation output"
|
|
168
|
+
/>,
|
|
169
|
+
);
|
|
170
|
+
expect(selection?.toString()).toBe(logs);
|
|
171
|
+
expect(output.scrollTop).toBe(100);
|
|
172
|
+
expect(output.textContent).toBe(`${logs}Downloaded numpy\n`);
|
|
173
|
+
|
|
174
|
+
fireEvent.click(screen.getByRole("button", { name: "Jump to latest" }));
|
|
175
|
+
expect(selection?.isCollapsed).toBe(true);
|
|
176
|
+
expect(output.scrollTop).toBeGreaterThan(100);
|
|
177
|
+
expect(
|
|
178
|
+
screen.queryByRole("button", { name: "Jump to latest" }),
|
|
179
|
+
).not.toBeInTheDocument();
|
|
180
|
+
|
|
181
|
+
fireEvent.click(
|
|
182
|
+
screen.getByRole("button", { name: "Collapse preparation output" }),
|
|
183
|
+
);
|
|
184
|
+
expect(output).not.toBeVisible();
|
|
185
|
+
});
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import { useAtomValue } from "jotai";
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowRightIcon, ChevronRightIcon } from "lucide-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
-
import { Spinner } from "@/components/icons/spinner";
|
|
6
5
|
import { Button } from "@/components/ui/button";
|
|
7
6
|
import { hasCellsAtom } from "@/core/cells/cells";
|
|
8
7
|
import type { AppConfig } from "@/core/config/config-schema";
|
|
9
8
|
import { connectionAtom } from "@/core/network/connection";
|
|
10
|
-
import {
|
|
9
|
+
import { useConnectionNotice } from "@/core/network/useConnectionNotice";
|
|
10
|
+
import { sandboxAtom } from "@/core/packages/sandbox-state";
|
|
11
11
|
import { WebSocketState } from "@/core/websocket/types";
|
|
12
|
-
import { useDelayElapsed } from "@/hooks/useDelayElapsed";
|
|
13
12
|
import { useInterval } from "@/hooks/useInterval";
|
|
14
13
|
import { cn } from "@/utils/cn";
|
|
15
14
|
import { SandboxErrorOutput } from "../chrome/panels/sandbox-panel";
|
|
16
15
|
import { chromeAtom, useChromeActions } from "../chrome/state";
|
|
17
16
|
import { VerticalLayoutWrapper } from "../renderers/vertical-layout/vertical-layout-wrapper";
|
|
17
|
+
import { ConnectionStatusIcon, StartupProgress } from "./startup-progress";
|
|
18
18
|
|
|
19
19
|
export const ConnectionNotice = ({
|
|
20
20
|
appConfig,
|
|
@@ -23,28 +23,61 @@ export const ConnectionNotice = ({
|
|
|
23
23
|
appConfig: AppConfig;
|
|
24
24
|
onRetry: () => void;
|
|
25
25
|
}) => {
|
|
26
|
-
const notice =
|
|
26
|
+
const notice = useConnectionNotice();
|
|
27
27
|
const connection = useAtomValue(connectionAtom);
|
|
28
28
|
const hasCells = useAtomValue(hasCellsAtom);
|
|
29
|
+
const sandbox = useAtomValue(sandboxAtom);
|
|
29
30
|
const chrome = useAtomValue(chromeAtom);
|
|
30
31
|
const packagesOpen =
|
|
31
32
|
(chrome.isSidebarOpen && chrome.selectedPanel === "packages") ||
|
|
32
33
|
(chrome.isDeveloperPanelOpen &&
|
|
33
34
|
chrome.selectedDeveloperPanelTab === "packages");
|
|
34
|
-
const elapsed = useDelayElapsed(notice?.pending ? 500 : 0);
|
|
35
35
|
const { openApplication } = useChromeActions();
|
|
36
36
|
|
|
37
37
|
// Reconnection uses the footer indicator while existing cells stay visible.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
38
|
+
const reconnecting =
|
|
39
|
+
connection.state === WebSocketState.CONNECTING &&
|
|
40
|
+
connection.phase === "reconnecting";
|
|
41
|
+
|
|
42
|
+
if (sandbox?.backend && hasCells) {
|
|
43
|
+
const title =
|
|
44
|
+
notice?.ready && notice.kind === "startup" ? "Ready" : notice?.title;
|
|
45
|
+
return (
|
|
46
|
+
<VerticalLayoutWrapper
|
|
47
|
+
appConfig={appConfig}
|
|
48
|
+
// Reserve the status row and gutter so completion never shifts cells.
|
|
49
|
+
className="shrink-0 h-12 pb-0 sm:pb-0 print:hidden"
|
|
50
|
+
innerClassName="pb-0 sm:pb-0 pr-4"
|
|
51
|
+
>
|
|
52
|
+
{notice && !reconnecting && (
|
|
53
|
+
<section aria-label="Notebook startup">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => openApplication("packages")}
|
|
57
|
+
aria-label={`${title} Open Packages`}
|
|
58
|
+
aria-expanded={packagesOpen}
|
|
59
|
+
className="flex h-6 items-center gap-2 whitespace-nowrap text-xs text-muted-foreground hover:text-foreground rounded focus-visible:outline-2 focus-visible:outline-ring"
|
|
60
|
+
>
|
|
61
|
+
<ConnectionStatusIcon notice={notice} />
|
|
62
|
+
<output>{title}</output>
|
|
63
|
+
<ChevronRightIcon className="size-3" aria-hidden={true} />
|
|
64
|
+
</button>
|
|
65
|
+
</section>
|
|
66
|
+
)}
|
|
67
|
+
</VerticalLayoutWrapper>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (!notice || (hasCells && reconnecting)) {
|
|
45
72
|
return null;
|
|
46
73
|
}
|
|
47
74
|
|
|
75
|
+
const showSteps =
|
|
76
|
+
notice.sandbox &&
|
|
77
|
+
notice.kind === "startup" &&
|
|
78
|
+
(notice.ready ||
|
|
79
|
+
notice.phase === "preparing-environment" ||
|
|
80
|
+
notice.phase === "starting-kernel");
|
|
48
81
|
return (
|
|
49
82
|
<VerticalLayoutWrapper
|
|
50
83
|
appConfig={appConfig}
|
|
@@ -57,60 +90,73 @@ export const ConnectionNotice = ({
|
|
|
57
90
|
"text-muted-foreground",
|
|
58
91
|
hasCells
|
|
59
92
|
? "mt-4 mb-8 border rounded-lg px-5 py-5"
|
|
60
|
-
: "max-w-
|
|
93
|
+
: "max-w-md mx-auto mt-16 sm:mt-24 px-4",
|
|
61
94
|
hasCells && !notice.pending && "border-(--red-7)",
|
|
62
95
|
)}
|
|
63
96
|
>
|
|
64
|
-
|
|
65
|
-
{notice
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
97
|
+
{showSteps ? (
|
|
98
|
+
<StartupProgress notice={notice} surface="notebook" />
|
|
99
|
+
) : (
|
|
100
|
+
<div role="status" className="flex items-center gap-2.5">
|
|
101
|
+
<ConnectionStatusIcon notice={notice} />
|
|
102
|
+
<h2
|
|
103
|
+
className={cn(
|
|
104
|
+
"text-base",
|
|
105
|
+
notice.pending || notice.ready
|
|
106
|
+
? "font-normal text-foreground"
|
|
107
|
+
: "font-medium text-(--red-11)",
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{notice.title}
|
|
111
|
+
</h2>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
<div className={showSteps ? "mt-8" : "ml-6.5"}>
|
|
115
|
+
{!showSteps && (
|
|
116
|
+
<p
|
|
117
|
+
className={cn(
|
|
118
|
+
"mt-3 text-sm leading-7",
|
|
119
|
+
!notice.pending && "text-foreground",
|
|
120
|
+
)}
|
|
121
|
+
>
|
|
122
|
+
{notice.description}
|
|
123
|
+
</p>
|
|
72
124
|
)}
|
|
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
125
|
{!notice.sandbox && notice.error && (
|
|
94
126
|
<SandboxErrorOutput error={notice.error} />
|
|
95
127
|
)}
|
|
96
|
-
<div
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
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>
|
|
128
|
+
<div
|
|
129
|
+
className={cn(
|
|
130
|
+
"flex flex-wrap items-center gap-x-6 gap-y-3",
|
|
131
|
+
showSteps ? "border-t border-border/60 pt-4" : "mt-5",
|
|
107
132
|
)}
|
|
133
|
+
>
|
|
134
|
+
{!notice.ready &&
|
|
135
|
+
!packagesOpen &&
|
|
136
|
+
(notice.sandbox || notice.pending) && (
|
|
137
|
+
<Button
|
|
138
|
+
variant="text"
|
|
139
|
+
size="xs"
|
|
140
|
+
className={cn(
|
|
141
|
+
"p-0 text-xs gap-1.5",
|
|
142
|
+
showSteps ? "h-6" : "h-auto",
|
|
143
|
+
)}
|
|
144
|
+
onClick={() => openApplication("packages")}
|
|
145
|
+
>
|
|
146
|
+
{notice.pending ? "View setup details" : "Open Packages"}
|
|
147
|
+
<ArrowRightIcon className="size-3" aria-hidden={true} />
|
|
148
|
+
</Button>
|
|
149
|
+
)}
|
|
108
150
|
{!notice.pending && !notice.sandbox && (
|
|
109
151
|
<Button variant="outline" size="xs" onClick={onRetry}>
|
|
110
152
|
Try again
|
|
111
153
|
</Button>
|
|
112
154
|
)}
|
|
113
|
-
{notice.pending &&
|
|
155
|
+
{notice.pending && (
|
|
156
|
+
<div className={showSteps ? "ml-auto" : undefined}>
|
|
157
|
+
<StartupElapsedTime />
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
114
160
|
</div>
|
|
115
161
|
</div>
|
|
116
162
|
</section>
|