@marimo-team/islands 0.25.2-dev66 → 0.25.2-dev89
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-BHvUXPtK.js → ErrorBoundary-DhBCglR0.js} +1 -1
- package/dist/{cells-DzqPprAQ.js → cells-DZi0ioak.js} +9 -2
- package/dist/{chat-ui-Da2o4rps.js → chat-ui-CwOWzoSO.js} +4 -4
- package/dist/{code-visibility-a6v4Kwtn.js → code-visibility-DVtowxt1.js} +2 -2
- package/dist/{common-Lbz4UoEr.js → common-CeQoafB5.js} +3 -3
- package/dist/{data-grid-overlay-editor-C7u6VQ9U.js → data-grid-overlay-editor-Df0vkYi7.js} +1 -1
- package/dist/{error-entries-CdgPvJVz.js → error-entries-BovkFYNy.js} +1 -1
- package/dist/{feedback-button-C_dzhSZo.js → feedback-button-mqHtzKAF.js} +4 -4
- package/dist/{glide-data-editor-CQKpxgZb.js → glide-data-editor-BtaerHsr.js} +3 -3
- package/dist/{globals-B0wv8u9V.js → globals-rsMlVnSz.js} +1 -1
- package/dist/{html-to-image-C-XXpUFD.js → html-to-image-Cn1_16-x.js} +1 -1
- package/dist/main.js +11 -11
- package/dist/{number-overlay-editor-BfWfXUbU.js → number-overlay-editor-Ci2JathI.js} +1 -1
- package/dist/{process-output-CdDN3Pdp.js → process-output-CUG57KRp.js} +2 -2
- package/dist/{reveal-component-D2i9ey2a.js → reveal-component-BFuZ3Pb9.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/__tests__/mount.test.ts +6 -6
- package/src/components/editor/actions/useRestartKernel.tsx +2 -2
- package/src/components/editor/alerts/__tests__/connection-notice.test.tsx +224 -17
- package/src/components/editor/alerts/connection-details.tsx +61 -0
- package/src/components/editor/alerts/connection-notice.tsx +46 -53
- package/src/components/editor/alerts/startup-progress.tsx +73 -129
- package/src/components/editor/app-container.tsx +2 -2
- package/src/components/editor/boundary/__tests__/ErrorBoundary.test.tsx +2 -2
- package/src/components/editor/cell/CreateCellButton.tsx +2 -2
- package/src/components/editor/cell/DeleteButton.tsx +2 -2
- package/src/components/editor/cell/RunButton.tsx +3 -3
- package/src/components/editor/cell/StopButton.tsx +2 -2
- package/src/components/editor/chrome/components/__tests__/feedback-button.test.tsx +4 -4
- package/src/components/editor/chrome/components/feedback-button.tsx +3 -3
- package/src/components/editor/chrome/panels/__tests__/packages-panel.test.tsx +8 -8
- package/src/components/editor/chrome/panels/__tests__/{sandbox-details.test.tsx → runtime-details.test.tsx} +94 -22
- package/src/components/editor/chrome/panels/__tests__/sandbox-recovery.test.tsx +4 -4
- package/src/components/editor/chrome/panels/packages-panel.tsx +8 -29
- package/src/components/editor/chrome/panels/{sandbox-details-state.ts → runtime-details-state.ts} +1 -1
- package/src/components/editor/chrome/panels/{sandbox-panel.tsx → runtime-panel.tsx} +38 -66
- package/src/components/editor/chrome/panels/runtime-status-toggle.tsx +56 -0
- package/src/components/editor/chrome/panels/sandbox-controller.tsx +2 -2
- package/src/components/editor/chrome/wrapper/app-chrome.tsx +3 -3
- package/src/components/editor/chrome/wrapper/footer-items/machine-stats.tsx +2 -2
- package/src/components/editor/controls/Controls.tsx +3 -3
- package/src/components/editor/controls/__tests__/notebook-menu-dropdown.test.tsx +2 -2
- package/src/components/editor/header/__tests__/status.test.tsx +4 -4
- package/src/components/editor/header/app-header.tsx +2 -2
- package/src/components/editor/header/status.tsx +3 -3
- package/src/components/ui/progress-stages.tsx +114 -0
- package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +106 -5
- package/src/core/codemirror/lsp/notebook-lsp.ts +16 -2
- package/src/core/codemirror/readonly/__tests__/extension.test.ts +6 -6
- package/src/core/codemirror/readonly/extension.ts +2 -2
- package/src/core/datasets/__tests__/data-source-discovery.test.ts +2 -2
- package/src/core/edit-app.tsx +5 -1
- package/src/core/export/hooks.ts +2 -2
- package/src/core/network/__tests__/connection.test.ts +3 -3
- package/src/core/network/connection-notice.ts +5 -5
- package/src/core/network/connection.ts +9 -9
- package/src/core/network/requests-lazy.ts +2 -2
- package/src/core/packages/__tests__/useSandboxController.test.tsx +2 -2
- package/src/core/packages/useSandboxController.ts +6 -6
- package/src/core/runtime/__tests__/adapter.test.ts +6 -6
- package/src/core/runtime/adapter.ts +7 -7
- package/src/core/runtime/config.ts +2 -2
- package/src/core/saving/filename.ts +2 -2
- package/src/core/saving/save-component.tsx +5 -5
- package/src/core/saving/useAutoSave.ts +2 -2
- package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +30 -25
- package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +5 -5
- package/src/core/websocket/connection-utils.ts +21 -21
- package/src/core/websocket/types.ts +8 -9
- package/src/core/websocket/useMarimoKernelConnection.tsx +21 -21
- package/src/mount.tsx +2 -2
- package/src/plugins/core/__test__/registerReactComponent.test.ts +55 -0
- package/src/plugins/core/registerReactComponent.tsx +3 -1
- package/src/stories/cell.stories.tsx +2 -2
- package/src/stories/layout/vertical/one-column.stories.tsx +5 -5
- package/src/stories/progress-stages.stories.tsx +62 -0
- package/src/stories/runtime-existing.stories.tsx +44 -0
- package/src/stories/runtime-maintenance.stories.tsx +40 -0
- package/src/stories/runtime-pixi.stories.tsx +28 -0
- package/src/stories/{sandbox.stories.tsx → runtime-story.tsx} +191 -152
- package/src/stories/runtime-uv.stories.tsx +56 -0
- package/src/components/editor/chrome/panels/sandbox-toggle.tsx +0 -52
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@ import { kioskModeAtom } from "@/core/mode";
|
|
|
7
7
|
import { connectionAtom } from "@/core/network/connection";
|
|
8
8
|
import { store } from "@/core/state/jotai";
|
|
9
9
|
import { isStaticNotebook } from "@/core/static/static-state";
|
|
10
|
-
import {
|
|
10
|
+
import { ConnectionState } from "@/core/websocket/types";
|
|
11
11
|
import { pairPreviewAtom } from "@/core/config/pair";
|
|
12
12
|
import { PAIR_PREVIEW } from "./fixtures/pair-preview";
|
|
13
13
|
import { mount, visibleForTesting } from "../mount";
|
|
@@ -59,7 +59,7 @@ describe("mount", () => {
|
|
|
59
59
|
store.set(layoutStateAtom, initialLayoutState());
|
|
60
60
|
store.set(kioskModeAtom, false);
|
|
61
61
|
// Reset connection atom to initial state
|
|
62
|
-
store.set(connectionAtom, { state:
|
|
62
|
+
store.set(connectionAtom, { state: ConnectionState.NOT_STARTED });
|
|
63
63
|
});
|
|
64
64
|
|
|
65
65
|
afterEach(() => {
|
|
@@ -111,7 +111,7 @@ describe("mount", () => {
|
|
|
111
111
|
);
|
|
112
112
|
|
|
113
113
|
const connection = store.get(connectionAtom);
|
|
114
|
-
expect(connection.state).toBe(
|
|
114
|
+
expect(connection.state).toBe(ConnectionState.CONNECTING);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it("should keep connection as NOT_STARTED when runtimeConfig has lazy=true", () => {
|
|
@@ -124,7 +124,7 @@ describe("mount", () => {
|
|
|
124
124
|
);
|
|
125
125
|
|
|
126
126
|
const connection = store.get(connectionAtom);
|
|
127
|
-
expect(connection.state).toBe(
|
|
127
|
+
expect(connection.state).toBe(ConnectionState.NOT_STARTED);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
it("should keep connection as NOT_STARTED when no runtimeConfig is provided", () => {
|
|
@@ -137,7 +137,7 @@ describe("mount", () => {
|
|
|
137
137
|
);
|
|
138
138
|
|
|
139
139
|
const connection = store.get(connectionAtom);
|
|
140
|
-
expect(connection.state).toBe(
|
|
140
|
+
expect(connection.state).toBe(ConnectionState.NOT_STARTED);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
it("should keep connection as NOT_STARTED for static notebooks even with lazy=false", () => {
|
|
@@ -155,7 +155,7 @@ describe("mount", () => {
|
|
|
155
155
|
);
|
|
156
156
|
|
|
157
157
|
const connection = store.get(connectionAtom);
|
|
158
|
-
expect(connection.state).toBe(
|
|
158
|
+
expect(connection.state).toBe(ConnectionState.NOT_STARTED);
|
|
159
159
|
});
|
|
160
160
|
});
|
|
161
161
|
|
|
@@ -5,7 +5,7 @@ import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
|
5
5
|
import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
|
|
6
6
|
import { connectionAtom } from "@/core/network/connection";
|
|
7
7
|
import { useRequestClient } from "@/core/network/requests";
|
|
8
|
-
import {
|
|
8
|
+
import { ConnectionState } from "@/core/websocket/types";
|
|
9
9
|
import { reloadSafe } from "@/utils/reload-safe";
|
|
10
10
|
|
|
11
11
|
export function useRestartKernel() {
|
|
@@ -22,7 +22,7 @@ export function useRestartKernel() {
|
|
|
22
22
|
confirmAction: (
|
|
23
23
|
<AlertDialogDestructiveAction
|
|
24
24
|
onClick={async () => {
|
|
25
|
-
setConnection({ state:
|
|
25
|
+
setConnection({ state: ConnectionState.CLOSING });
|
|
26
26
|
await sendRestart();
|
|
27
27
|
reloadSafe();
|
|
28
28
|
}}
|
|
@@ -8,16 +8,22 @@ import { alertAtom, getPackageAlert } from "@/core/alerts/state";
|
|
|
8
8
|
import { notebookAtom } from "@/core/cells/cells";
|
|
9
9
|
import { CellId } from "@/core/cells/ids";
|
|
10
10
|
import { AppConfigSchema } from "@/core/config/config-schema";
|
|
11
|
+
import { kernelStartupErrorAtom } from "@/core/errors/state";
|
|
11
12
|
import { connectionAtom } from "@/core/network/connection";
|
|
12
13
|
import { sandboxAtom, sandboxSyncAtom } from "@/core/packages/sandbox-state";
|
|
13
|
-
import { WebSocketClosedReason,
|
|
14
|
+
import { WebSocketClosedReason, ConnectionState } from "@/core/websocket/types";
|
|
14
15
|
import { chromeAtom } from "../../chrome/state";
|
|
16
|
+
import { runtimeDetailsExpandedAtom } from "../../chrome/panels/runtime-details-state";
|
|
15
17
|
import { ConnectionNotice } from "../connection-notice";
|
|
16
18
|
|
|
17
19
|
beforeEach(() => vi.useFakeTimers());
|
|
18
20
|
afterEach(() => vi.useRealTimers());
|
|
19
21
|
|
|
20
|
-
function mount(
|
|
22
|
+
function mount({
|
|
23
|
+
existingCells = false,
|
|
24
|
+
onRetry = vi.fn(),
|
|
25
|
+
canOpenPackages = true,
|
|
26
|
+
} = {}) {
|
|
21
27
|
const store = createStore();
|
|
22
28
|
store.set(sandboxAtom, {
|
|
23
29
|
backend: "pixi",
|
|
@@ -25,7 +31,7 @@ function mount(existingCells = false) {
|
|
|
25
31
|
filename: "notebook.py",
|
|
26
32
|
});
|
|
27
33
|
store.set(connectionAtom, {
|
|
28
|
-
state:
|
|
34
|
+
state: ConnectionState.CONNECTING,
|
|
29
35
|
phase: "preparing-environment",
|
|
30
36
|
});
|
|
31
37
|
if (existingCells) {
|
|
@@ -40,7 +46,8 @@ function mount(existingCells = false) {
|
|
|
40
46
|
<Provider store={store}>
|
|
41
47
|
<ConnectionNotice
|
|
42
48
|
appConfig={AppConfigSchema.parse({})}
|
|
43
|
-
onRetry={
|
|
49
|
+
onRetry={onRetry}
|
|
50
|
+
canOpenPackages={canOpenPackages}
|
|
44
51
|
/>
|
|
45
52
|
</Provider>,
|
|
46
53
|
);
|
|
@@ -57,13 +64,13 @@ it("shows both stages in an empty notebook, checks them off, then dismisses Read
|
|
|
57
64
|
expect(steps.getByText("Start kernel")).toBeInTheDocument();
|
|
58
65
|
act(() =>
|
|
59
66
|
store.set(connectionAtom, {
|
|
60
|
-
state:
|
|
67
|
+
state: ConnectionState.CONNECTING,
|
|
61
68
|
phase: "starting-kernel",
|
|
62
69
|
}),
|
|
63
70
|
);
|
|
64
71
|
expect(steps.getByText("Environment prepared")).toBeInTheDocument();
|
|
65
72
|
expect(steps.getByText("Starting kernel")).toBeInTheDocument();
|
|
66
|
-
act(() => store.set(connectionAtom, { state:
|
|
73
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
67
74
|
expect(
|
|
68
75
|
screen.getByRole("heading", { name: "Your notebook is ready" }),
|
|
69
76
|
).toBeInTheDocument();
|
|
@@ -78,8 +85,122 @@ it("shows both stages in an empty notebook, checks them off, then dismisses Read
|
|
|
78
85
|
).not.toBeInTheDocument();
|
|
79
86
|
});
|
|
80
87
|
|
|
88
|
+
it("uses the shared kernel stage for an ordinary connection and dismisses it when connected", () => {
|
|
89
|
+
const store = mount();
|
|
90
|
+
act(() => {
|
|
91
|
+
store.set(sandboxAtom, null);
|
|
92
|
+
store.set(connectionAtom, { state: ConnectionState.CONNECTING });
|
|
93
|
+
});
|
|
94
|
+
act(() => vi.advanceTimersByTime(500));
|
|
95
|
+
const stages = within(
|
|
96
|
+
screen.getByRole("list", {
|
|
97
|
+
name: "Notebook startup stages",
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
expect(stages.getAllByRole("listitem")).toHaveLength(1);
|
|
101
|
+
expect(stages.getByText("Starting kernel")).toBeVisible();
|
|
102
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
103
|
+
expect(screen.queryByRole("region", { name: "Notebook startup" })).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("shows connection status until a sandbox reports its startup phase", () => {
|
|
107
|
+
const store = mount();
|
|
108
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.CONNECTING }));
|
|
109
|
+
act(() => vi.advanceTimersByTime(500));
|
|
110
|
+
expect(screen.getByRole("heading", { name: "Connecting…" })).toBeVisible();
|
|
111
|
+
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
|
112
|
+
|
|
113
|
+
act(() =>
|
|
114
|
+
store.set(connectionAtom, {
|
|
115
|
+
state: ConnectionState.CLOSED,
|
|
116
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
117
|
+
reason: "Kernel startup failed",
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
expect(
|
|
121
|
+
screen.getByRole("heading", { name: "Kernel failed to start" }),
|
|
122
|
+
).toBeVisible();
|
|
123
|
+
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
|
124
|
+
|
|
125
|
+
act(() =>
|
|
126
|
+
store.set(connectionAtom, {
|
|
127
|
+
state: ConnectionState.CONNECTING,
|
|
128
|
+
phase: "preparing-environment",
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
act(() => vi.advanceTimersByTime(500));
|
|
132
|
+
expect(screen.getByRole("list")).toHaveTextContent("Preparing environment");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("shows kernel startup while sandbox metadata is loading, then adds environment preparation", () => {
|
|
136
|
+
const store = mount();
|
|
137
|
+
const sandbox = store.get(sandboxAtom);
|
|
138
|
+
act(() => store.set(sandboxAtom, null));
|
|
139
|
+
act(() => vi.advanceTimersByTime(500));
|
|
140
|
+
expect(screen.getByText("Starting kernel")).toBeVisible();
|
|
141
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
|
142
|
+
|
|
143
|
+
act(() => store.set(sandboxAtom, sandbox));
|
|
144
|
+
const stages = within(screen.getByRole("list"));
|
|
145
|
+
expect(stages.getByText("Preparing environment")).toBeVisible();
|
|
146
|
+
expect(stages.getByText("Start kernel")).toBeVisible();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("shows ordinary environment preparation failures in the kernel stage", () => {
|
|
150
|
+
const store = mount();
|
|
151
|
+
act(() => store.set(sandboxAtom, null));
|
|
152
|
+
act(() => vi.advanceTimersByTime(500));
|
|
153
|
+
expect(screen.getByText("Starting kernel")).toBeVisible();
|
|
154
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(1);
|
|
155
|
+
act(() => {
|
|
156
|
+
store.set(
|
|
157
|
+
kernelStartupErrorAtom,
|
|
158
|
+
"Configured Python environment is missing",
|
|
159
|
+
);
|
|
160
|
+
store.set(connectionAtom, {
|
|
161
|
+
state: ConnectionState.CLOSED,
|
|
162
|
+
phase: "preparing-environment",
|
|
163
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
164
|
+
reason: "Kernel startup failed",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
expect(screen.getByRole("listitem")).toHaveTextContent(
|
|
168
|
+
"Kernel failed to start",
|
|
169
|
+
);
|
|
170
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
171
|
+
"Kernel failed to start",
|
|
172
|
+
);
|
|
173
|
+
expect(screen.getByLabelText("Error details")).toHaveTextContent(
|
|
174
|
+
"Configured Python environment is missing",
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("reports the inline disclosure as expanded only when Packages and runtime details are open", () => {
|
|
179
|
+
const store = mount({ existingCells: true });
|
|
180
|
+
act(() => {
|
|
181
|
+
store.set(chromeAtom, (value) => ({
|
|
182
|
+
...value,
|
|
183
|
+
isSidebarOpen: true,
|
|
184
|
+
selectedPanel: "packages",
|
|
185
|
+
}));
|
|
186
|
+
store.set(runtimeDetailsExpandedAtom, false);
|
|
187
|
+
});
|
|
188
|
+
act(() => vi.advanceTimersByTime(500));
|
|
189
|
+
const toggle = screen.getByRole("button", { name: /Open Packages/ });
|
|
190
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
191
|
+
fireEvent.click(toggle);
|
|
192
|
+
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
|
193
|
+
act(() => store.set(runtimeDetailsExpandedAtom, false));
|
|
194
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
195
|
+
act(() => {
|
|
196
|
+
store.set(runtimeDetailsExpandedAtom, true);
|
|
197
|
+
store.set(chromeAtom, (value) => ({ ...value, isSidebarOpen: false }));
|
|
198
|
+
});
|
|
199
|
+
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
|
200
|
+
});
|
|
201
|
+
|
|
81
202
|
it("opens Packages from the minimal inline status when cells exist", () => {
|
|
82
|
-
const store = mount(true);
|
|
203
|
+
const store = mount({ existingCells: true });
|
|
83
204
|
act(() => vi.advanceTimersByTime(500));
|
|
84
205
|
expect(screen.queryByRole("list")).not.toBeInTheDocument();
|
|
85
206
|
fireEvent.click(screen.getByRole("button", { name: /Open Packages/ }));
|
|
@@ -87,14 +208,100 @@ it("opens Packages from the minimal inline status when cells exist", () => {
|
|
|
87
208
|
isSidebarOpen: true,
|
|
88
209
|
selectedPanel: "packages",
|
|
89
210
|
});
|
|
90
|
-
act(() => store.set(connectionAtom, { state:
|
|
211
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
91
212
|
expect(screen.getByRole("status")).toHaveTextContent(/^Ready$/);
|
|
92
213
|
});
|
|
93
214
|
|
|
215
|
+
it("uses an inline notice for an existing ordinary notebook, opens collapsed details, and offers retry on failure", () => {
|
|
216
|
+
const onRetry = vi.fn();
|
|
217
|
+
const store = mount({ existingCells: true, onRetry });
|
|
218
|
+
act(() => {
|
|
219
|
+
store.set(sandboxAtom, null);
|
|
220
|
+
store.set(connectionAtom, { state: ConnectionState.CONNECTING });
|
|
221
|
+
store.set(runtimeDetailsExpandedAtom, false);
|
|
222
|
+
});
|
|
223
|
+
act(() => vi.advanceTimersByTime(500));
|
|
224
|
+
const statusRow = screen.getByRole("region", {
|
|
225
|
+
name: "Notebook startup",
|
|
226
|
+
}).parentElement;
|
|
227
|
+
fireEvent.click(screen.getByRole("button", { name: /Open Packages/ }));
|
|
228
|
+
expect(store.get(chromeAtom)).toMatchObject({
|
|
229
|
+
isSidebarOpen: true,
|
|
230
|
+
selectedPanel: "packages",
|
|
231
|
+
});
|
|
232
|
+
expect(store.get(runtimeDetailsExpandedAtom)).toBe(true);
|
|
233
|
+
act(() =>
|
|
234
|
+
store.set(connectionAtom, {
|
|
235
|
+
state: ConnectionState.CLOSED,
|
|
236
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
237
|
+
reason: "Kernel startup failed",
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
expect(
|
|
241
|
+
screen.getByRole("button", {
|
|
242
|
+
name: /Kernel failed to start Open Packages/,
|
|
243
|
+
}),
|
|
244
|
+
).toBeVisible();
|
|
245
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
246
|
+
expect(onRetry).toHaveBeenCalledOnce();
|
|
247
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
248
|
+
expect(screen.queryByRole("region", { name: "Notebook startup" })).toBeNull();
|
|
249
|
+
expect(statusRow).not.toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it.each([
|
|
253
|
+
{ existingCells: false, sandbox: false },
|
|
254
|
+
{ existingCells: true, sandbox: false },
|
|
255
|
+
{ existingCells: false, sandbox: true },
|
|
256
|
+
{ existingCells: true, sandbox: true },
|
|
257
|
+
])(
|
|
258
|
+
"keeps errors in the notebook without chrome (cells=$existingCells, sandbox=$sandbox)",
|
|
259
|
+
({ existingCells, sandbox }) => {
|
|
260
|
+
const onRetry = vi.fn();
|
|
261
|
+
const store = mount({ existingCells, onRetry, canOpenPackages: false });
|
|
262
|
+
act(() => {
|
|
263
|
+
if (!sandbox) {
|
|
264
|
+
store.set(sandboxAtom, null);
|
|
265
|
+
}
|
|
266
|
+
store.set(connectionAtom, {
|
|
267
|
+
state: ConnectionState.CONNECTING,
|
|
268
|
+
phase: "starting-kernel",
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
act(() => vi.advanceTimersByTime(500));
|
|
272
|
+
expect(screen.getByText("Starting kernel")).toBeVisible();
|
|
273
|
+
expect(screen.getByRole("status")).toHaveTextContent("Starting kernel…");
|
|
274
|
+
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
275
|
+
|
|
276
|
+
act(() => {
|
|
277
|
+
store.set(kernelStartupErrorAtom, "Could not import the runtime");
|
|
278
|
+
store.set(connectionAtom, {
|
|
279
|
+
state: ConnectionState.CLOSED,
|
|
280
|
+
phase: "starting-kernel",
|
|
281
|
+
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
282
|
+
reason: "Kernel startup failed",
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
expect(screen.getByLabelText("Error details")).toHaveTextContent(
|
|
286
|
+
"Could not import the runtime",
|
|
287
|
+
);
|
|
288
|
+
expect(screen.getByRole("status")).toHaveTextContent(
|
|
289
|
+
"Kernel failed to start",
|
|
290
|
+
);
|
|
291
|
+
expect(
|
|
292
|
+
screen.queryByRole("button", { name: /Open Packages/ }),
|
|
293
|
+
).not.toBeInTheDocument();
|
|
294
|
+
if (!sandbox) {
|
|
295
|
+
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
|
|
296
|
+
}
|
|
297
|
+
expect(onRetry).toHaveBeenCalledTimes(sandbox ? 0 : 1);
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
|
|
94
301
|
it("does not flash Ready for a startup that finished before the notice appeared", () => {
|
|
95
|
-
const store = mount(true);
|
|
302
|
+
const store = mount({ existingCells: true });
|
|
96
303
|
act(() => vi.advanceTimersByTime(200));
|
|
97
|
-
act(() => store.set(connectionAtom, { state:
|
|
304
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
98
305
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
99
306
|
act(() => vi.advanceTimersByTime(2000));
|
|
100
307
|
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
|
@@ -103,11 +310,11 @@ it("does not flash Ready for a startup that finished before the notice appeared"
|
|
|
103
310
|
it("replaces Ready with a new attempt and never dismisses a failure", () => {
|
|
104
311
|
const store = mount();
|
|
105
312
|
act(() => vi.advanceTimersByTime(500));
|
|
106
|
-
act(() => store.set(connectionAtom, { state:
|
|
313
|
+
act(() => store.set(connectionAtom, { state: ConnectionState.OPEN }));
|
|
107
314
|
act(() => vi.advanceTimersByTime(1000));
|
|
108
315
|
act(() =>
|
|
109
316
|
store.set(connectionAtom, {
|
|
110
|
-
state:
|
|
317
|
+
state: ConnectionState.CONNECTING,
|
|
111
318
|
phase: "starting-kernel",
|
|
112
319
|
}),
|
|
113
320
|
);
|
|
@@ -115,7 +322,7 @@ it("replaces Ready with a new attempt and never dismisses a failure", () => {
|
|
|
115
322
|
expect(screen.getByText("Starting kernel")).toBeInTheDocument();
|
|
116
323
|
act(() =>
|
|
117
324
|
store.set(connectionAtom, {
|
|
118
|
-
state:
|
|
325
|
+
state: ConnectionState.CLOSED,
|
|
119
326
|
code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
|
|
120
327
|
phase: "starting-kernel",
|
|
121
328
|
reason: "Kernel startup failed",
|
|
@@ -128,9 +335,9 @@ it("replaces Ready with a new attempt and never dismisses a failure", () => {
|
|
|
128
335
|
});
|
|
129
336
|
|
|
130
337
|
it("shows a sync as a single operation, without pretending to restart the kernel", () => {
|
|
131
|
-
const store = mount(true);
|
|
338
|
+
const store = mount({ existingCells: true });
|
|
132
339
|
act(() => {
|
|
133
|
-
store.set(connectionAtom, { state:
|
|
340
|
+
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
134
341
|
store.set(sandboxSyncAtom, { kind: "running" });
|
|
135
342
|
});
|
|
136
343
|
act(() => vi.advanceTimersByTime(500));
|
|
@@ -141,7 +348,7 @@ it("shows a sync as a single operation, without pretending to restart the kernel
|
|
|
141
348
|
});
|
|
142
349
|
|
|
143
350
|
it("restores syncing in the existing status and stays quiet for a completed snapshot", () => {
|
|
144
|
-
const store = mount(true);
|
|
351
|
+
const store = mount({ existingCells: true });
|
|
145
352
|
const operation: EnvironmentOperation = {
|
|
146
353
|
operation_id: "sync",
|
|
147
354
|
action: "sync",
|
|
@@ -162,7 +369,7 @@ it("restores syncing in the existing status and stays quiet for a completed snap
|
|
|
162
369
|
},
|
|
163
370
|
}));
|
|
164
371
|
act(() => {
|
|
165
|
-
store.set(connectionAtom, { state:
|
|
372
|
+
store.set(connectionAtom, { state: ConnectionState.OPEN });
|
|
166
373
|
restore({ kind: "succeeded" });
|
|
167
374
|
});
|
|
168
375
|
act(() => vi.advanceTimersByTime(2000));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import type { PropsWithChildren } from "react";
|
|
3
|
+
import type { DisplayConnectionNotice } from "@/core/network/useConnectionNotice";
|
|
4
|
+
import { cn } from "@/utils/cn";
|
|
5
|
+
import {
|
|
6
|
+
ConnectionStatusIcon,
|
|
7
|
+
hasStartupStages,
|
|
8
|
+
StartupProgress,
|
|
9
|
+
} from "./startup-progress";
|
|
10
|
+
|
|
11
|
+
export function ConnectionErrorOutput({ error }: { error: string }) {
|
|
12
|
+
return (
|
|
13
|
+
<pre
|
|
14
|
+
tabIndex={0}
|
|
15
|
+
aria-label="Error details"
|
|
16
|
+
className="mt-3 p-3 rounded border bg-muted/30 text-xs text-muted-foreground whitespace-pre min-w-0 max-w-full max-h-52 overflow-auto"
|
|
17
|
+
>
|
|
18
|
+
{error}
|
|
19
|
+
</pre>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ConnectionDetails({
|
|
24
|
+
notice,
|
|
25
|
+
surface = "sidebar",
|
|
26
|
+
children,
|
|
27
|
+
}: PropsWithChildren<{
|
|
28
|
+
notice: DisplayConnectionNotice;
|
|
29
|
+
surface?: "notebook" | "sidebar";
|
|
30
|
+
}>) {
|
|
31
|
+
const showSteps = hasStartupStages(notice);
|
|
32
|
+
const failed = !notice.pending && !notice.ready;
|
|
33
|
+
return (
|
|
34
|
+
<div className="min-w-0 text-sm">
|
|
35
|
+
{showSteps ? (
|
|
36
|
+
<StartupProgress notice={notice} surface={surface} />
|
|
37
|
+
) : (
|
|
38
|
+
<div className="flex items-center gap-2" role="status">
|
|
39
|
+
<ConnectionStatusIcon notice={notice} />
|
|
40
|
+
<h2
|
|
41
|
+
className={cn(
|
|
42
|
+
surface === "notebook" && "text-base",
|
|
43
|
+
failed ? "font-medium text-(--red-11)" : "text-foreground",
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{notice.title}
|
|
47
|
+
</h2>
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
{(failed || (!showSteps && surface === "notebook")) && (
|
|
51
|
+
<p className={cn("mt-2", failed && "text-foreground")}>
|
|
52
|
+
{notice.description}
|
|
53
|
+
</p>
|
|
54
|
+
)}
|
|
55
|
+
{notice.error && (surface === "sidebar" || !notice.sandbox) && (
|
|
56
|
+
<ConnectionErrorOutput error={notice.error} />
|
|
57
|
+
)}
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import { useAtomValue } from "jotai";
|
|
2
|
+
import { useAtom, useAtomValue } from "jotai";
|
|
3
3
|
import { ArrowRightIcon, ChevronRightIcon } from "lucide-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
@@ -8,25 +8,31 @@ import type { AppConfig } from "@/core/config/config-schema";
|
|
|
8
8
|
import { connectionAtom } from "@/core/network/connection";
|
|
9
9
|
import { useConnectionNotice } from "@/core/network/useConnectionNotice";
|
|
10
10
|
import { sandboxAtom } from "@/core/packages/sandbox-state";
|
|
11
|
-
import {
|
|
11
|
+
import { ConnectionState } from "@/core/websocket/types";
|
|
12
12
|
import { useInterval } from "@/hooks/useInterval";
|
|
13
13
|
import { cn } from "@/utils/cn";
|
|
14
|
-
import {
|
|
14
|
+
import { runtimeDetailsExpandedAtom } from "../chrome/panels/runtime-details-state";
|
|
15
15
|
import { chromeAtom, useChromeActions } from "../chrome/state";
|
|
16
16
|
import { VerticalLayoutWrapper } from "../renderers/vertical-layout/vertical-layout-wrapper";
|
|
17
|
-
import {
|
|
17
|
+
import { ConnectionDetails } from "./connection-details";
|
|
18
|
+
import { ConnectionStatusIcon, hasStartupStages } from "./startup-progress";
|
|
18
19
|
|
|
19
20
|
export const ConnectionNotice = ({
|
|
20
21
|
appConfig,
|
|
21
22
|
onRetry,
|
|
23
|
+
canOpenPackages = true,
|
|
22
24
|
}: {
|
|
23
25
|
appConfig: AppConfig;
|
|
24
26
|
onRetry: () => void;
|
|
27
|
+
canOpenPackages?: boolean;
|
|
25
28
|
}) => {
|
|
26
29
|
const notice = useConnectionNotice();
|
|
27
30
|
const connection = useAtomValue(connectionAtom);
|
|
28
31
|
const hasCells = useAtomValue(hasCellsAtom);
|
|
29
32
|
const sandbox = useAtomValue(sandboxAtom);
|
|
33
|
+
const [detailsExpanded, setDetailsExpanded] = useAtom(
|
|
34
|
+
runtimeDetailsExpandedAtom,
|
|
35
|
+
);
|
|
30
36
|
const chrome = useAtomValue(chromeAtom);
|
|
31
37
|
const packagesOpen =
|
|
32
38
|
(chrome.isSidebarOpen && chrome.selectedPanel === "packages") ||
|
|
@@ -36,48 +42,60 @@ export const ConnectionNotice = ({
|
|
|
36
42
|
|
|
37
43
|
// Reconnection uses the footer indicator while existing cells stay visible.
|
|
38
44
|
const reconnecting =
|
|
39
|
-
connection.state ===
|
|
45
|
+
connection.state === ConnectionState.CONNECTING &&
|
|
40
46
|
connection.phase === "reconnecting";
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
const openDetails = () => {
|
|
49
|
+
setDetailsExpanded(true);
|
|
50
|
+
openApplication("packages");
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
if (!notice && !sandbox?.backend) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (hasCells && canOpenPackages) {
|
|
43
58
|
const title =
|
|
44
59
|
notice?.ready && notice.kind === "startup" ? "Ready" : notice?.title;
|
|
45
60
|
return (
|
|
46
61
|
<VerticalLayoutWrapper
|
|
47
62
|
appConfig={appConfig}
|
|
48
|
-
//
|
|
63
|
+
// Sandboxes retain this row for subsequent environment syncs.
|
|
49
64
|
className="shrink-0 h-12 pb-0 sm:pb-0 print:hidden"
|
|
50
65
|
innerClassName="pb-0 sm:pb-0 pr-4"
|
|
51
66
|
>
|
|
52
67
|
{notice && !reconnecting && (
|
|
53
|
-
<section
|
|
68
|
+
<section
|
|
69
|
+
aria-label="Notebook startup"
|
|
70
|
+
className="flex items-center gap-4"
|
|
71
|
+
>
|
|
54
72
|
<button
|
|
55
73
|
type="button"
|
|
56
|
-
onClick={
|
|
74
|
+
onClick={openDetails}
|
|
57
75
|
aria-label={`${title} Open Packages`}
|
|
58
|
-
aria-expanded={packagesOpen}
|
|
76
|
+
aria-expanded={packagesOpen && detailsExpanded}
|
|
59
77
|
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
78
|
>
|
|
61
79
|
<ConnectionStatusIcon notice={notice} />
|
|
62
80
|
<output>{title}</output>
|
|
63
81
|
<ChevronRightIcon className="size-3" aria-hidden={true} />
|
|
64
82
|
</button>
|
|
83
|
+
{!notice.pending && !notice.ready && !notice.sandbox && (
|
|
84
|
+
<Button variant="text" size="xs" onClick={onRetry}>
|
|
85
|
+
Try again
|
|
86
|
+
</Button>
|
|
87
|
+
)}
|
|
65
88
|
</section>
|
|
66
89
|
)}
|
|
67
90
|
</VerticalLayoutWrapper>
|
|
68
91
|
);
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
if (!notice
|
|
94
|
+
if (!notice) {
|
|
72
95
|
return null;
|
|
73
96
|
}
|
|
74
97
|
|
|
75
|
-
const showSteps =
|
|
76
|
-
notice.sandbox &&
|
|
77
|
-
notice.kind === "startup" &&
|
|
78
|
-
(notice.ready ||
|
|
79
|
-
notice.phase === "preparing-environment" ||
|
|
80
|
-
notice.phase === "starting-kernel");
|
|
98
|
+
const showSteps = hasStartupStages(notice);
|
|
81
99
|
return (
|
|
82
100
|
<VerticalLayoutWrapper
|
|
83
101
|
appConfig={appConfig}
|
|
@@ -87,51 +105,26 @@ export const ConnectionNotice = ({
|
|
|
87
105
|
<section
|
|
88
106
|
aria-label="Notebook startup"
|
|
89
107
|
className={cn(
|
|
90
|
-
"text-muted-foreground",
|
|
91
|
-
hasCells
|
|
92
|
-
? "mt-4 mb-8 border rounded-lg px-5 py-5"
|
|
93
|
-
: "max-w-md mx-auto mt-16 sm:mt-24 px-4",
|
|
94
|
-
hasCells && !notice.pending && "border-(--red-7)",
|
|
108
|
+
"text-muted-foreground max-w-md mx-auto px-4",
|
|
109
|
+
hasCells ? "mt-4 mb-8" : "mt-16 sm:mt-24",
|
|
95
110
|
)}
|
|
96
111
|
>
|
|
97
|
-
{showSteps
|
|
98
|
-
<
|
|
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>
|
|
112
|
+
{!canOpenPackages && showSteps && (
|
|
113
|
+
<output className="sr-only">{notice.title}</output>
|
|
113
114
|
)}
|
|
115
|
+
<ConnectionDetails
|
|
116
|
+
notice={notice}
|
|
117
|
+
surface={canOpenPackages ? "notebook" : "sidebar"}
|
|
118
|
+
/>
|
|
114
119
|
<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>
|
|
124
|
-
)}
|
|
125
|
-
{!notice.sandbox && notice.error && (
|
|
126
|
-
<SandboxErrorOutput error={notice.error} />
|
|
127
|
-
)}
|
|
128
120
|
<div
|
|
129
121
|
className={cn(
|
|
130
122
|
"flex flex-wrap items-center gap-x-6 gap-y-3",
|
|
131
123
|
showSteps ? "border-t border-border/60 pt-4" : "mt-5",
|
|
132
124
|
)}
|
|
133
125
|
>
|
|
134
|
-
{
|
|
126
|
+
{canOpenPackages &&
|
|
127
|
+
!notice.ready &&
|
|
135
128
|
!packagesOpen &&
|
|
136
129
|
(notice.sandbox || notice.pending) && (
|
|
137
130
|
<Button
|
|
@@ -141,7 +134,7 @@ export const ConnectionNotice = ({
|
|
|
141
134
|
"p-0 text-xs gap-1.5",
|
|
142
135
|
showSteps ? "h-6" : "h-auto",
|
|
143
136
|
)}
|
|
144
|
-
onClick={
|
|
137
|
+
onClick={openDetails}
|
|
145
138
|
>
|
|
146
139
|
{notice.pending ? "View setup details" : "Open Packages"}
|
|
147
140
|
<ArrowRightIcon className="size-3" aria-hidden={true} />
|