@marimo-team/islands 0.23.14-dev3 → 0.23.14-dev31
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/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-DXBx_nQg.js} +4 -4
- package/dist/{chat-ui-CsPewo4h.js → chat-ui-CO8WuXGb.js} +3243 -3039
- package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
- package/dist/{code-visibility-BbnxGblD.js → code-visibility-0uqOIXQg.js} +1122 -924
- package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
- package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
- package/dist/{formats-d6MhLuQ9.js → formats-WsOgyW_K.js} +1 -1
- package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-7wxMGXjG.js} +2084 -1889
- package/dist/{html-to-image-DXwLcQ6l.js → html-to-image-DLSOS-bE.js} +2351 -2282
- package/dist/{input-CbEz_aj_.js → input-BSdZp5Ng.js} +1 -1
- package/dist/main.js +1231 -1133
- package/dist/{mermaid-CJW9vIyO.js → mermaid-D-HYBMEV.js} +2 -2
- package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
- package/dist/{process-output-C6_e1pT_.js → process-output-B9ysqk1y.js} +1 -1
- package/dist/{reveal-component-B2onAQFS.js → reveal-component-DxTNjwZw.js} +600 -596
- package/dist/{spec-Bv-XlYiv.js → spec-CnTgI25l.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-D-l5s8nn.js → toDate-D1Z7ZXWh.js} +1 -1
- package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-BMc8itk2.js} +1 -1
- package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-ZwmDBRDY.js} +1 -1
- package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-CxffarYV.js} +1 -1
- package/dist/{useTheme-BrYvK-_A.js → useTheme-yGsGEk82.js} +26 -24
- package/dist/{vega-component-Pk6lyc_a.js → vega-component-BFJTyykA.js} +5 -5
- package/package.json +3 -3
- package/src/components/chat/acp/agent-panel.tsx +35 -1
- package/src/components/chat/chat-panel.tsx +68 -29
- package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
- package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
- package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
- package/src/components/data-table/charts/charts.tsx +23 -1
- package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
- package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
- package/src/components/editor/actions/useNotebookActions.tsx +2 -2
- package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -2
- package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
- package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
- package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
- package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
- package/src/components/editor/code/readonly-python-code.tsx +16 -2
- package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
- package/src/components/editor/errors/auto-fix.tsx +88 -34
- package/src/components/editor/errors/fix-mode.ts +1 -1
- package/src/components/editor/output/MarimoTracebackOutput.tsx +10 -1
- package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
- package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +6 -2
- package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
- package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
- package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
- package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
- package/src/components/slides/__tests__/slide.test.tsx +33 -0
- package/src/components/slides/minimap.tsx +7 -1
- package/src/components/slides/reveal-component.tsx +3 -0
- package/src/components/slides/slide-cell-view.tsx +10 -6
- package/src/components/slides/slide.tsx +16 -13
- package/src/core/ai/config.ts +2 -2
- package/src/core/ai/context/__tests__/registry.test.ts +133 -3
- package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
- package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
- package/src/core/ai/context/providers/datasource.ts +27 -2
- package/src/core/ai/context/providers/error.ts +226 -36
- package/src/core/ai/context/registry.ts +77 -43
- package/src/core/ai/state.ts +11 -0
- package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
- package/src/core/cells/readonly-code-display.ts +35 -0
- package/src/core/codemirror/__tests__/setup.test.ts +33 -1
- package/src/core/codemirror/ai/resources.ts +15 -10
- package/src/core/codemirror/cells/extensions.ts +1 -4
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +94 -0
- package/src/core/codemirror/completion/completer.ts +12 -1
- package/src/core/codemirror/completion/signature-hint.ts +68 -0
- package/src/core/codemirror/language/languages/python.ts +10 -4
- package/src/core/codemirror/utils.ts +15 -0
- package/src/core/edit-app.tsx +8 -5
- package/src/core/errors/errors.ts +2 -1
- package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
- package/src/core/lifecycle/render-policy.ts +125 -0
- package/src/core/mime.ts +11 -4
- package/src/core/run-app.tsx +27 -23
- package/src/core/runtime/__tests__/adapter.test.ts +160 -0
- package/src/core/runtime/adapter.ts +182 -0
- package/src/core/wasm/PyodideLoader.tsx +20 -62
- package/src/core/wasm/bridge.ts +9 -4
- package/src/core/wasm/state.ts +8 -19
- package/src/css/app/codemirror.css +14 -0
- package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
- package/src/hooks/useDelayElapsed.ts +27 -0
- package/src/plugins/impl/DataTablePlugin.tsx +1 -0
- package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +53 -2
- package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +52 -2
- package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
- package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
- package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
- package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
- package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
- package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
- package/src/core/wasm/__tests__/state.test.ts +0 -124
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { atom } from "jotai";
|
|
4
|
+
import { hasCellsAtom, notebookAtom } from "../cells/cells";
|
|
5
|
+
import { isOutputEmpty } from "../cells/outputs";
|
|
6
|
+
import { KnownQueryParams } from "../constants";
|
|
7
|
+
import { showCodeInRunModeAtom } from "../meta/state";
|
|
8
|
+
import { type AppMode, initialModeAtom } from "../mode";
|
|
9
|
+
import { hasQueryParam } from "@/utils/urls";
|
|
10
|
+
|
|
11
|
+
export interface RenderPolicy {
|
|
12
|
+
/** False in `present` mode, and in `read` mode when the user has opted to hide code. */
|
|
13
|
+
showCode: boolean;
|
|
14
|
+
/** True only when there are real, non-empty cached outputs to display. */
|
|
15
|
+
showCachedOutputs: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Whether the current view has anything to paint without waiting on the
|
|
18
|
+
* runtime:
|
|
19
|
+
* - In edit/home/gallery mode, this is `hasCells` — the editor renders the
|
|
20
|
+
* cell skeleton even when output is empty.
|
|
21
|
+
* - In read/present mode with code visible, the cell source is enough.
|
|
22
|
+
* - In headless read mode (code hidden), we need either cached outputs or a
|
|
23
|
+
* settled notebook (all cells idle) — a completed but output-less notebook
|
|
24
|
+
* is still "done" and shouldn't strand on an infinite spinner.
|
|
25
|
+
*/
|
|
26
|
+
canPaint: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCodeVisibility(
|
|
30
|
+
mode: AppMode | undefined,
|
|
31
|
+
showAppCode: boolean,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (mode === "edit" || mode === "home" || mode === "gallery") {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
if (mode === "present") {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
// read mode (or undefined). If `marimo run` stripped the sources
|
|
40
|
+
// (`--include-code` off), there is no code to show regardless of any other
|
|
41
|
+
// signal — mirror `core/meta/code-visibility.ts` so the two don't drift.
|
|
42
|
+
if (hasQueryParam(KnownQueryParams.includeCode, "false")) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// Otherwise the URL `show-code` param wins, then the view config.
|
|
46
|
+
if (hasQueryParam(KnownQueryParams.showCode, "false")) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (hasQueryParam(KnownQueryParams.showCode, "true")) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return showAppCode;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function computeRenderPolicy(input: {
|
|
56
|
+
mode: AppMode | undefined;
|
|
57
|
+
showAppCode: boolean;
|
|
58
|
+
hasCells: boolean;
|
|
59
|
+
hasCachedOutputs: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* True when every cell is idle. A settled notebook is paintable even with no
|
|
62
|
+
* visible outputs — otherwise a completed but output-less notebook viewed in
|
|
63
|
+
* headless read mode would strand on an infinite spinner. Mirrors the
|
|
64
|
+
* pre-framework `hasAnyOutputAtom` idle fallback.
|
|
65
|
+
*/
|
|
66
|
+
allCellsIdle: boolean;
|
|
67
|
+
}): RenderPolicy {
|
|
68
|
+
const showCode = readCodeVisibility(input.mode, input.showAppCode);
|
|
69
|
+
const showCachedOutputs = input.hasCells && input.hasCachedOutputs;
|
|
70
|
+
const canPaint =
|
|
71
|
+
input.hasCells && (showCode || showCachedOutputs || input.allCellsIdle);
|
|
72
|
+
|
|
73
|
+
return { showCode, showCachedOutputs, canPaint };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Both cell-runtime facts the render policy needs, computed in a single pass
|
|
78
|
+
* over `cellRuntime`:
|
|
79
|
+
* - `hasCachedOutputs`: any cell has a non-empty output.
|
|
80
|
+
* - `allCellsIdle`: every cell's status is idle — nothing queued or running.
|
|
81
|
+
* Vacuously true for an empty notebook, so `canPaint` still gates on
|
|
82
|
+
* `hasCells`.
|
|
83
|
+
*
|
|
84
|
+
* Fan out to two boolean selector atoms below so `renderPolicyAtom` only
|
|
85
|
+
* recomputes when one of the booleans actually flips, not on every notebook
|
|
86
|
+
* update.
|
|
87
|
+
*/
|
|
88
|
+
const cellRuntimeSummaryAtom = atom((get) => {
|
|
89
|
+
let hasCachedOutputs = false;
|
|
90
|
+
let allCellsIdle = true;
|
|
91
|
+
for (const runtime of Object.values(get(notebookAtom).cellRuntime)) {
|
|
92
|
+
if (!isOutputEmpty(runtime.output)) {
|
|
93
|
+
hasCachedOutputs = true;
|
|
94
|
+
}
|
|
95
|
+
if (runtime.status !== "idle") {
|
|
96
|
+
allCellsIdle = false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return { hasCachedOutputs, allCellsIdle };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const hasCachedOutputsAtom = atom(
|
|
103
|
+
(get) => get(cellRuntimeSummaryAtom).hasCachedOutputs,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const allCellsIdleAtom = atom(
|
|
107
|
+
(get) => get(cellRuntimeSummaryAtom).allCellsIdle,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
export const renderPolicyAtom = atom<RenderPolicy>((get) =>
|
|
111
|
+
computeRenderPolicy({
|
|
112
|
+
mode: get(initialModeAtom),
|
|
113
|
+
showAppCode: get(showCodeInRunModeAtom),
|
|
114
|
+
hasCells: get(hasCellsAtom),
|
|
115
|
+
hasCachedOutputs: get(hasCachedOutputsAtom),
|
|
116
|
+
allCellsIdle: get(allCellsIdleAtom),
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
/** Sugar over `renderPolicyAtom.canPaint`. */
|
|
121
|
+
export const canPaintAtom = atom((get) => get(renderPolicyAtom).canPaint);
|
|
122
|
+
|
|
123
|
+
export const visibleForTesting = {
|
|
124
|
+
computeRenderPolicy,
|
|
125
|
+
};
|
package/src/core/mime.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
import type { OutputMessage } from "./kernel/messages";
|
|
3
3
|
|
|
4
|
+
export function isMarimoErrorsMime(
|
|
5
|
+
mime: OutputMessage["mimetype"] | undefined,
|
|
6
|
+
) {
|
|
7
|
+
return mime === "application/vnd.marimo+error";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function isTracebackMime(mime: OutputMessage["mimetype"] | undefined) {
|
|
11
|
+
return mime === "application/vnd.marimo+traceback";
|
|
12
|
+
}
|
|
13
|
+
|
|
4
14
|
export function isErrorMime(mime: OutputMessage["mimetype"] | undefined) {
|
|
5
|
-
return (
|
|
6
|
-
mime === "application/vnd.marimo+error" ||
|
|
7
|
-
mime === "application/vnd.marimo+traceback"
|
|
8
|
-
);
|
|
15
|
+
return isMarimoErrorsMime(mime) || isTracebackMime(mime);
|
|
9
16
|
}
|
package/src/core/run-app.tsx
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { useAtomValue } from "jotai";
|
|
3
|
+
import { atom, useAtomValue } from "jotai";
|
|
4
4
|
import { ArrowLeftIcon } from "lucide-react";
|
|
5
5
|
import { useEffect } from "react";
|
|
6
6
|
import { AppContainer } from "@/components/editor/app-container";
|
|
7
7
|
import { AppHeader } from "@/components/editor/header/app-header";
|
|
8
|
+
import { ProgressiveBoundary } from "@/components/lifecycle/ProgressiveBoundary";
|
|
8
9
|
import { Spinner } from "@/components/icons/spinner";
|
|
9
10
|
import { buttonVariants } from "@/components/ui/button";
|
|
10
|
-
import { DelayMount } from "@/components/utils/delay-mount";
|
|
11
11
|
import { cn } from "@/utils/cn";
|
|
12
12
|
import { CellsRenderer } from "../components/editor/renderers/cells-renderer";
|
|
13
13
|
import {
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type { AppConfig } from "./config/config-schema";
|
|
19
19
|
import { RuntimeState } from "./kernel/RuntimeState";
|
|
20
20
|
import { getSessionId } from "./kernel/session";
|
|
21
|
+
import { connectionAtom } from "./network/connection";
|
|
21
22
|
import { useRequestClient } from "./network/requests";
|
|
22
23
|
import { isAppConnecting } from "./websocket/connection-utils";
|
|
23
24
|
import { useMarimoKernelConnection } from "./websocket/useMarimoKernelConnection";
|
|
@@ -26,6 +27,16 @@ interface AppProps {
|
|
|
26
27
|
appConfig: AppConfig;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Paint the (possibly empty) app once we have cells, or as soon as we are no
|
|
32
|
+
* longer actively connecting. Without the connection check, a connected or
|
|
33
|
+
* disconnected notebook with no cells would sit on a misleading "Connecting…"
|
|
34
|
+
* spinner instead of rendering the empty app.
|
|
35
|
+
*/
|
|
36
|
+
const canPaintRunAppAtom = atom(
|
|
37
|
+
(get) => get(hasCellsAtom) || !isAppConnecting(get(connectionAtom).state),
|
|
38
|
+
);
|
|
39
|
+
|
|
29
40
|
export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
30
41
|
const { setCells } = useCellActions();
|
|
31
42
|
const { sendComponentValues } = useRequestClient();
|
|
@@ -45,26 +56,6 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
|
45
56
|
});
|
|
46
57
|
|
|
47
58
|
const isRunning = useAtomValue(notebookIsRunningAtom);
|
|
48
|
-
const isConnecting = isAppConnecting(connection.state);
|
|
49
|
-
// Skip the "Connecting..." gate when we already have cells to show — from
|
|
50
|
-
// an embedded snapshot or a prior connection.
|
|
51
|
-
const hasExistingCells = useAtomValue(hasCellsAtom);
|
|
52
|
-
|
|
53
|
-
const renderCells = () => {
|
|
54
|
-
// If we are connecting for more than 2 seconds, show a spinner
|
|
55
|
-
if (isConnecting && !hasExistingCells) {
|
|
56
|
-
return (
|
|
57
|
-
<DelayMount milliseconds={2000} fallback={null}>
|
|
58
|
-
<Spinner className="mx-auto" />
|
|
59
|
-
<p className="text-center text-sm text-muted-foreground mt-2">
|
|
60
|
-
Connecting...
|
|
61
|
-
</p>
|
|
62
|
-
</DelayMount>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
return <CellsRenderer appConfig={appConfig} mode="read" />;
|
|
67
|
-
};
|
|
68
59
|
|
|
69
60
|
const galleryHref = (() => {
|
|
70
61
|
if (typeof window === "undefined") {
|
|
@@ -103,7 +94,20 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
|
|
|
103
94
|
</div>
|
|
104
95
|
)}
|
|
105
96
|
</AppHeader>
|
|
106
|
-
|
|
97
|
+
<ProgressiveBoundary
|
|
98
|
+
requires={canPaintRunAppAtom}
|
|
99
|
+
delay={2000}
|
|
100
|
+
fallback={
|
|
101
|
+
<>
|
|
102
|
+
<Spinner className="mx-auto" />
|
|
103
|
+
<p className="text-center text-sm text-muted-foreground mt-2">
|
|
104
|
+
Connecting...
|
|
105
|
+
</p>
|
|
106
|
+
</>
|
|
107
|
+
}
|
|
108
|
+
>
|
|
109
|
+
<CellsRenderer appConfig={appConfig} mode="read" />
|
|
110
|
+
</ProgressiveBoundary>
|
|
107
111
|
</AppContainer>
|
|
108
112
|
);
|
|
109
113
|
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createStore } from "jotai";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { islandsInitializedAtom } from "../../islands/state";
|
|
6
|
+
import { kernelStateAtom } from "../../kernel/state";
|
|
7
|
+
import { connectionAtom } from "../../network/connection";
|
|
8
|
+
import { wasmInitStateAtom } from "../../wasm/state";
|
|
9
|
+
import { WebSocketState } from "../../websocket/types";
|
|
10
|
+
import {
|
|
11
|
+
canRunCellsAtom,
|
|
12
|
+
islandsAdapter,
|
|
13
|
+
remoteAdapter,
|
|
14
|
+
runtimeAdapterAtom,
|
|
15
|
+
staticAdapter,
|
|
16
|
+
wasmAdapter,
|
|
17
|
+
} from "../adapter";
|
|
18
|
+
|
|
19
|
+
describe("wasmAdapter", () => {
|
|
20
|
+
it("reports connecting with the progress label while loading", () => {
|
|
21
|
+
const store = createStore();
|
|
22
|
+
store.set(wasmInitStateAtom, {
|
|
23
|
+
kind: "loading",
|
|
24
|
+
message: "Loading Pyodide…",
|
|
25
|
+
});
|
|
26
|
+
expect(store.get(wasmAdapter.state)).toEqual({
|
|
27
|
+
kind: "connecting",
|
|
28
|
+
progress: { label: "Loading Pyodide…" },
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("reports ready when initialized", () => {
|
|
33
|
+
const store = createStore();
|
|
34
|
+
store.set(wasmInitStateAtom, { kind: "ready" });
|
|
35
|
+
expect(store.get(wasmAdapter.state)).toEqual({ kind: "ready" });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("exposes the real error message on failure", () => {
|
|
39
|
+
const store = createStore();
|
|
40
|
+
store.set(wasmInitStateAtom, {
|
|
41
|
+
kind: "error",
|
|
42
|
+
message: "ImportError: numpy",
|
|
43
|
+
});
|
|
44
|
+
expect(store.get(wasmAdapter.state)).toEqual({
|
|
45
|
+
kind: "failed",
|
|
46
|
+
error: { message: "ImportError: numpy", errorKind: "init" },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("remoteAdapter", () => {
|
|
52
|
+
it.each([
|
|
53
|
+
[{ state: WebSocketState.OPEN }, { kind: "ready" }],
|
|
54
|
+
[
|
|
55
|
+
{ state: WebSocketState.CONNECTING },
|
|
56
|
+
{ kind: "connecting", progress: { label: "Connecting…" } },
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
{ state: WebSocketState.NOT_STARTED },
|
|
60
|
+
{ kind: "connecting", progress: { label: "Not connected" } },
|
|
61
|
+
],
|
|
62
|
+
] as const)("maps %j to %j", (conn, expected) => {
|
|
63
|
+
const store = createStore();
|
|
64
|
+
store.set(connectionAtom, conn as never);
|
|
65
|
+
expect(store.get(remoteAdapter.state)).toEqual(expected);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("surfaces the close reason when closed", () => {
|
|
69
|
+
const store = createStore();
|
|
70
|
+
store.set(connectionAtom, {
|
|
71
|
+
state: WebSocketState.CLOSED,
|
|
72
|
+
code: "KERNEL_DISCONNECTED",
|
|
73
|
+
reason: "Kernel went away",
|
|
74
|
+
});
|
|
75
|
+
expect(store.get(remoteAdapter.state)).toEqual({
|
|
76
|
+
kind: "failed",
|
|
77
|
+
error: { message: "Kernel went away", errorKind: "runtime" },
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("staticAdapter", () => {
|
|
83
|
+
it("is always ready", () => {
|
|
84
|
+
const store = createStore();
|
|
85
|
+
expect(store.get(staticAdapter.state)).toEqual({ kind: "ready" });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("advertises no capabilities", () => {
|
|
89
|
+
expect(staticAdapter.capabilities).toEqual({
|
|
90
|
+
canHealthCheck: false,
|
|
91
|
+
canShutdown: false,
|
|
92
|
+
canRestart: false,
|
|
93
|
+
supportsLsp: false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("islandsAdapter", () => {
|
|
99
|
+
it("is connecting before islands report initialization", () => {
|
|
100
|
+
const store = createStore();
|
|
101
|
+
store.set(islandsInitializedAtom, false);
|
|
102
|
+
expect(store.get(islandsAdapter.state)).toEqual({
|
|
103
|
+
kind: "connecting",
|
|
104
|
+
progress: { label: "Initializing islands…" },
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("is ready once islands initialize", () => {
|
|
109
|
+
const store = createStore();
|
|
110
|
+
store.set(islandsInitializedAtom, true);
|
|
111
|
+
expect(store.get(islandsAdapter.state)).toEqual({ kind: "ready" });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("surfaces the error string when islands fail", () => {
|
|
115
|
+
const store = createStore();
|
|
116
|
+
store.set(islandsInitializedAtom, "wheel download failed");
|
|
117
|
+
expect(store.get(islandsAdapter.state)).toEqual({
|
|
118
|
+
kind: "failed",
|
|
119
|
+
error: { message: "wheel download failed", errorKind: "init" },
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("canRunCellsAtom", () => {
|
|
125
|
+
it("is false on a remote adapter until kernel acks", () => {
|
|
126
|
+
const store = createStore();
|
|
127
|
+
// Default adapter in test env is remote.
|
|
128
|
+
store.set(connectionAtom, { state: WebSocketState.OPEN });
|
|
129
|
+
expect(store.get(canRunCellsAtom)).toBe(false);
|
|
130
|
+
|
|
131
|
+
store.set(kernelStateAtom, { isInstantiated: true, error: null });
|
|
132
|
+
expect(store.get(canRunCellsAtom)).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is true on WASM as soon as the adapter is ready (no separate kernel ack)", () => {
|
|
136
|
+
const store = createStore();
|
|
137
|
+
store.set(runtimeAdapterAtom, wasmAdapter);
|
|
138
|
+
store.set(wasmInitStateAtom, { kind: "loading", message: "…" });
|
|
139
|
+
expect(store.get(canRunCellsAtom)).toBe(false);
|
|
140
|
+
|
|
141
|
+
store.set(wasmInitStateAtom, { kind: "ready" });
|
|
142
|
+
expect(store.get(canRunCellsAtom)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("is always false on a static notebook", () => {
|
|
146
|
+
const store = createStore();
|
|
147
|
+
store.set(runtimeAdapterAtom, staticAdapter);
|
|
148
|
+
expect(store.get(canRunCellsAtom)).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("is true on islands once initialized", () => {
|
|
152
|
+
const store = createStore();
|
|
153
|
+
store.set(runtimeAdapterAtom, islandsAdapter);
|
|
154
|
+
store.set(islandsInitializedAtom, false);
|
|
155
|
+
expect(store.get(canRunCellsAtom)).toBe(false);
|
|
156
|
+
|
|
157
|
+
store.set(islandsInitializedAtom, true);
|
|
158
|
+
expect(store.get(canRunCellsAtom)).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { type Atom, atom } from "jotai";
|
|
4
|
+
import { kernelStateAtom } from "../kernel/state";
|
|
5
|
+
import { islandsInitializedAtom } from "../islands/state";
|
|
6
|
+
import { isIslands } from "../islands/utils";
|
|
7
|
+
import { connectionAtom } from "../network/connection";
|
|
8
|
+
import { isStaticNotebook } from "../static/static-state";
|
|
9
|
+
import { wasmInitStateAtom } from "../wasm/state";
|
|
10
|
+
import { isWasm } from "../wasm/utils";
|
|
11
|
+
import { WebSocketState } from "../websocket/types";
|
|
12
|
+
|
|
13
|
+
export type RuntimeKind = "wasm" | "remote" | "static" | "islands";
|
|
14
|
+
|
|
15
|
+
export type AdapterState =
|
|
16
|
+
| { kind: "connecting"; progress?: { label: string; percent?: number } }
|
|
17
|
+
| { kind: "ready" }
|
|
18
|
+
| {
|
|
19
|
+
kind: "failed";
|
|
20
|
+
error: { message: string; errorKind: "init" | "runtime" };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface RuntimeCapabilities {
|
|
24
|
+
canHealthCheck: boolean;
|
|
25
|
+
canShutdown: boolean;
|
|
26
|
+
canRestart: boolean;
|
|
27
|
+
supportsLsp: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Runtimes with no server-side kernel to manage (wasm, static, islands). */
|
|
31
|
+
const NO_CAPABILITIES: RuntimeCapabilities = {
|
|
32
|
+
canHealthCheck: false,
|
|
33
|
+
canShutdown: false,
|
|
34
|
+
canRestart: false,
|
|
35
|
+
supportsLsp: false,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One adapter per runtime so consumers don't branch on `isWasm()` /
|
|
40
|
+
* `isStaticNotebook()` / `isIslands()` at the call site.
|
|
41
|
+
*/
|
|
42
|
+
export interface RuntimeAdapter {
|
|
43
|
+
readonly kind: RuntimeKind;
|
|
44
|
+
/** Short label suitable for status pills, e.g. "Kernel", "Pyodide". */
|
|
45
|
+
readonly label: string;
|
|
46
|
+
readonly capabilities: RuntimeCapabilities;
|
|
47
|
+
state: Atom<AdapterState>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- WASM ------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const wasmStateAtom = atom<AdapterState>((get) => {
|
|
53
|
+
const s = get(wasmInitStateAtom);
|
|
54
|
+
switch (s.kind) {
|
|
55
|
+
case "loading":
|
|
56
|
+
return { kind: "connecting", progress: { label: s.message } };
|
|
57
|
+
case "ready":
|
|
58
|
+
return { kind: "ready" };
|
|
59
|
+
case "error":
|
|
60
|
+
return {
|
|
61
|
+
kind: "failed",
|
|
62
|
+
error: { message: s.message, errorKind: "init" },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export const wasmAdapter: RuntimeAdapter = {
|
|
68
|
+
kind: "wasm",
|
|
69
|
+
label: "Pyodide",
|
|
70
|
+
capabilities: NO_CAPABILITIES,
|
|
71
|
+
state: wasmStateAtom,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// --- Remote ----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
const remoteStateAtom = atom<AdapterState>((get) => {
|
|
77
|
+
const conn = get(connectionAtom);
|
|
78
|
+
switch (conn.state) {
|
|
79
|
+
case WebSocketState.OPEN:
|
|
80
|
+
return { kind: "ready" };
|
|
81
|
+
case WebSocketState.CONNECTING:
|
|
82
|
+
return { kind: "connecting", progress: { label: "Connecting…" } };
|
|
83
|
+
case WebSocketState.NOT_STARTED:
|
|
84
|
+
return { kind: "connecting", progress: { label: "Not connected" } };
|
|
85
|
+
case WebSocketState.CLOSING:
|
|
86
|
+
case WebSocketState.CLOSED:
|
|
87
|
+
return {
|
|
88
|
+
kind: "failed",
|
|
89
|
+
error: {
|
|
90
|
+
message:
|
|
91
|
+
conn.state === WebSocketState.CLOSED
|
|
92
|
+
? conn.reason
|
|
93
|
+
: "Disconnecting",
|
|
94
|
+
errorKind: "runtime",
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const remoteAdapter: RuntimeAdapter = {
|
|
101
|
+
kind: "remote",
|
|
102
|
+
label: "Kernel",
|
|
103
|
+
capabilities: {
|
|
104
|
+
canHealthCheck: true,
|
|
105
|
+
canShutdown: true,
|
|
106
|
+
canRestart: true,
|
|
107
|
+
supportsLsp: true,
|
|
108
|
+
},
|
|
109
|
+
state: remoteStateAtom,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
// --- Static ----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const staticStateAtom = atom<AdapterState>({ kind: "ready" });
|
|
115
|
+
|
|
116
|
+
export const staticAdapter: RuntimeAdapter = {
|
|
117
|
+
kind: "static",
|
|
118
|
+
label: "Static",
|
|
119
|
+
capabilities: NO_CAPABILITIES,
|
|
120
|
+
state: staticStateAtom,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// --- Islands ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
const islandsStateAtom = atom<AdapterState>((get) => {
|
|
126
|
+
const status = get(islandsInitializedAtom);
|
|
127
|
+
if (status === true) {
|
|
128
|
+
return { kind: "ready" };
|
|
129
|
+
}
|
|
130
|
+
if (typeof status === "string") {
|
|
131
|
+
return {
|
|
132
|
+
kind: "failed",
|
|
133
|
+
error: { message: status, errorKind: "init" },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
kind: "connecting",
|
|
138
|
+
progress: { label: "Initializing islands…" },
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
export const islandsAdapter: RuntimeAdapter = {
|
|
143
|
+
kind: "islands",
|
|
144
|
+
label: "Islands",
|
|
145
|
+
capabilities: NO_CAPABILITIES,
|
|
146
|
+
state: islandsStateAtom,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function selectAdapter(): RuntimeAdapter {
|
|
150
|
+
if (isStaticNotebook()) {
|
|
151
|
+
return staticAdapter;
|
|
152
|
+
}
|
|
153
|
+
if (isIslands()) {
|
|
154
|
+
return islandsAdapter;
|
|
155
|
+
}
|
|
156
|
+
if (isWasm()) {
|
|
157
|
+
return wasmAdapter;
|
|
158
|
+
}
|
|
159
|
+
return remoteAdapter;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Picked once at module load; the choice doesn't change after page load. */
|
|
163
|
+
export const runtimeAdapterAtom = atom<RuntimeAdapter>(selectAdapter());
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Remote runtimes require both the WS to be open *and* the kernel to have
|
|
167
|
+
* ack'd instantiation. WASM/islands have no separate kernel handshake.
|
|
168
|
+
* Static notebooks never run cells.
|
|
169
|
+
*/
|
|
170
|
+
export const canRunCellsAtom = atom<boolean>((get) => {
|
|
171
|
+
const adapter = get(runtimeAdapterAtom);
|
|
172
|
+
if (get(adapter.state).kind !== "ready") {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (adapter.kind === "static") {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
if (adapter.kind === "remote") {
|
|
179
|
+
return get(kernelStateAtom).isInstantiated;
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
});
|