@marimo-team/islands 0.24.3-dev82 → 0.24.3-dev83
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ErrorBoundary-CegtSjfd.js → ErrorBoundary-Uf_LvjvY.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-DxgQWu1j.js} +2 -2
- package/dist/{common-BQZDWy8O.js → common-ByAtncLr.js} +3 -3
- package/dist/{data-grid-overlay-editor-DsU0Zpgt.js → data-grid-overlay-editor-DL_blFyL.js} +1 -1
- package/dist/{error-entries-CR0oClSL.js → error-entries-C0NsUqXl.js} +1 -1
- package/dist/{feedback-button-pGaDcCJS.js → feedback-button-AZcBqfoV.js} +4 -4
- package/dist/{glide-data-editor-DtJQCRsf.js → glide-data-editor-BJdZncJk.js} +3 -3
- package/dist/{globals-DBAC4Ihd.js → globals-yWarpyN4.js} +1 -1
- package/dist/{html-to-image-_i03yR4q.js → html-to-image-CeYYvQNb.js} +1 -1
- package/dist/main.js +12 -11
- package/dist/{number-overlay-editor-9M7FnzxL.js → number-overlay-editor-BatBVYxr.js} +1 -1
- package/dist/{process-output-D3zQyTAS.js → process-output-BCTjlyuH.js} +2 -2
- package/dist/{reveal-component-ovaRyk0R.js → reveal-component-D02jcpiJ.js} +3 -3
- package/dist/style.css +1 -1
- package/package.json +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/stories/sandbox.stories.tsx +230 -50
package/package.json
CHANGED
|
@@ -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>
|