@marimo-team/frontend 0.23.6-dev9 → 0.23.6

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 (121) hide show
  1. package/dist/assets/{CellStatus-BasmzQBh.js → CellStatus-CO_unhk0.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DNoHDaQW.js → ConnectedDataExplorerComponent-k8s9vETJ.js} +1 -1
  3. package/dist/assets/{JsonOutput-BQaSLDBi.js → JsonOutput-ugBhs7bt.js} +11 -11
  4. package/dist/assets/{MarimoErrorOutput-Dm4hVwDq.js → MarimoErrorOutput-1q6qtvxi.js} +1 -1
  5. package/dist/assets/RenderHTML-DkEHxB1r.js +1 -0
  6. package/dist/assets/{RunButton-DHxYW_jZ.js → RunButton-CJsJsGj-.js} +1 -1
  7. package/dist/assets/__vite-browser-external-D0cSGXjR.js +1 -0
  8. package/dist/assets/__vite-browser-external-DQc2JVNq.js +1 -0
  9. package/dist/assets/{add-cell-with-ai-5QPp1nPl.js → add-cell-with-ai-D5JeNTNV.js} +1 -1
  10. package/dist/assets/{add-connection-dialog-C3IbqmBw.js → add-connection-dialog-_nMyst1l.js} +1 -1
  11. package/dist/assets/{agent-panel-Dbk-1jxq.js → agent-panel-CBj2Q42_.js} +1 -1
  12. package/dist/assets/ai-model-dropdown-B-9yxYM4.js +5 -0
  13. package/dist/assets/{app-config-button-DyNQc93A.js → app-config-button-BOc_z0uX.js} +1 -1
  14. package/dist/assets/{cell-editor-BaK05lPI.js → cell-editor-C1BpIU12.js} +3 -3
  15. package/dist/assets/{cell-link-8o8mCUzE.js → cell-link-CC2MiW7a.js} +1 -1
  16. package/dist/assets/{cells-Jdt48eTG.js → cells-DAxz8J5R.js} +3 -3
  17. package/dist/assets/{chat-display-D4A5J3ON.js → chat-display-BiUNr6dU.js} +1 -1
  18. package/dist/assets/{chat-panel--TAJZjAo.js → chat-panel-BHsaaTzR.js} +1 -1
  19. package/dist/assets/{chat-ui-Dlmd_U56.js → chat-ui-CTYG4pnL.js} +1 -1
  20. package/dist/assets/{column-preview-B1oiFlur.js → column-preview-DBPye87P.js} +1 -1
  21. package/dist/assets/{command-palette-kgF1SANg.js → command-palette-C3fn0qCr.js} +1 -1
  22. package/dist/assets/{common-C6ZHulDM.js → common-Qy2P7rii.js} +1 -1
  23. package/dist/assets/{components-Du9BBcZY.js → components-CZy03693.js} +1 -1
  24. package/dist/assets/{components-BiZUhizd.js → components-D9aJNSr-.js} +1 -1
  25. package/dist/assets/config-CPqw1wUv.js +1 -0
  26. package/dist/assets/{datasource-BGCJgHn8.js → datasource-C1JWjcmE.js} +1 -1
  27. package/dist/assets/{dependency-graph-panel-BFoJRcHv.js → dependency-graph-panel-DOHj-hNc.js} +1 -1
  28. package/dist/assets/{documentation-panel-Bi7B6BDD.js → documentation-panel-BM7hNnyp.js} +1 -1
  29. package/dist/assets/{download-q17GDjRk.js → download-CXTuIv7r.js} +1 -1
  30. package/dist/assets/{edit-page-B4TUnRbq.js → edit-page-Ct5Ke1wi.js} +6 -6
  31. package/dist/assets/{error-panel-Elx5tZAZ.js → error-panel-BN-anQD3.js} +1 -1
  32. package/dist/assets/{file-explorer-panel-omTFPhXg.js → file-explorer-panel-D1O0vw5C.js} +3 -3
  33. package/dist/assets/{file-icons-D1lzcliR.js → file-icons-5G4ZC70N.js} +1 -1
  34. package/dist/assets/{floating-outline-DVqXePmL.js → floating-outline-DHE0ukvC.js} +1 -1
  35. package/dist/assets/{focus-Bz1PGBQ2.js → focus-Crs_4nnQ.js} +1 -1
  36. package/dist/assets/{form-CpNkZ10P.js → form-DRJZl2zK.js} +1 -1
  37. package/dist/assets/{glide-data-editor-CxV4Ph39.js → glide-data-editor-DqqLCmqF.js} +1 -1
  38. package/dist/assets/{globals-B-ZMi0ZU.js → globals-DUw71mRV.js} +1 -1
  39. package/dist/assets/{home-page-B6hZQb34.js → home-page-BM_BZnw7.js} +1 -1
  40. package/dist/assets/{hooks-S16Gx0fA.js → hooks-CUEvgvEQ.js} +1 -1
  41. package/dist/assets/{html-to-image-BJDxFKbb.js → html-to-image-v-_444d3.js} +1 -1
  42. package/dist/assets/{index-6t5Y6NP5.js → index-BMxMikGP.js} +7 -7
  43. package/dist/assets/{kiosk-mode-DlJLa7MP.js → kiosk-mode-DKb6W1WN.js} +1 -1
  44. package/dist/assets/layout-Bp1vAdBy.js +9 -0
  45. package/dist/assets/{logs-panel--5x174b0.js → logs-panel-BWCDk8Zy.js} +1 -1
  46. package/dist/assets/{markdown-renderer-9gIJjISB.js → markdown-renderer-CbuqHMPu.js} +1 -1
  47. package/dist/assets/{mermaid-CXmjgj4p.js → mermaid-BfdNvRSd.js} +1 -1
  48. package/dist/assets/{name-cell-input-DUUJ7fdG.js → name-cell-input-DoYtA-nF.js} +1 -1
  49. package/dist/assets/{outline-panel-B6y7CdzK.js → outline-panel-C5bTOsj4.js} +1 -1
  50. package/dist/assets/{packages-panel-bTONt5Lb.js → packages-panel-CHVjLKJK.js} +1 -1
  51. package/dist/assets/panels-GT2UyjFN.js +1 -0
  52. package/dist/assets/{process-output-TR8koJYc.js → process-output-CKd9tbdL.js} +1 -1
  53. package/dist/assets/{radio-group-B0jYgGRE.js → radio-group-BI3wOhfc.js} +1 -1
  54. package/dist/assets/{readonly-python-code-BNCBnJYH.js → readonly-python-code-CaKJ84fy.js} +1 -1
  55. package/dist/assets/{renderShortcut-DZpkrZaP.js → renderShortcut-Bfk4NjRL.js} +1 -1
  56. package/dist/assets/{reveal-component-BakhgglY.js → reveal-component-DC85rv-5.js} +1 -1
  57. package/dist/assets/run-page-CldmTgnu.js +1 -0
  58. package/dist/assets/{save-worker-CtJsIYIM.js → save-worker-CvbUHJh7.js} +3 -3
  59. package/dist/assets/{scratchpad-panel-Bc-DomsH.js → scratchpad-panel-C6QArhRa.js} +1 -1
  60. package/dist/assets/{session-panel-BAAW6R7U.js → session-panel-bu72cztx.js} +1 -1
  61. package/dist/assets/{snippets-panel-roW3_D4d.js → snippets-panel-BvuRa1mc.js} +1 -1
  62. package/dist/assets/state-CjlRDG08.js +3 -0
  63. package/dist/assets/{state-Bhut5iLD.js → state-DgRSUIT8.js} +1 -1
  64. package/dist/assets/{switch-ecwOrzz3.js → switch-BvybnC9P.js} +1 -1
  65. package/dist/assets/{terminal-CQZ69wrS.js → terminal-zehb39z5.js} +23 -23
  66. package/dist/assets/{textarea-DapfQSws.js → textarea-GPxmW3rS.js} +1 -1
  67. package/dist/assets/{tracing-D3nIh6vh.js → tracing-CVQn241p.js} +1 -1
  68. package/dist/assets/{tracing-panel-B23uvwYc.js → tracing-panel-TnogJs00.js} +2 -2
  69. package/dist/assets/{useBoolean-B_vDzBHf.js → useBoolean-xXcxYCaI.js} +1 -1
  70. package/dist/assets/{useCellActionButton-DrzG-_Bd.js → useCellActionButton-C8PCItmw.js} +1 -1
  71. package/dist/assets/{useDeleteCell-CcnkgOq-.js → useDeleteCell-DMZGFMOB.js} +1 -1
  72. package/dist/assets/{useDependencyPanelTab-Dv04JzMr.js → useDependencyPanelTab-Jnl7B-vS.js} +1 -1
  73. package/dist/assets/{useHotkey-Cm0DaHEJ.js → useHotkey-DccKPSPx.js} +1 -1
  74. package/dist/assets/useNotebookActions-U6kQhg4l.js +1 -0
  75. package/dist/assets/{useRunCells-BeRcytF8.js → useRunCells-BMgfN_OV.js} +1 -1
  76. package/dist/assets/{useSplitCell-DSLPh_8D.js → useSplitCell-B5YK7yVe.js} +1 -1
  77. package/dist/assets/{useTheme-Cb4Wekek.js → useTheme-DFXuDFj9.js} +1 -1
  78. package/dist/assets/utils-BrXijSdZ.js +1 -0
  79. package/dist/assets/{vega-component-BDK4MwYT.js → vega-component-DOyQwlmD.js} +1 -1
  80. package/dist/assets/{worker-ztl1wuLb.js → worker-CF8V9c2V.js} +3 -3
  81. package/dist/index.html +29 -29
  82. package/package.json +5 -5
  83. package/src/components/ai/ai-provider-icon.tsx +1 -0
  84. package/src/components/ai/ai-utils.ts +1 -0
  85. package/src/components/app-config/ai-config.tsx +30 -0
  86. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +47 -0
  87. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  88. package/src/components/editor/renderers/cell-array.tsx +14 -7
  89. package/src/components/slides/slide-form.tsx +43 -0
  90. package/src/components/terminal/terminal.tsx +16 -0
  91. package/src/components/ui/links.tsx +2 -1
  92. package/src/core/ai/ids/ids.ts +1 -0
  93. package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
  94. package/src/core/codemirror/markdown/commands.ts +4 -1
  95. package/src/core/config/config-schema.ts +1 -0
  96. package/src/core/edit-app.tsx +1 -0
  97. package/src/core/run-app.tsx +9 -2
  98. package/src/core/runtime/runtime.ts +3 -2
  99. package/src/core/static/static-state.ts +5 -1
  100. package/src/core/wasm/PyodideLoader.tsx +54 -16
  101. package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
  102. package/src/core/wasm/__tests__/bridge.test.ts +26 -1
  103. package/src/core/wasm/bridge.ts +24 -6
  104. package/src/core/wasm/state.ts +3 -0
  105. package/src/core/wasm/worker/getController.ts +7 -0
  106. package/src/core/wasm/worker/save-worker.ts +2 -1
  107. package/src/core/wasm/worker/worker.ts +2 -1
  108. package/src/plugins/core/RenderHTML.tsx +49 -3
  109. package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
  110. package/src/plugins/impl/common/labeled.tsx +1 -1
  111. package/dist/assets/RenderHTML-CA4tujDX.js +0 -1
  112. package/dist/assets/__vite-browser-external-D-ioOGDE.js +0 -1
  113. package/dist/assets/__vite-browser-external-DgbEhFq1.js +0 -1
  114. package/dist/assets/ai-model-dropdown-DRqlfqFb.js +0 -5
  115. package/dist/assets/config-DczIUz0b.js +0 -1
  116. package/dist/assets/layout-D1lkx0Aj.js +0 -9
  117. package/dist/assets/panels-gY-_wGFa.js +0 -1
  118. package/dist/assets/run-page-DwUpSMBL.js +0 -1
  119. package/dist/assets/state-D5aL5woK.js +0 -3
  120. package/dist/assets/useNotebookActions-DC5VHfXc.js +0 -1
  121. package/dist/assets/utils-DIGrmLDO.js +0 -1
@@ -8,6 +8,7 @@ import {
8
8
  CookieIcon,
9
9
  PanelRightCloseIcon,
10
10
  PanelRightOpenIcon,
11
+ KeyboardIcon,
11
12
  } from "lucide-react";
12
13
  import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
13
14
  import {
@@ -28,6 +29,7 @@ import type {
28
29
  import { useState } from "react";
29
30
  import { Tooltip } from "../ui/tooltip";
30
31
  import { Button } from "../ui/button";
32
+ import { Kbd } from "../ui/kbd";
31
33
  import type { RuntimeCell } from "@/core/cells/types";
32
34
 
33
35
  export const DEFAULT_SLIDE_TYPE: SlideType = "slide";
@@ -132,10 +134,51 @@ const SlidesForm = ({
132
134
  <TabsContent value="deck" className="mt-0 flex-1">
133
135
  <DeckConfigForm layout={layout} setLayout={setLayout} />
134
136
  </TabsContent>
137
+ <hr />
138
+ <KeyboardTips />
135
139
  </Tabs>
136
140
  );
137
141
  };
138
142
 
143
+ const KEYBOARD_TIPS: { keys: string[]; description: string }[] = [
144
+ { keys: ["F"], description: "Enter fullscreen" },
145
+ { keys: ["C"], description: "Toggle code editor" },
146
+ ];
147
+
148
+ const KEYBOARD_SHORTCUTS_URL =
149
+ "https://vlaaad.github.io/reveal/keyboard-shortcuts";
150
+
151
+ const KeyboardTips = () => {
152
+ return (
153
+ <div className="flex flex-col gap-2 text-xs text-muted-foreground">
154
+ <div className="flex items-center gap-1.5 font-medium text-foreground/80">
155
+ <KeyboardIcon className="h-3.5 w-3.5" />
156
+ <span>Shortcuts</span>
157
+ </div>
158
+ <ul className="flex flex-col gap-1.5">
159
+ {KEYBOARD_TIPS.map(({ keys, description }) => (
160
+ <li key={description} className="flex items-center justify-between">
161
+ <span>{description}</span>
162
+ <span className="flex gap-1">
163
+ {keys.map((key) => (
164
+ <Kbd key={key}>{key}</Kbd>
165
+ ))}
166
+ </span>
167
+ </li>
168
+ ))}
169
+ </ul>
170
+ <a
171
+ href={KEYBOARD_SHORTCUTS_URL}
172
+ target="_blank"
173
+ rel="noopener noreferrer"
174
+ className="text-link hover:underline"
175
+ >
176
+ See all shortcuts
177
+ </a>
178
+ </div>
179
+ );
180
+ };
181
+
139
182
  const SlideConfigForm = ({
140
183
  layout,
141
184
  setLayout,
@@ -270,6 +270,22 @@ const TerminalComponent: React.FC<TerminalComponentProps> = ({
270
270
 
271
271
  const handleOpen = () => {
272
272
  updateReadyState();
273
+ // Send initial dimensions: the mount-time fit() may have fired
274
+ // before the WS was OPEN, dropping the resize message and leaving
275
+ // the PTY at its default 0x0 winsize.
276
+ fitAddon.fit();
277
+ if (terminal.cols > 0 && terminal.rows > 0) {
278
+ socket.send(
279
+ JSON.stringify({
280
+ type: "resize",
281
+ cols: terminal.cols,
282
+ rows: terminal.rows,
283
+ }),
284
+ );
285
+ // The fit() above may have triggered onResize → scheduled a
286
+ // debounced send. Cancel it; we just sent the same dims.
287
+ handleBackendResizeDebounced.cancel();
288
+ }
273
289
  };
274
290
 
275
291
  const handleDisconnect = () => {
@@ -15,7 +15,8 @@ export const ExternalLink = ({
15
15
  | `https://marimo.io/${string}`
16
16
  | `https://links.marimo.app/${string}`
17
17
  | `https://wandb.ai/${string}`
18
- | `https://portal.azure.com/${string}`;
18
+ | `https://portal.azure.com/${string}`
19
+ | `https://opencode.ai/${string}`;
19
20
  children: React.ReactNode;
20
21
  }) => {
21
22
  return (
@@ -13,6 +13,7 @@ export const KNOWN_PROVIDERS = [
13
13
  "github",
14
14
  "openrouter",
15
15
  "wandb",
16
+ "opencode-go",
16
17
  "marimo",
17
18
  ] as const;
18
19
  export type KnownProviderId = (typeof KNOWN_PROVIDERS)[number];
@@ -385,6 +385,42 @@ describe("insertImage", () => {
385
385
  );
386
386
  });
387
387
 
388
+ test("normalizes Windows backslash paths to forward slashes in image URL", async () => {
389
+ view = createEditor("Hello, world!");
390
+ view.dispatch({
391
+ selection: { anchor: 7, head: 7 },
392
+ });
393
+
394
+ vi.spyOn(store, "get").mockImplementation((atom) => {
395
+ if (atom === filenameAtom) {
396
+ return "C:\\Users\\user\\project\\notebook.py";
397
+ }
398
+ if (atom === requestClientAtom) {
399
+ return mockRequestClient;
400
+ }
401
+ });
402
+
403
+ mockRequestClient.sendCreateFileOrFolder.mockResolvedValueOnce({
404
+ success: true,
405
+ message: null,
406
+ info: {
407
+ path: "C:\\Users\\user\\project\\public\\hello.png",
408
+ name: "hello.png",
409
+ children: [],
410
+ id: "",
411
+ isDirectory: false,
412
+ isMarimoFile: false,
413
+ lastModified: null,
414
+ },
415
+ });
416
+
417
+ await insertImage(view, mockPngFile());
418
+
419
+ expect(view.state.doc.toString()).toMatchInlineSnapshot(
420
+ `"Hello, ![alt](public/hello.png)world!"`,
421
+ );
422
+ });
423
+
388
424
  test("saves image as file different extension", async () => {
389
425
  view = createEditor("Hello, world!");
390
426
  view.dispatch({
@@ -360,7 +360,10 @@ export async function insertImage(view: EditorView, file: File) {
360
360
  notebookDir &&
361
361
  savedFilePath.startsWith(notebookDir)
362
362
  ) {
363
- savedFilePath = Paths.rest(savedFilePath, notebookDir);
363
+ savedFilePath = Paths.rest(savedFilePath, notebookDir).replaceAll(
364
+ "\\",
365
+ "/",
366
+ );
364
367
  }
365
368
 
366
369
  toast({
@@ -166,6 +166,7 @@ export const UserConfigSchema = z
166
166
  ollama: AiConfigSchema.optional(),
167
167
  openrouter: AiConfigSchema.optional(),
168
168
  wandb: AiConfigSchema.optional(),
169
+ opencode_go: AiConfigSchema.optional(),
169
170
  open_ai_compatible: AiConfigSchema.optional(),
170
171
  azure: AiConfigSchema.optional(),
171
172
  bedrock: z
@@ -137,6 +137,7 @@ export const EditApp: React.FC<AppProps> = ({
137
137
  mode={viewState.mode}
138
138
  userConfig={userConfig}
139
139
  appConfig={appConfig}
140
+ hideControls={hideControls}
140
141
  />
141
142
  );
142
143
 
@@ -10,7 +10,11 @@ import { buttonVariants } from "@/components/ui/button";
10
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
- import { notebookIsRunningAtom, useCellActions } from "./cells/cells";
13
+ import {
14
+ hasCellsAtom,
15
+ notebookIsRunningAtom,
16
+ useCellActions,
17
+ } from "./cells/cells";
14
18
  import type { AppConfig } from "./config/config-schema";
15
19
  import { RuntimeState } from "./kernel/RuntimeState";
16
20
  import { getSessionId } from "./kernel/session";
@@ -42,10 +46,13 @@ export const RunApp: React.FC<AppProps> = ({ appConfig }) => {
42
46
 
43
47
  const isRunning = useAtomValue(notebookIsRunningAtom);
44
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);
45
52
 
46
53
  const renderCells = () => {
47
54
  // If we are connecting for more than 2 seconds, show a spinner
48
- if (isConnecting) {
55
+ if (isConnecting && !hasExistingCells) {
49
56
  return (
50
57
  <DelayMount milliseconds={2000} fallback={null}>
51
58
  <Spinner className="mx-auto" />
@@ -5,6 +5,7 @@ import { Logger } from "@/utils/Logger";
5
5
  import { KnownQueryParams } from "../constants";
6
6
  import { isIslands } from "../islands/utils";
7
7
  import { getSessionId, type SessionId } from "../kernel/session";
8
+ import { isStaticNotebook } from "../static/static-state";
8
9
  import { isWasm } from "../wasm/utils";
9
10
  import type { RuntimeConfig } from "./types";
10
11
 
@@ -178,8 +179,8 @@ export class RuntimeManager {
178
179
  }
179
180
 
180
181
  async isHealthy(): Promise<boolean> {
181
- // Always healthy if WASM
182
- if (isWasm() || isIslands()) {
182
+ // Always healthy if WASM, Islands, or a static notebook (no server)
183
+ if (isWasm() || isIslands() || isStaticNotebook()) {
183
184
  return true;
184
185
  }
185
186
 
@@ -43,7 +43,11 @@ function isMarimoStaticState(
43
43
  }
44
44
 
45
45
  function getMarimoStaticState(): Readonly<MarimoStaticState> | undefined {
46
- const state = window?.__MARIMO_STATIC__;
46
+ // `typeof window` guard handles the identifier-undeclared case (e.g.
47
+ // leaked async work firing after jsdom teardown in tests); `?.` only
48
+ // short-circuits on null/undefined.
49
+ const state =
50
+ typeof window === "undefined" ? undefined : window.__MARIMO_STATIC__;
47
51
  return isMarimoStaticState(state) ? state : undefined;
48
52
  }
49
53
 
@@ -3,13 +3,18 @@
3
3
  import { useAtomValue } from "jotai";
4
4
  import type React from "react";
5
5
  import type { PropsWithChildren } from "react";
6
+ import { useEffect, useRef } from "react";
6
7
  import { LargeSpinner } from "@/components/icons/large-spinner";
8
+ import { toast } from "@/components/ui/use-toast";
9
+ import { hasCellsAtom } from "@/core/cells/cells";
7
10
  import { showCodeInRunModeAtom } from "@/core/meta/state";
8
11
  import { store } from "@/core/state/jotai";
9
12
  import { useAsyncData } from "@/hooks/useAsyncData";
13
+ import { prettyError } from "@/utils/errors";
14
+ import { Logger } from "@/utils/Logger";
10
15
  import { hasQueryParam } from "@/utils/urls";
11
16
  import { KnownQueryParams } from "../constants";
12
- import { getInitialAppMode } from "../mode";
17
+ import { type AppMode, getInitialAppMode } from "../mode";
13
18
  import { PyodideBridge } from "./bridge";
14
19
  import { hasAnyOutputAtom, wasmInitializationAtom } from "./state";
15
20
  import { isWasm } from "./utils";
@@ -26,30 +31,44 @@ export const PyodideLoader: React.FC<PropsWithChildren> = ({ children }) => {
26
31
  };
27
32
 
28
33
  const PyodideLoaderInner: React.FC<PropsWithChildren> = ({ children }) => {
29
- // isPyodide() is constant, so this is safe
30
- const { isPending, error } = useAsyncData(async () => {
34
+ // Don't block render on Pyodide: a hydrated snapshot can paint immediately
35
+ // while Pyodide downloads in the background.
36
+ const { error } = useAsyncData(async () => {
31
37
  await PyodideBridge.INSTANCE.initialized.promise;
32
38
  return true;
33
39
  }, []);
34
40
 
41
+ const hasCells = useAtomValue(hasCellsAtom);
35
42
  const hasOutput = useAtomValue(hasAnyOutputAtom);
43
+ const nothingToShow = shouldShowSpinner({
44
+ hasCells,
45
+ hasOutput,
46
+ mode: getInitialAppMode(),
47
+ codeHidden: isCodeHidden(),
48
+ });
36
49
 
37
- if (isPending) {
38
- return <WasmSpinner />;
39
- }
50
+ const didToastErrorRef = useRef(false);
51
+ useEffect(() => {
52
+ // With snapshot content on-screen, toast instead of throwing so the
53
+ // snapshot stays readable. The ref ensures we only toast once even if
54
+ // nothingToShow toggles later.
55
+ if (error && !nothingToShow && !didToastErrorRef.current) {
56
+ didToastErrorRef.current = true;
57
+ Logger.error("Pyodide failed to initialize", error);
58
+ toast({
59
+ title: "Failed to start the notebook runtime",
60
+ description: prettyError(error),
61
+ variant: "danger",
62
+ });
63
+ }
64
+ }, [error, nothingToShow]);
40
65
 
41
- // If ALL are true:
42
- // - are in read mode
43
- // - we are not showing the code
44
- // - and there is no output
45
- // then show the spinner
46
- if (!hasOutput && getInitialAppMode() === "read" && isCodeHidden()) {
47
- return <WasmSpinner />;
66
+ if (error && nothingToShow) {
67
+ throw error;
48
68
  }
49
69
 
50
- // Propagate back up to our error boundary
51
- if (error) {
52
- throw error;
70
+ if (nothingToShow) {
71
+ return <WasmSpinner />;
53
72
  }
54
73
 
55
74
  return children;
@@ -65,6 +84,25 @@ function isCodeHidden() {
65
84
  );
66
85
  }
67
86
 
87
+ /**
88
+ * Pure predicate: should the WASM loader render a spinner instead of its
89
+ * children? We block render only when nothing user-visible would appear:
90
+ * - no cells have been hydrated (Pyodide hasn't parsed the notebook), or
91
+ * - we are in headless run mode (code hidden) with no outputs to display.
92
+ */
93
+ export function shouldShowSpinner(input: {
94
+ hasCells: boolean;
95
+ hasOutput: boolean;
96
+ mode: AppMode;
97
+ codeHidden: boolean;
98
+ }): boolean {
99
+ const { hasCells, hasOutput, mode, codeHidden } = input;
100
+ if (!hasCells) {
101
+ return true;
102
+ }
103
+ return !hasOutput && mode === "read" && codeHidden;
104
+ }
105
+
68
106
  export const WasmSpinner: React.FC<PropsWithChildren> = ({ children }) => {
69
107
  const wasmInitialization = useAtomValue(wasmInitializationAtom);
70
108
 
@@ -0,0 +1,72 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { shouldShowSpinner } from "../PyodideLoader";
5
+
6
+ describe("shouldShowSpinner", () => {
7
+ it("shows the spinner when there are no cells yet (Pyodide hasn't parsed)", () => {
8
+ expect(
9
+ shouldShowSpinner({
10
+ hasCells: false,
11
+ hasOutput: false,
12
+ mode: "read",
13
+ codeHidden: false,
14
+ }),
15
+ ).toBe(true);
16
+
17
+ expect(
18
+ shouldShowSpinner({
19
+ hasCells: false,
20
+ hasOutput: true,
21
+ mode: "edit",
22
+ codeHidden: false,
23
+ }),
24
+ ).toBe(true);
25
+ });
26
+
27
+ it("renders children once cells exist with code visible", () => {
28
+ // run mode, code visible, no outputs yet — the user can read the code
29
+ expect(
30
+ shouldShowSpinner({
31
+ hasCells: true,
32
+ hasOutput: false,
33
+ mode: "read",
34
+ codeHidden: false,
35
+ }),
36
+ ).toBe(false);
37
+ });
38
+
39
+ it("renders children once cells exist with cached outputs (snapshot case)", () => {
40
+ expect(
41
+ shouldShowSpinner({
42
+ hasCells: true,
43
+ hasOutput: true,
44
+ mode: "read",
45
+ codeHidden: true,
46
+ }),
47
+ ).toBe(false);
48
+ });
49
+
50
+ it("keeps the spinner up in headless run mode with no outputs", () => {
51
+ // read mode + code hidden + no outputs = nothing visible to render
52
+ expect(
53
+ shouldShowSpinner({
54
+ hasCells: true,
55
+ hasOutput: false,
56
+ mode: "read",
57
+ codeHidden: true,
58
+ }),
59
+ ).toBe(true);
60
+ });
61
+
62
+ it("never blocks edit mode once cells exist", () => {
63
+ expect(
64
+ shouldShowSpinner({
65
+ hasCells: true,
66
+ hasOutput: false,
67
+ mode: "edit",
68
+ codeHidden: true,
69
+ }),
70
+ ).toBe(false);
71
+ });
72
+ });
@@ -58,7 +58,7 @@ vi.mock("@/core/wasm/store", () => ({
58
58
  // Import after all mocks are set up
59
59
  import { store } from "@/core/state/jotai";
60
60
  import { initialModeAtom } from "@/core/mode";
61
- import { PyodideBridge } from "../bridge";
61
+ import { getWasmWorkerName, PyodideBridge } from "../bridge";
62
62
 
63
63
  // Access INSTANCE once at module level so the constructor runs (and
64
64
  // addMessageListener populates rpcListeners) before any test executes.
@@ -111,3 +111,28 @@ describe("PyodideBridge.readCode", () => {
111
111
  expect(mockNotebookReadFile).not.toHaveBeenCalled();
112
112
  });
113
113
  });
114
+
115
+ describe("getWasmWorkerName", () => {
116
+ afterEach(() => {
117
+ delete (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
118
+ .__MARIMO_HAS_WASM_CONTROLLER__;
119
+ });
120
+
121
+ it("returns the version without suffix by default", () => {
122
+ expect(getWasmWorkerName()).toBe("0.0.0-test");
123
+ });
124
+
125
+ it("appends ::controller when the host opts in", () => {
126
+ (
127
+ window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean }
128
+ ).__MARIMO_HAS_WASM_CONTROLLER__ = true;
129
+ expect(getWasmWorkerName()).toBe("0.0.0-test::controller");
130
+ });
131
+
132
+ it("does not append the suffix for non-true values", () => {
133
+ (
134
+ window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: unknown }
135
+ ).__MARIMO_HAS_WASM_CONTROLLER__ = "true";
136
+ expect(getWasmWorkerName()).toBe("0.0.0-test");
137
+ });
138
+ });
@@ -37,7 +37,7 @@ import type { IConnectionTransport } from "../websocket/transports/transport";
37
37
  import { PyodideRouter } from "./router";
38
38
  import { getWorkerRPC } from "./rpc";
39
39
  import { createShareableLink } from "./share";
40
- import { wasmInitializationAtom } from "./state";
40
+ import { wasmInitializationAtom, wasmInitStatusAtom } from "./state";
41
41
  import { fallbackFileStore, notebookFileStore } from "./store";
42
42
  import { isWasm } from "./utils";
43
43
  import type { SaveWorkerSchema } from "./worker/save-worker";
@@ -81,9 +81,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
81
81
  new URL("./worker/save-worker.ts", import.meta.url),
82
82
  {
83
83
  type: "module",
84
- // Pass the version to the worker
84
+ // Pass the version (and optional capability suffix) to the worker
85
85
  /* @vite-ignore */
86
- name: getMarimoVersion(),
86
+ name: getWasmWorkerName(),
87
87
  },
88
88
  );
89
89
 
@@ -101,9 +101,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
101
101
  new URL("./worker/worker.ts", import.meta.url),
102
102
  {
103
103
  type: "module",
104
- // Pass the version to the worker
104
+ // Pass the version (and optional capability suffix) to the worker
105
105
  /* @vite-ignore */
106
- name: getMarimoVersion(),
106
+ name: getWasmWorkerName(),
107
107
  },
108
108
  );
109
109
 
@@ -119,13 +119,15 @@ export class PyodideBridge implements RunRequests, EditRequests {
119
119
  // By initializing after, we get hits on cached network requests
120
120
  this.saveRpc = this.getSaveWorker();
121
121
  this.setInterruptBuffer();
122
+ store.set(wasmInitStatusAtom, "ready");
122
123
  this.initialized.resolve();
123
124
  });
124
125
  this.rpc.addMessageListener("initializingMessage", ({ message }) => {
125
126
  store.set(wasmInitializationAtom, message);
126
127
  });
127
128
  this.rpc.addMessageListener("initializedError", ({ error }) => {
128
- // If already resolved, show a toast
129
+ // If already initialized, surface as a toast and leave the deferred /
130
+ // init status alone — the worker is healthy, this is a runtime error.
129
131
  if (this.initialized.status === "resolved") {
130
132
  Logger.error(error);
131
133
  toast({
@@ -133,7 +135,9 @@ export class PyodideBridge implements RunRequests, EditRequests {
133
135
  description: error,
134
136
  variant: "danger",
135
137
  });
138
+ return;
136
139
  }
140
+ store.set(wasmInitStatusAtom, "error");
137
141
  this.initialized.reject(new Error(error));
138
142
  });
139
143
  this.rpc.addMessageListener("kernelMessage", ({ message }) => {
@@ -634,3 +638,17 @@ export function createPyodideConnection(): IConnectionTransport {
634
638
  PyodideBridge.INSTANCE.attachMessageConsumer(callback);
635
639
  });
636
640
  }
641
+
642
+ // Compose the worker name. The version prefix is read by getMarimoVersion()
643
+ // in the worker; the optional "::controller" suffix tells getController.ts
644
+ // that the host page provides a custom /wasm/controller.js and that the
645
+ // dynamic import should be attempted. Hosts opt in by setting
646
+ // `window.__MARIMO_HAS_WASM_CONTROLLER__ = true` before
647
+ // PyodideBridge/worker initialization.
648
+ export function getWasmWorkerName(): string {
649
+ const hasCustomController =
650
+ typeof window !== "undefined" &&
651
+ (window as unknown as { __MARIMO_HAS_WASM_CONTROLLER__?: boolean })
652
+ .__MARIMO_HAS_WASM_CONTROLLER__ === true;
653
+ return getMarimoVersion() + (hasCustomController ? "::controller" : "");
654
+ }
@@ -5,6 +5,9 @@ import { isOutputEmpty } from "../cells/outputs";
5
5
 
6
6
  export const wasmInitializationAtom = atom<string>("Initializing...");
7
7
 
8
+ export type WasmInitStatus = "loading" | "ready" | "error";
9
+ export const wasmInitStatusAtom = atom<WasmInitStatus>("loading");
10
+
8
11
  export const hasAnyOutputAtom = atom<boolean>((get) => {
9
12
  const notebook = get(notebookAtom);
10
13
  const runtimeStates = Object.values(notebook.cellRuntime);
@@ -5,6 +5,13 @@ import type { WasmController } from "./types";
5
5
  // Load the controller
6
6
  // Falls back to the default controller
7
7
  export async function getController(version: string): Promise<WasmController> {
8
+ // Hosts that provide a custom /wasm/controller.js opt in via the worker
9
+ // name (see bridge.ts). Default: skip the dynamic import to avoid a
10
+ // guaranteed-404 round trip on the standard build.
11
+ const hasCustomController = self.name?.includes("::controller") ?? false;
12
+ if (!hasCustomController) {
13
+ return new DefaultWasmController();
14
+ }
8
15
  try {
9
16
  const controller = await import(
10
17
  /* @vite-ignore */ `/wasm/controller.js?version=${version}`
@@ -103,5 +103,6 @@ const rpc = createRPC<SaveWorkerSchema, ParentSchema>({
103
103
  rpc.send("ready", {});
104
104
 
105
105
  function getMarimoVersion() {
106
- return self.name; // We store the version in the worker name
106
+ // Worker name is "<version>" or "<version>::<capability>" see bridge.ts.
107
+ return self.name.split("::")[0];
107
108
  }
@@ -375,7 +375,8 @@ const namesThatRequireSync = new Set<keyof RawBridge>([
375
375
  ]);
376
376
 
377
377
  function getMarimoVersion() {
378
- return self.name; // We store the version in the worker name
378
+ // Worker name is "<version>" or "<version>::<capability>" see bridge.ts.
379
+ return self.name.split("::")[0];
379
380
  }
380
381
 
381
382
  const pyodideReadyPromise = t.wrapAsync(loadPyodideAndPackages)();