@marimo-team/islands 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 (51) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CWU3Az6F.js → ConnectedDataExplorerComponent-PmilQqXR.js} +4 -4
  2. package/dist/assets/__vite-browser-external-rrUYDKRl.js +1 -0
  3. package/dist/assets/{worker-D-EdLKct.js → worker-Bfy15ViQ.js} +2 -2
  4. package/dist/{chat-ui-Cyca6aKX.js → chat-ui-B-gbqk_F.js} +6 -6
  5. package/dist/{code-visibility-B0kwrVA6.js → code-visibility-DNiCvIcQ.js} +678 -564
  6. package/dist/{formats-Dh5M1ZRs.js → formats-CgaK7Gmx.js} +1 -1
  7. package/dist/{glide-data-editor-DXti2axL.js → glide-data-editor-CvlvtPWJ.js} +2 -2
  8. package/dist/{html-to-image-6VI69paz.js → html-to-image-hMMPiNe_.js} +2120 -2103
  9. package/dist/{input-Drx1pguW.js → input-BAOe64zx.js} +1 -1
  10. package/dist/main.js +19 -19
  11. package/dist/{mermaid-BagLPXm9.js → mermaid-DJ1NyBGw.js} +2 -2
  12. package/dist/{process-output-SkNR_Omd.js → process-output-Bza_GK7Q.js} +1 -1
  13. package/dist/{reveal-component-DlCLweHo.js → reveal-component-BSwl7P64.js} +13 -13
  14. package/dist/{spec-BKWq0wn2.js → spec-DSIuqd3f.js} +1 -1
  15. package/dist/toDate-CHtl9vts.js +662 -0
  16. package/dist/{useAsyncData-CKYzhCis.js → useAsyncData-B6hCGywC.js} +1 -1
  17. package/dist/{useDeepCompareMemoize-je76AJS_.js → useDeepCompareMemoize-CmwDuYUH.js} +1 -1
  18. package/dist/{useLifecycle-smVfjLNI.js → useLifecycle-CjMjllqy.js} +1 -1
  19. package/dist/{useTheme-CX9pPLUH.js → useTheme-CByZUW0p.js} +1 -0
  20. package/dist/{vega-component-BnCQmtxw.js → vega-component-CC8TqWWV.js} +5 -5
  21. package/package.json +5 -5
  22. package/src/components/ai/ai-provider-icon.tsx +1 -0
  23. package/src/components/ai/ai-utils.ts +1 -0
  24. package/src/components/app-config/ai-config.tsx +30 -0
  25. package/src/components/editor/chrome/wrapper/footer-items/pyodide-status.tsx +47 -0
  26. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  27. package/src/components/editor/renderers/cell-array.tsx +14 -7
  28. package/src/components/slides/slide-form.tsx +43 -0
  29. package/src/components/terminal/terminal.tsx +16 -0
  30. package/src/components/ui/links.tsx +2 -1
  31. package/src/core/ai/ids/ids.ts +1 -0
  32. package/src/core/codemirror/markdown/__tests__/commands.test.ts +36 -0
  33. package/src/core/codemirror/markdown/commands.ts +4 -1
  34. package/src/core/config/config-schema.ts +1 -0
  35. package/src/core/edit-app.tsx +1 -0
  36. package/src/core/run-app.tsx +9 -2
  37. package/src/core/runtime/runtime.ts +3 -2
  38. package/src/core/static/static-state.ts +5 -1
  39. package/src/core/wasm/PyodideLoader.tsx +54 -16
  40. package/src/core/wasm/__tests__/PyodideLoader.test.ts +72 -0
  41. package/src/core/wasm/__tests__/bridge.test.ts +26 -1
  42. package/src/core/wasm/bridge.ts +24 -6
  43. package/src/core/wasm/state.ts +3 -0
  44. package/src/core/wasm/worker/getController.ts +7 -0
  45. package/src/core/wasm/worker/save-worker.ts +2 -1
  46. package/src/core/wasm/worker/worker.ts +2 -1
  47. package/src/plugins/core/RenderHTML.tsx +49 -3
  48. package/src/plugins/core/__test__/RenderHTML.test.ts +54 -0
  49. package/src/plugins/impl/common/labeled.tsx +1 -1
  50. package/dist/assets/__vite-browser-external-C4JkHbyY.js +0 -1
  51. package/dist/toDate-yqOcZ_tY.js +0 -638
@@ -614,6 +614,7 @@ const UserConfigSchema = looseObject({
614
614
  ollama: AiConfigSchema.optional(),
615
615
  openrouter: AiConfigSchema.optional(),
616
616
  wandb: AiConfigSchema.optional(),
617
+ opencode_go: AiConfigSchema.optional(),
617
618
  open_ai_compatible: AiConfigSchema.optional(),
618
619
  azure: AiConfigSchema.optional(),
619
620
  bedrock: looseObject({
@@ -2,23 +2,23 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { _ as Logger, c as Objects, g as cn, h as Events } from "./button-CA5pI2YF.js";
3
3
  import { t as require_react } from "./react-DA-nE2FX.js";
4
4
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
5
- import { c as asRemoteURL, g as CircleQuestionMark } from "./toDate-yqOcZ_tY.js";
5
+ import { c as asRemoteURL, v as CircleQuestionMark } from "./toDate-CHtl9vts.js";
6
6
  import "./react-dom-BWRJ_g_k.js";
7
7
  import { t as require_jsx_runtime } from "./jsx-runtime-COBk7ree.js";
8
8
  import "./zod-BxdsqRPd.js";
9
9
  import { n as ErrorBanner } from "./error-banner-DnBPzEWg.js";
10
10
  import { t as Tooltip } from "./tooltip-B0mtKTXm.js";
11
11
  import { i as debounce_default } from "./constants-D0gkYoE2.js";
12
- import { n as useTheme, w as useEvent_default } from "./useTheme-CX9pPLUH.js";
12
+ import { n as useTheme, w as useEvent_default } from "./useTheme-CByZUW0p.js";
13
13
  import { s as uniq } from "./arrays-CldYf7p7.js";
14
- import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-Dh5M1ZRs.js";
14
+ import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-CgaK7Gmx.js";
15
15
  import { n as formats } from "./vega-loader.browser-3_z8GoFC.js";
16
16
  import { a as getContainerWidth, n as vegaLoadData, s as tooltipHandler } from "./loader-BvW0-YWZ.js";
17
- import { t as useAsyncData } from "./useAsyncData-CKYzhCis.js";
17
+ import { t as useAsyncData } from "./useAsyncData-B6hCGywC.js";
18
18
  import { t as j } from "./react-vega-k9ODWPlI.js";
19
19
  import "./defaultLocale-BpsHxBd7.js";
20
20
  import "./defaultLocale-DoeErsX2.js";
21
- import { t as useDeepCompareMemoize } from "./useDeepCompareMemoize-je76AJS_.js";
21
+ import { t as useDeepCompareMemoize } from "./useDeepCompareMemoize-CmwDuYUH.js";
22
22
  var import_compiler_runtime = require_compiler_runtime(), import_react = /* @__PURE__ */ __toESM(require_react(), 1);
23
23
  function fixRelativeUrl(e) {
24
24
  return e.data && "url" in e.data && (e.data.url = asRemoteURL(e.data.url).href), e;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.6-dev9",
3
+ "version": "0.23.6",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -195,9 +195,9 @@
195
195
  "@codecov/vite-plugin": "^1.9.1",
196
196
  "@csstools/postcss-light-dark-function": "^2.0.11",
197
197
  "@playwright/test": "^1.59.1",
198
- "@storybook/addon-docs": "^10.2.12",
199
- "@storybook/addon-links": "^10.2.12",
200
- "@storybook/react-vite": "^10.2.12",
198
+ "@storybook/addon-docs": "^10.3.5",
199
+ "@storybook/addon-links": "^10.3.5",
200
+ "@storybook/react-vite": "^10.3.5",
201
201
  "@swc-jotai/react-refresh": "^0.5.0",
202
202
  "@testing-library/jest-dom": "^6.9.1",
203
203
  "@testing-library/react": "^16.3.2",
@@ -223,7 +223,7 @@
223
223
  "react": "^19.2.4",
224
224
  "react-compiler-runtime": "19.1.0-rc.3",
225
225
  "react-dom": "^19.2.4",
226
- "storybook": "^10.2.12",
226
+ "storybook": "^10.3.5",
227
227
  "stylelint": "^16.26.1",
228
228
  "stylelint-config-standard": "^36.0.1",
229
229
  "tailwindcss": "^4.2.2",
@@ -33,6 +33,7 @@ const icons: Record<ProviderId | ExternalAgentId, string> = {
33
33
  github: GitHubIcon,
34
34
  openrouter: OpenRouterIcon,
35
35
  wandb: WandbIcon,
36
+ "opencode-go": OpencodeIcon,
36
37
  marimo: marimoIcon,
37
38
  opencode: OpencodeIcon,
38
39
  cursor: CursorIcon,
@@ -21,6 +21,7 @@ const CREDENTIAL_CHECKERS: Record<KnownProviderId, CredentialChecker> = {
21
21
  openrouter: (ai) => Boolean(ai?.openrouter?.api_key),
22
22
  azure: (ai) => Boolean(ai?.azure?.api_key && ai?.azure?.base_url),
23
23
  wandb: (ai) => Boolean(ai?.wandb?.api_key),
24
+ "opencode-go": (ai) => Boolean(ai?.opencode_go?.api_key),
24
25
  bedrock: (ai) => Boolean(ai?.bedrock?.region_name),
25
26
  ollama: (ai) => Boolean(ai?.ollama?.base_url),
26
27
  // These providers don't have user-configurable credentials in the UI
@@ -1058,6 +1058,36 @@ export const AiProvidersConfig: React.FC<AiConfigProps> = ({
1058
1058
  />
1059
1059
  </AccordionFormItem>
1060
1060
 
1061
+ <AccordionFormItem
1062
+ title="OpenCode Go"
1063
+ provider="opencode-go"
1064
+ isConfigured={hasValue("ai.opencode_go.api_key")}
1065
+ >
1066
+ <ApiKey
1067
+ form={form}
1068
+ config={config}
1069
+ name="ai.opencode_go.api_key"
1070
+ placeholder="your-opencode-api-key"
1071
+ testId="ai-opencode-go-api-key-input"
1072
+ description={
1073
+ <>
1074
+ Your OpenCode API key from{" "}
1075
+ <ExternalLink href="https://opencode.ai/auth">
1076
+ opencode.ai
1077
+ </ExternalLink>
1078
+ . OpenCode Go is a low-cost subscription for open coding models.
1079
+ </>
1080
+ }
1081
+ />
1082
+ <BaseUrl
1083
+ form={form}
1084
+ config={config}
1085
+ name="ai.opencode_go.base_url"
1086
+ placeholder="https://opencode.ai/zen/go/v1/"
1087
+ testId="ai-opencode-go-base-url-input"
1088
+ />
1089
+ </AccordionFormItem>
1090
+
1061
1091
  <AccordionFormItem
1062
1092
  title="Azure"
1063
1093
  provider="azure"
@@ -0,0 +1,47 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useAtomValue } from "jotai";
4
+ import { AlertCircleIcon } from "lucide-react";
5
+ import type React from "react";
6
+ import { Spinner } from "@/components/icons/spinner";
7
+ import { Tooltip } from "@/components/ui/tooltip";
8
+ import { wasmInitializationAtom, wasmInitStatusAtom } from "@/core/wasm/state";
9
+ import { isWasm } from "@/core/wasm/utils";
10
+
11
+ /**
12
+ * Footer indicator that surfaces Pyodide initialization progress. Mirrors
13
+ * the "Kernel" indicator but tracks the WASM runtime instead of the server
14
+ * connection. Hides itself once Pyodide is ready.
15
+ */
16
+ export const PyodideStatus: React.FC = () => {
17
+ const status = useAtomValue(wasmInitStatusAtom);
18
+ const message = useAtomValue(wasmInitializationAtom);
19
+
20
+ if (!isWasm() || status === "ready") {
21
+ return null;
22
+ }
23
+
24
+ const icon =
25
+ status === "error" ? (
26
+ <AlertCircleIcon className="w-4 h-4 text-destructive" />
27
+ ) : (
28
+ <Spinner size="small" />
29
+ );
30
+
31
+ const tooltip = status === "error" ? "Pyodide failed to initialize" : message;
32
+
33
+ return (
34
+ <Tooltip
35
+ content={<div className="text-sm whitespace-pre-line">{tooltip}</div>}
36
+ data-testid="footer-pyodide-status"
37
+ >
38
+ <div
39
+ className="p-1 hover:bg-accent rounded flex items-center gap-1.5 text-xs text-muted-foreground"
40
+ data-testid="pyodide-status"
41
+ >
42
+ {icon}
43
+ <span>Pyodide</span>
44
+ </div>
45
+ </Tooltip>
46
+ );
47
+ };
@@ -18,6 +18,7 @@ import {
18
18
  } from "./footer-items/backend-status";
19
19
  import { CopilotStatusIcon } from "./footer-items/copilot-status";
20
20
  import { MachineStats } from "./footer-items/machine-stats";
21
+ import { PyodideStatus } from "./footer-items/pyodide-status";
21
22
  import { RTCStatus } from "./footer-items/rtc-status";
22
23
  import { RuntimeSettings } from "./footer-items/runtime-settings";
23
24
  import { useSetDependencyPanelTab } from "./useDependencyPanelTab";
@@ -85,6 +86,7 @@ export const Footer: React.FC = () => {
85
86
 
86
87
  <div className="mx-auto" />
87
88
 
89
+ <PyodideStatus />
88
90
  <ConnectingKernelIndicatorItem />
89
91
 
90
92
  <ShowInKioskMode>
@@ -59,6 +59,7 @@ interface CellArrayProps {
59
59
  mode: AppMode;
60
60
  userConfig: UserConfig;
61
61
  appConfig: AppConfig;
62
+ hideControls?: boolean;
62
63
  }
63
64
 
64
65
  export const CellArray: React.FC<CellArrayProps> = (props) => {
@@ -82,6 +83,7 @@ const CellArrayInternal: React.FC<CellArrayProps> = ({
82
83
  mode,
83
84
  userConfig,
84
85
  appConfig,
86
+ hideControls = false,
85
87
  }) => {
86
88
  const actions = useCellActions();
87
89
  const { theme } = useTheme();
@@ -147,6 +149,7 @@ const CellArrayInternal: React.FC<CellArrayProps> = ({
147
149
  mode={mode}
148
150
  userConfig={userConfig}
149
151
  theme={theme}
152
+ hideControls={hideControls}
150
153
  />
151
154
  ))}
152
155
  </div>
@@ -166,6 +169,7 @@ const CellColumn: React.FC<{
166
169
  mode: AppMode;
167
170
  userConfig: UserConfig;
168
171
  theme: Theme;
172
+ hideControls: boolean;
169
173
  }> = ({
170
174
  columnId,
171
175
  index,
@@ -174,6 +178,7 @@ const CellColumn: React.FC<{
174
178
  mode,
175
179
  userConfig,
176
180
  theme,
181
+ hideControls,
177
182
  }) => {
178
183
  const cellIds = useCellIds();
179
184
  const column = cellIds.get(columnId);
@@ -191,13 +196,15 @@ const CellColumn: React.FC<{
191
196
  width={appConfig.width}
192
197
  canDelete={columnsLength > 1}
193
198
  footer={
194
- <AddCellButtons
195
- columnId={columnId}
196
- className={cn(
197
- appConfig.width === "columns" &&
198
- "opacity-0 group-hover/column:opacity-100",
199
- )}
200
- />
199
+ hideControls ? null : (
200
+ <AddCellButtons
201
+ columnId={columnId}
202
+ className={cn(
203
+ appConfig.width === "columns" &&
204
+ "opacity-0 group-hover/column:opacity-100",
205
+ )}
206
+ />
207
+ )
201
208
  }
202
209
  >
203
210
  <SortableContext
@@ -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