@marimo-team/islands 0.23.14-dev5 → 0.23.14-dev50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/dist/{ConnectedDataExplorerComponent-Du3_nUzI.js → ConnectedDataExplorerComponent-DXBx_nQg.js} +4 -4
  2. package/dist/{chat-ui-BZxLHwyD.js → chat-ui-DXPRhRO2.js} +3248 -3044
  3. package/dist/{click-outside-container-BDd67_1U.js → click-outside-container-BLPjMamz.js} +141 -113
  4. package/dist/{code-visibility-rxZi4Phe.js → code-visibility-BFDT-GEQ.js} +1153 -946
  5. package/dist/data-grid-overlay-editor-C6lxUJp-.js +136 -0
  6. package/dist/{dist-D_bzzWBm.js → dist-Bf9f8MuT.js} +3 -3
  7. package/dist/{formats-d6MhLuQ9.js → formats-WsOgyW_K.js} +1 -1
  8. package/dist/{glide-data-editor-DkzAInWG.js → glide-data-editor-7wxMGXjG.js} +2084 -1889
  9. package/dist/{html-to-image-CGp_08St.js → html-to-image-DOqQBSQc.js} +2367 -2301
  10. package/dist/{input-CbEz_aj_.js → input-BSdZp5Ng.js} +1 -1
  11. package/dist/main.js +2707 -1186
  12. package/dist/{mermaid-CJW9vIyO.js → mermaid-D-HYBMEV.js} +2 -2
  13. package/dist/{number-overlay-editor-D-a0qCT8.js → number-overlay-editor-BLJXvX9c.js} +1 -1
  14. package/dist/{process-output-R6JsYrv3.js → process-output-kGk2Jc2x.js} +1 -1
  15. package/dist/{reveal-component-sEb3Wd1x.js → reveal-component-uSCMaaUE.js} +600 -596
  16. package/dist/{spec-Bv-XlYiv.js → spec-CnTgI25l.js} +1 -1
  17. package/dist/style.css +1 -1
  18. package/dist/{toDate-D-l5s8nn.js → toDate-D1Z7ZXWh.js} +1 -1
  19. package/dist/{useAsyncData-1Dhzjfwf.js → useAsyncData-BMc8itk2.js} +1 -1
  20. package/dist/{useDeepCompareMemoize-CDWT3BDz.js → useDeepCompareMemoize-ZwmDBRDY.js} +1 -1
  21. package/dist/{useLifecycle-AHlswLw-.js → useLifecycle-CxffarYV.js} +1 -1
  22. package/dist/{useTheme-BrYvK-_A.js → useTheme-yGsGEk82.js} +26 -24
  23. package/dist/{vega-component-Pk6lyc_a.js → vega-component-BFJTyykA.js} +5 -5
  24. package/package.json +3 -3
  25. package/src/__mocks__/requests.ts +1 -0
  26. package/src/components/app-config/user-config-form.tsx +26 -0
  27. package/src/components/chat/acp/agent-panel.tsx +35 -1
  28. package/src/components/chat/chat-panel.tsx +68 -29
  29. package/src/components/data-table/__tests__/column-explorer.test.tsx +25 -0
  30. package/src/components/data-table/__tests__/column-visibility-dropdown.test.tsx +60 -3
  31. package/src/components/data-table/charts/__tests__/altair-generator.test.ts +24 -0
  32. package/src/components/data-table/charts/__tests__/merge-index-fields.test.ts +33 -0
  33. package/src/components/data-table/charts/chart-spec/altair-generator.ts +4 -1
  34. package/src/components/data-table/charts/charts.tsx +23 -1
  35. package/src/components/data-table/column-explorer-panel/column-explorer.tsx +33 -12
  36. package/src/components/data-table/column-visibility-dropdown.tsx +15 -0
  37. package/src/components/debugger/debugger-code.tsx +7 -2
  38. package/src/components/editor/actions/useNotebookActions.tsx +2 -2
  39. package/src/components/editor/cell/cell-context-menu.tsx +2 -2
  40. package/src/components/editor/chrome/panels/snippets-panel.tsx +3 -3
  41. package/src/components/editor/chrome/wrapper/__tests__/useOpenAiAssistant.test.ts +36 -0
  42. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +6 -36
  43. package/src/components/editor/chrome/wrapper/useAiPanel.ts +3 -1
  44. package/src/components/editor/chrome/wrapper/useOpenAiAssistant.ts +88 -0
  45. package/src/components/editor/code/__tests__/readonly-python-code.test.tsx +79 -0
  46. package/src/components/editor/code/readonly-python-code.tsx +60 -33
  47. package/src/components/editor/errors/__tests__/auto-fix.test.ts +23 -0
  48. package/src/components/editor/errors/auto-fix.tsx +88 -34
  49. package/src/components/editor/errors/fix-mode.ts +1 -1
  50. package/src/components/editor/notebook-cell.tsx +7 -0
  51. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -2
  52. package/src/components/editor/output/__tests__/traceback.test.tsx +14 -6
  53. package/src/components/editor/renderers/grid-layout/grid-layout.tsx +7 -2
  54. package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +7 -3
  55. package/src/components/lifecycle/ProgressiveBoundary.tsx +42 -0
  56. package/src/components/lifecycle/RuntimeStatusBadge.tsx +59 -0
  57. package/src/components/lifecycle/__tests__/ProgressiveBoundary.test.tsx +147 -0
  58. package/src/components/lifecycle/__tests__/RuntimeStatusBadge.test.tsx +72 -0
  59. package/src/components/slides/__tests__/slide.test.tsx +33 -0
  60. package/src/components/slides/minimap.tsx +7 -1
  61. package/src/components/slides/reveal-component.tsx +3 -0
  62. package/src/components/slides/slide-cell-view.tsx +10 -6
  63. package/src/components/slides/slide.tsx +16 -13
  64. package/src/core/ai/config.ts +2 -2
  65. package/src/core/ai/context/__tests__/registry.test.ts +133 -3
  66. package/src/core/ai/context/providers/__tests__/datasource.test.ts +3 -1
  67. package/src/core/ai/context/providers/__tests__/error.test.ts +200 -15
  68. package/src/core/ai/context/providers/datasource.ts +27 -2
  69. package/src/core/ai/context/providers/error.ts +226 -36
  70. package/src/core/ai/context/registry.ts +77 -43
  71. package/src/core/ai/state.ts +11 -0
  72. package/src/core/cells/__tests__/cell.test.ts +39 -0
  73. package/src/core/cells/__tests__/readonly-code-display.test.ts +46 -0
  74. package/src/core/cells/cell.ts +5 -3
  75. package/src/core/cells/readonly-code-display.ts +35 -0
  76. package/src/core/codemirror/__tests__/setup.test.ts +33 -1
  77. package/src/core/codemirror/ai/resources.ts +15 -10
  78. package/src/core/codemirror/cells/__tests__/debugger-decorations.test.ts +185 -0
  79. package/src/core/codemirror/cells/__tests__/debugger-state.test.ts +139 -0
  80. package/src/core/codemirror/cells/debugger-decorations.ts +188 -0
  81. package/src/core/codemirror/cells/debugger-state.ts +100 -0
  82. package/src/core/codemirror/cells/extensions.ts +23 -4
  83. package/src/core/codemirror/go-to-definition/__tests__/utils.test.ts +75 -3
  84. package/src/core/codemirror/go-to-definition/extension.ts +3 -3
  85. package/src/core/codemirror/go-to-definition/underline.ts +5 -14
  86. package/src/core/codemirror/go-to-definition/utils.ts +49 -1
  87. package/src/core/codemirror/utils.ts +15 -0
  88. package/src/core/config/feature-flag.tsx +2 -0
  89. package/src/core/edit-app.tsx +8 -5
  90. package/src/core/errors/errors.ts +2 -1
  91. package/src/core/islands/bootstrap.ts +1 -0
  92. package/src/core/islands/bridge.ts +1 -0
  93. package/src/core/lifecycle/__tests__/render-policy.test.ts +247 -0
  94. package/src/core/lifecycle/render-policy.ts +125 -0
  95. package/src/core/mime.ts +11 -4
  96. package/src/core/network/__tests__/api.test.ts +17 -0
  97. package/src/core/network/__tests__/requests-toasting.test.tsx +46 -0
  98. package/src/core/network/api.ts +17 -6
  99. package/src/core/network/requests-lazy.ts +1 -0
  100. package/src/core/network/requests-network.ts +8 -0
  101. package/src/core/network/requests-static.ts +1 -0
  102. package/src/core/network/requests-toasting.tsx +10 -1
  103. package/src/core/network/types.ts +2 -0
  104. package/src/core/run-app.tsx +27 -23
  105. package/src/core/runtime/__tests__/adapter.test.ts +160 -0
  106. package/src/core/runtime/adapter.ts +182 -0
  107. package/src/core/wasm/PyodideLoader.tsx +20 -62
  108. package/src/core/wasm/bridge.ts +13 -4
  109. package/src/core/wasm/state.ts +8 -19
  110. package/src/core/websocket/useMarimoKernelConnection.tsx +17 -0
  111. package/src/css/app/codemirror.css +14 -0
  112. package/src/hooks/__tests__/useDelayElapsed.test.tsx +55 -0
  113. package/src/hooks/useDelayElapsed.ts +27 -0
  114. package/src/plugins/core/RenderHTML.tsx +35 -1
  115. package/src/plugins/core/__test__/RenderHTML.test.ts +44 -0
  116. package/src/plugins/impl/DataTablePlugin.tsx +1 -0
  117. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +53 -2
  118. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +52 -2
  119. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +187 -0
  120. package/src/plugins/impl/data-editor/glide-data-editor.tsx +6 -0
  121. package/src/plugins/impl/data-editor/glide-portal.tsx +73 -0
  122. package/src/utils/errors.ts +15 -0
  123. package/dist/data-grid-overlay-editor-mfEJ5475.js +0 -128
  124. package/src/components/editor/chrome/panels/__tests__/snippet-display.test.ts +0 -22
  125. package/src/components/editor/chrome/panels/snippet-display.ts +0 -27
  126. package/src/core/ai/context/providers/__tests__/__snapshots__/error.test.ts.snap +0 -3
  127. package/src/core/wasm/__tests__/PyodideLoader.test.ts +0 -72
  128. package/src/core/wasm/__tests__/state.test.ts +0 -124
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { createMarimoClient } from "@marimo-team/marimo-api";
4
+ import { HTTPError } from "../../utils/errors";
4
5
  import { Logger } from "../../utils/Logger";
5
6
  import { Strings } from "../../utils/strings";
6
7
  import { getRuntimeManager } from "../runtime/config";
@@ -50,7 +51,7 @@ export const API = {
50
51
  const errorBody = isJson
51
52
  ? await response.json()
52
53
  : await response.text();
53
- throw new Error(response.statusText, { cause: errorBody });
54
+ throw new HTTPError(response.status, response.statusText, errorBody);
54
55
  }
55
56
  if (isJson) {
56
57
  return response.json() as RESP;
@@ -83,7 +84,7 @@ export const API = {
83
84
  })
84
85
  .then((response) => {
85
86
  if (!response.ok) {
86
- throw new Error(response.statusText);
87
+ throw new HTTPError(response.status, response.statusText);
87
88
  }
88
89
  if (
89
90
  response.headers.get("Content-Type")?.startsWith("application/json")
@@ -108,8 +109,13 @@ export const API = {
108
109
  response: Response;
109
110
  }): Promise<T> => {
110
111
  if (response.error) {
111
- // oxlint-disable-next-line typescript/prefer-promise-reject-errors
112
- return Promise.reject(response.error);
112
+ return Promise.reject(
113
+ new HTTPError(
114
+ response.response.status,
115
+ response.response.statusText,
116
+ response.error,
117
+ ),
118
+ );
113
119
  }
114
120
  return Promise.resolve(response.data as T);
115
121
  },
@@ -118,8 +124,13 @@ export const API = {
118
124
  response: Response;
119
125
  }): Promise<null> => {
120
126
  if (response.error) {
121
- // oxlint-disable-next-line typescript/prefer-promise-reject-errors
122
- return Promise.reject(response.error);
127
+ return Promise.reject(
128
+ new HTTPError(
129
+ response.response.status,
130
+ response.response.statusText,
131
+ response.error,
132
+ ),
133
+ );
123
134
  }
124
135
  return Promise.resolve(null);
125
136
  },
@@ -108,6 +108,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
108
108
  sendStdin: "waitForConnectionOpen",
109
109
  sendInterrupt: "waitForConnectionOpen",
110
110
  sendPdb: "waitForConnectionOpen",
111
+ sendSetBreakpoints: "waitForConnectionOpen",
111
112
  sendInstallMissingPackages: "waitForConnectionOpen",
112
113
  readSnippets: "waitForConnectionOpen",
113
114
  previewDatasetColumn: "waitForConnectionOpen",
@@ -293,6 +293,14 @@ export function createNetworkRequests(): EditRequests & RunRequests {
293
293
  })
294
294
  .then(handleResponseReturnNull);
295
295
  },
296
+ sendSetBreakpoints: (request) => {
297
+ return getClient()
298
+ .POST("/api/kernel/pdb/breakpoints", {
299
+ body: request,
300
+ params: getParams(),
301
+ })
302
+ .then(handleResponseReturnNull);
303
+ },
296
304
  sendListFiles: async (request) => {
297
305
  await waitForConnectionOpen();
298
306
  return getClient()
@@ -68,6 +68,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
68
68
  sendListFiles: throwNotInEditMode,
69
69
  sendSearchFiles: throwNotInEditMode,
70
70
  sendPdb: throwNotInEditMode,
71
+ sendSetBreakpoints: throwNotInEditMode,
71
72
  sendCreateFileOrFolder: throwNotInEditMode,
72
73
  sendDeleteFileOrFolder: throwNotInEditMode,
73
74
  sendCopyFileOrFolder: throwNotInEditMode,
@@ -5,7 +5,7 @@ import { useAtomValue } from "jotai";
5
5
  import { Spinner } from "@/components/icons/spinner";
6
6
  import { Button } from "@/components/ui/button";
7
7
  import { toast } from "@/components/ui/use-toast";
8
- import { NoKernelConnectedError, prettyError } from "@/utils/errors";
8
+ import { HTTPError, NoKernelConnectedError, prettyError } from "@/utils/errors";
9
9
  import { Logger } from "@/utils/Logger";
10
10
  import { useConnectToRuntime } from "../runtime/config";
11
11
  import { store } from "../state/jotai";
@@ -49,6 +49,7 @@ export function createErrorToastingRequests(
49
49
  sendListFiles: "Failed to list files",
50
50
  sendSearchFiles: "Failed to search files",
51
51
  sendPdb: "Failed to start debug session",
52
+ sendSetBreakpoints: "", // No toast
52
53
  sendCreateFileOrFolder: "Failed to create file or folder",
53
54
  sendDeleteFileOrFolder: "Failed to delete file or folder",
54
55
  sendCopyFileOrFolder: "Failed to duplicate file or folder",
@@ -109,6 +110,14 @@ export function createErrorToastingRequests(
109
110
  throw error;
110
111
  }
111
112
 
113
+ // A capability 403: the connection is read-only for this action. The
114
+ // live capability notification already moved the UI to viewer state,
115
+ // so don't alarm the user with an error toast.
116
+ if (error instanceof HTTPError && error.status === 403) {
117
+ Logger.warn(`Request refused, connection is read-only: ${key}`);
118
+ throw error;
119
+ }
120
+
112
121
  const title = MESSAGES[keyString];
113
122
  const message = prettyError(error);
114
123
  if (title) {
@@ -66,6 +66,7 @@ export type ListDataSourceConnectionRequest =
66
66
  schemas["ListDataSourceConnectionRequest"];
67
67
  export type ValidateSQLRequest = schemas["ValidateSQLRequest"];
68
68
  export type DebugCellRequest = schemas["DebugCellRequest"];
69
+ export type SetBreakpointsRequest = schemas["SetBreakpointsRequest"];
69
70
  export type ReadCodeResponse = schemas["ReadCodeResponse"];
70
71
  export type RecentFilesResponse = schemas["RecentFilesResponse"];
71
72
  export type RenameNotebookRequest = schemas["RenameNotebookRequest"];
@@ -172,6 +173,7 @@ export interface EditRequests {
172
173
  getUsageStats: () => Promise<UsageResponse>;
173
174
  // Debugger
174
175
  sendPdb: (request: DebugCellRequest) => Promise<null>;
176
+ sendSetBreakpoints: (request: SetBreakpointsRequest) => Promise<null>;
175
177
  // File explorer requests
176
178
  sendListFiles: (request: FileListRequest) => Promise<FileListResponse>;
177
179
  sendSearchFiles: (request: FileSearchRequest) => Promise<FileSearchResponse>;
@@ -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
- {renderCells()}
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
+ });