@marimo-team/frontend 0.19.3-dev8 → 0.19.4-dev0

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 (179) hide show
  1. package/dist/assets/{CellStatus-BwPGnX3z.js → CellStatus--kUu6N2K.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-KlUs_Sz3.js → ConnectedDataExplorerComponent-BKJwCHu7.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-Drf1manw.js → ErrorBoundary-C7JBxSzd.js} +1 -1
  4. package/dist/assets/{ImperativeModal-q6QlC2aZ.js → ImperativeModal-DVhvP4lH.js} +1 -1
  5. package/dist/assets/{JsonOutput--AuyEErr.js → JsonOutput-BSGE-MRo.js} +5 -5
  6. package/dist/assets/{LazyAnyLanguageCodeMirror-jpEDlD0M.js → LazyAnyLanguageCodeMirror-Cp2punaU.js} +2 -2
  7. package/dist/assets/{MarimoErrorOutput-BZjY8e2w.js → MarimoErrorOutput-CX0SCJOZ.js} +2 -2
  8. package/dist/assets/{RenderHTML-BTLaM20B.js → RenderHTML-Do_PVqRy.js} +1 -1
  9. package/dist/assets/VisuallyHidden-B9t3FhTP.js +1 -0
  10. package/dist/assets/{add-cell-with-ai-BWWVs9qV.js → add-cell-with-ai-manh7kBT.js} +21 -21
  11. package/dist/assets/{add-database-form-Bw_YRH1r.js → add-database-form-CgkV0MRs.js} +2 -2
  12. package/dist/assets/agent-panel-D-OmT-rw.js +287 -0
  13. package/dist/assets/{ai-model-dropdown-BrUOgnWS.js → ai-model-dropdown-DzyBY5VA.js} +1 -1
  14. package/dist/assets/{alert-dialog-k5KxevGr.js → alert-dialog-jcHA5geR.js} +1 -1
  15. package/dist/assets/{any-language-editor-DQu1Tt2N.js → any-language-editor-Cm83E7D_.js} +1 -1
  16. package/dist/assets/{app-config-button-B8CXELx0.js → app-config-button-DC3alCuB.js} +1 -1
  17. package/dist/assets/button-B8cGZzP5.js +1 -0
  18. package/dist/assets/{cache-panel-C1So4Zu3.js → cache-panel-1FqnpB9y.js} +1 -1
  19. package/dist/assets/cell-editor-RHFZmO74.js +23 -0
  20. package/dist/assets/cell-link-Dqj_nfXA.js +1 -0
  21. package/dist/assets/{cells-DU3EySUd.js → cells-BNQUQiDS.js} +49 -49
  22. package/dist/assets/{chat-components-Bc9j9ls4.js → chat-components-CWiXtKu6.js} +1 -1
  23. package/dist/assets/{chat-display-BrTi6c8V.js → chat-display-CGnOamQG.js} +1 -1
  24. package/dist/assets/{chat-panel-8Dym5Gv3.js → chat-panel-Dh1M55c9.js} +2 -2
  25. package/dist/assets/client-CDjmJmVw.js +4 -0
  26. package/dist/assets/{column-preview-Ck6B_-sQ.js → column-preview-CKxT2s-S.js} +1 -1
  27. package/dist/assets/{command-B_minI8b.js → command-YPFTinLj.js} +1 -1
  28. package/dist/assets/{command-palette-BT3u6JBB.js → command-palette-7fVEhKGc.js} +1 -1
  29. package/dist/assets/common-DJkPpBxC.js +1 -0
  30. package/dist/assets/config-D6nhy4FA.js +1 -0
  31. package/dist/assets/context-DHfVoQfl.js +1 -0
  32. package/dist/assets/{copy-icon-B69c-352.js → copy-icon-jWsqdLn1.js} +1 -1
  33. package/dist/assets/{datasource-DCvPlnaJ.js → datasource-DerBLc6V.js} +2 -2
  34. package/dist/assets/{dependency-graph-panel-C9jYZ6pA.js → dependency-graph-panel-Vd-OsVLa.js} +4 -4
  35. package/dist/assets/{dialog-DUEuLcT2.js → dialog-CF5DtF1E.js} +1 -1
  36. package/dist/assets/{dist-DOFFh6Ii.js → dist-Dg7UO_Vw.js} +1 -1
  37. package/dist/assets/{documentation-panel-AsatrTfg.js → documentation-panel-xG2-zpwg.js} +1 -1
  38. package/dist/assets/{download-PR1bF3g_.js → download-B6EJS7Ar.js} +1 -1
  39. package/dist/assets/edit-page-7Hkti2j_.js +12 -0
  40. package/dist/assets/{error-banner-DU5Qb8a8.js → error-banner-DvT0IGDZ.js} +1 -1
  41. package/dist/assets/{error-panel-D_wVKV6I.js → error-panel-BxBpZYvt.js} +1 -1
  42. package/dist/assets/{es-CEE_7T0w.js → es-BoHEdemq.js} +1 -1
  43. package/dist/assets/{field-DDKGFzpC.js → field-Clr_fqUr.js} +1 -1
  44. package/dist/assets/{file-explorer-panel-DltK8JVp.js → file-explorer-panel-C9K0vIPl.js} +1 -1
  45. package/dist/assets/{floating-outline-BfdazXWm.js → floating-outline-DCrTuu2G.js} +1 -1
  46. package/dist/assets/{focus-CtlWIiQr.js → focus-DM53w5BH.js} +1 -1
  47. package/dist/assets/{form-Cy5TkLzh.js → form-BcKfhfZc.js} +2 -2
  48. package/dist/assets/{glide-data-editor-D_bRnWfy.js → glide-data-editor-CRb9AiCG.js} +1 -1
  49. package/dist/assets/{globals-BSLm1nlz.js → globals-Bf30kOQF.js} +1 -1
  50. package/dist/assets/{home-page-CEnaUutq.js → home-page-BRyNf7fl.js} +2 -2
  51. package/dist/assets/index-CBMqMxiq.js +43 -0
  52. package/dist/assets/index-DDc_1b-N.css +2 -0
  53. package/dist/assets/input-B80Yt1uu.js +1 -0
  54. package/dist/assets/{kiosk-mode-BnTZR6mM.js → kiosk-mode-P-NYHJID.js} +1 -1
  55. package/dist/assets/{label-qwandMoh.js → label-CNZLffHW.js} +1 -1
  56. package/dist/assets/{layout-BTiWDrbh.js → layout-DT91GUei.js} +4 -4
  57. package/dist/assets/links-D529u6GQ.js +1 -0
  58. package/dist/assets/{logs-panel-Cnp9tO_1.js → logs-panel-C2dfrRig.js} +1 -1
  59. package/dist/assets/{markdown-renderer-BSrbBHwX.js → markdown-renderer-BPnVa0ym.js} +2 -2
  60. package/dist/assets/{mermaid-BPkO79lo.js → mermaid--ZwxKP7u.js} +1 -1
  61. package/dist/assets/mode-Dq8MKjNR.js +1 -0
  62. package/dist/assets/{multi-map-fjX9ImVF.js → multi-map-CQd4MZr5.js} +1 -1
  63. package/dist/assets/name-cell-input-BaEPC7ON.js +1 -0
  64. package/dist/assets/{outline-panel-DCfj1bI-.js → outline-panel-Cca864H0.js} +1 -1
  65. package/dist/assets/{packages-panel-BiEckVdM.js → packages-panel-Cy_KAYmq.js} +1 -1
  66. package/dist/assets/{panels-BXRys72u.js → panels-BzlLZfye.js} +1 -1
  67. package/dist/assets/{process-output-wGlHkL-Q.js → process-output-Dn1rOp26.js} +1 -1
  68. package/dist/assets/{readonly-python-code-xbh7G2Y2.js → readonly-python-code-CXeF74Iq.js} +1 -1
  69. package/dist/assets/{renderShortcut-D0Pei-OA.js → renderShortcut-eU5Hsfml.js} +1 -1
  70. package/dist/assets/{run-page-BCwJRhCq.js → run-page-CM_n6pXD.js} +1 -1
  71. package/dist/assets/scratchpad-panel-XCkVY3Hp.js +1 -0
  72. package/dist/assets/{secrets-panel-CDWmmmBS.js → secrets-panel-BMY6PPth.js} +1 -1
  73. package/dist/assets/{select-D0g5GnIs.js → select-D9lTzMzP.js} +1 -1
  74. package/dist/assets/{session-panel-DuQl_oQp.js → session-panel-BDt6Y_mU.js} +1 -1
  75. package/dist/assets/{slides-component-MkPkpql1.js → slides-component-Dp0Yv5b0.js} +1 -1
  76. package/dist/assets/{snippets-panel-R_ql6HGu.js → snippets-panel-K-JKJQBf.js} +1 -1
  77. package/dist/assets/state-DWRZTH2y.js +1 -0
  78. package/dist/assets/state-JzO-Ni5T.js +1 -0
  79. package/dist/assets/{switch-CWzL-0WF.js → switch-RowEjq0T.js} +1 -1
  80. package/dist/assets/{terminal-BWM0fOMh.js → terminal-BhbNfCNw.js} +1 -1
  81. package/dist/assets/{textarea-CfvBt_Xm.js → textarea-Di1KKcL4.js} +1 -1
  82. package/dist/assets/{tracing-Kscqc1t3.js → tracing-nvbrZdpf.js} +1 -1
  83. package/dist/assets/{tracing-panel-BEzOflWc.js → tracing-panel-CTXJaO-A.js} +2 -2
  84. package/dist/assets/{types-DhuSHMNQ.js → types-CT2U5Ljy.js} +1 -1
  85. package/dist/assets/{useAddCell-C9lbOVO1.js → useAddCell-COb93CUl.js} +1 -1
  86. package/dist/assets/{useBoolean-B-A0dyIW.js → useBoolean-B_S7yTZz.js} +1 -1
  87. package/dist/assets/{useCellActionButton-fsh9MTAX.js → useCellActionButton-D5Zt1dDz.js} +1 -1
  88. package/dist/assets/{useDateFormatter-CV0QXb5P.js → useDateFormatter-DsANziQR.js} +1 -1
  89. package/dist/assets/useDeleteCell-DHF_xvAh.js +1 -0
  90. package/dist/assets/{useDependencyPanelTab-CngFbla0.js → useDependencyPanelTab-D59iW_MD.js} +1 -1
  91. package/dist/assets/useInterval-BGPIviJp.js +1 -0
  92. package/dist/assets/{useNotebookActions-D01w160c.js → useNotebookActions-DEl-rH-3.js} +1 -1
  93. package/dist/assets/{useNumberFormatter-D8ks3oPN.js → useNumberFormatter-FoXhpyAb.js} +1 -1
  94. package/dist/assets/usePress-DTwIUo40.js +7 -0
  95. package/dist/assets/useRunCells-CKEmgeKM.js +1 -0
  96. package/dist/assets/useSplitCell-D9YiO-z5.js +1 -0
  97. package/dist/assets/{useTheme-DfP1CWaW.js → useTheme-CNj0G_ol.js} +1 -1
  98. package/dist/assets/utilities.esm-DG4qccZc.js +3 -0
  99. package/dist/assets/utils-pfqq9IdB.js +1 -0
  100. package/dist/assets/{vega-component-B8ghmMYW.js → vega-component-C1voDf5W.js} +1 -1
  101. package/dist/assets/{write-secret-modal-CLm48gMe.js → write-secret-modal-hOetwavI.js} +1 -1
  102. package/dist/index.html +56 -56
  103. package/package.json +5 -5
  104. package/src/__mocks__/requests.ts +1 -0
  105. package/src/__tests__/mount.test.ts +128 -0
  106. package/src/components/app-config/__tests__/get-dirty-values.test.ts +1 -1
  107. package/src/components/app-config/ai-config.tsx +328 -28
  108. package/src/components/app-config/user-config-form.tsx +10 -3
  109. package/src/components/chat/acp/agent-panel.tsx +56 -43
  110. package/src/components/chat/chat-utils.ts +0 -19
  111. package/src/components/data-table/column-header.tsx +1 -1
  112. package/src/components/editor/KernelStartupErrorModal.tsx +2 -2
  113. package/src/components/editor/actions/name-cell-input.tsx +10 -4
  114. package/src/components/editor/ai/completion-handlers.tsx +1 -1
  115. package/src/components/editor/alerts/connecting-alert.tsx +33 -6
  116. package/src/components/editor/chrome/types.ts +2 -4
  117. package/src/components/editor/chrome/wrapper/app-chrome.tsx +55 -58
  118. package/src/components/editor/chrome/wrapper/footer-items/runtime-settings.tsx +150 -96
  119. package/src/components/editor/renderers/vertical-layout/__tests__/useFocusFirstEditor.test.ts +27 -0
  120. package/src/components/editor/renderers/vertical-layout/useFocusFirstEditor.ts +6 -0
  121. package/src/components/utils/lazy-mount.tsx +29 -8
  122. package/src/core/ai/ids/ids.ts +12 -4
  123. package/src/core/cells/cells.ts +2 -0
  124. package/src/core/cells/scrollCellIntoView.ts +3 -2
  125. package/src/core/codemirror/cm.ts +2 -0
  126. package/src/core/codemirror/lsp/__tests__/notebook-lsp.test.ts +123 -0
  127. package/src/core/codemirror/lsp/notebook-lsp.ts +44 -4
  128. package/src/core/codemirror/misc/__tests__/string-braces.test.ts +200 -0
  129. package/src/core/codemirror/misc/string-braces.ts +37 -0
  130. package/src/core/config/__tests__/config-schema.test.ts +36 -0
  131. package/src/core/config/config-schema.ts +1 -0
  132. package/src/core/export/__tests__/hooks.test.ts +504 -0
  133. package/src/core/export/hooks.ts +93 -4
  134. package/src/core/islands/bridge.ts +1 -0
  135. package/src/core/kernel/__tests__/handlers.test.ts +2 -2
  136. package/src/core/kernel/state.ts +1 -0
  137. package/src/core/network/__tests__/requests-lazy.test.ts +1 -1
  138. package/src/core/network/__tests__/requests-network.test.ts +0 -18
  139. package/src/core/network/requests-lazy.ts +3 -2
  140. package/src/core/network/requests-network.ts +10 -7
  141. package/src/core/network/requests-static.ts +1 -0
  142. package/src/core/network/requests-toasting.tsx +1 -0
  143. package/src/core/network/types.ts +2 -0
  144. package/src/core/wasm/bridge.ts +1 -0
  145. package/src/css/globals.css +2 -0
  146. package/src/hooks/__tests__/useInterval.test.tsx +104 -0
  147. package/src/hooks/useInterval.ts +32 -6
  148. package/src/mount.tsx +6 -0
  149. package/src/plugins/impl/chat/ChatPlugin.tsx +2 -4
  150. package/src/plugins/impl/chat/chat-ui.tsx +62 -191
  151. package/src/plugins/impl/chat/types.ts +5 -12
  152. package/src/plugins/impl/data-frames/DataFramePlugin.tsx +3 -1
  153. package/src/utils/events.ts +1 -0
  154. package/dist/assets/VisuallyHidden-BodIky8L.js +0 -1
  155. package/dist/assets/agent-panel-Bm-vW8YL.js +0 -287
  156. package/dist/assets/button-DuYGqRtX.js +0 -1
  157. package/dist/assets/cell-editor-Cdtc1m3g.js +0 -23
  158. package/dist/assets/cell-link-CFAPzUg5.js +0 -1
  159. package/dist/assets/client-DfkWorYM.js +0 -4
  160. package/dist/assets/common-jorbwXZC.js +0 -1
  161. package/dist/assets/config-Ba3eeYri.js +0 -1
  162. package/dist/assets/context-BAYdLMF_.js +0 -1
  163. package/dist/assets/edit-page-B1Ed6RKp.js +0 -12
  164. package/dist/assets/index-DNg7_e7t.js +0 -43
  165. package/dist/assets/index-__6MNWbe.css +0 -2
  166. package/dist/assets/input-CaEtLL8p.js +0 -1
  167. package/dist/assets/links-Bpd4gqTj.js +0 -1
  168. package/dist/assets/mode-yhfN-4ye.js +0 -1
  169. package/dist/assets/name-cell-input-CmuWqgFR.js +0 -1
  170. package/dist/assets/scratchpad-panel-C6PpCYtK.js +0 -1
  171. package/dist/assets/state-DEHWsmkM.js +0 -1
  172. package/dist/assets/state-DXAf-ejz.js +0 -1
  173. package/dist/assets/useDeleteCell-ByImoTpm.js +0 -1
  174. package/dist/assets/useInterval-DpipYmgs.js +0 -1
  175. package/dist/assets/usePress-C2LPFxyv.js +0 -7
  176. package/dist/assets/useRunCells-CmnSPQtM.js +0 -1
  177. package/dist/assets/useSplitCell-BTH64tve.js +0 -1
  178. package/dist/assets/utilities.esm-CMQs6YPp.js +0 -3
  179. package/dist/assets/utils-CJJIceVn.js +0 -1
@@ -0,0 +1,504 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import type { ReactNode } from "react";
6
+ import * as React from "react";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { CellId } from "@/core/cells/ids";
9
+ import { CellOutputId } from "@/core/cells/ids";
10
+ import type { CellRuntimeState } from "@/core/cells/types";
11
+ import { useEnrichCellOutputs } from "../hooks";
12
+
13
+ // Mock html-to-image
14
+ vi.mock("html-to-image", () => ({
15
+ toPng: vi.fn(),
16
+ }));
17
+
18
+ // Mock Logger
19
+ vi.mock("@/utils/Logger", () => ({
20
+ Logger: {
21
+ error: vi.fn(),
22
+ },
23
+ }));
24
+
25
+ // Mock cellsRuntimeAtom - must be defined inline in the factory function
26
+ vi.mock("@/core/cells/cells", async () => {
27
+ const { atom } = await import("jotai");
28
+ return {
29
+ cellsRuntimeAtom: atom({}),
30
+ };
31
+ });
32
+
33
+ import { toPng } from "html-to-image";
34
+ import { cellsRuntimeAtom } from "@/core/cells/cells";
35
+ import { Logger } from "@/utils/Logger";
36
+
37
+ describe("useEnrichCellOutputs", () => {
38
+ let store: ReturnType<typeof createStore>;
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ store = createStore();
43
+ });
44
+
45
+ const wrapper = ({ children }: { children: ReactNode }) =>
46
+ React.createElement(Provider, { store }, children);
47
+
48
+ // Helper to set the mocked atom (cast to any to work around type mismatch)
49
+ const setCellsRuntime = (value: Record<CellId, CellRuntimeState>) => {
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ store.set(cellsRuntimeAtom as any, value);
52
+ };
53
+
54
+ const createMockCellRuntimes = (
55
+ cells: Record<string, Partial<CellRuntimeState>>,
56
+ ): Record<CellId, CellRuntimeState> => {
57
+ return Object.fromEntries(
58
+ Object.entries(cells).map(([cellId, cell]) => [
59
+ cellId as CellId,
60
+ {
61
+ output: cell.output || null,
62
+ status: cell.status || "idle",
63
+ interrupted: false,
64
+ errored: false,
65
+ runStartTimestamp: null,
66
+ runElapsedTimeMs: null,
67
+ stallTime: null as unknown as number,
68
+ ...cell,
69
+ } as CellRuntimeState,
70
+ ]),
71
+ ) as Record<CellId, CellRuntimeState>;
72
+ };
73
+
74
+ it("should return empty object when no cells need screenshots", async () => {
75
+ vi.spyOn(document, "getElementById");
76
+
77
+ // Set up cell runtimes with no text/html outputs
78
+ setCellsRuntime(
79
+ createMockCellRuntimes({
80
+ "cell-1": {
81
+ output: {
82
+ channel: "output",
83
+ mimetype: "text/plain",
84
+ data: "Hello World",
85
+ timestamp: 0,
86
+ },
87
+ },
88
+ }),
89
+ );
90
+
91
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
92
+
93
+ const enrichCellOutputs = result.current;
94
+ const output = await enrichCellOutputs();
95
+
96
+ expect(output).toEqual({});
97
+ expect(document.getElementById).not.toHaveBeenCalled();
98
+ expect(toPng).not.toHaveBeenCalled();
99
+ });
100
+
101
+ it("should capture screenshots for cells with text/html output", async () => {
102
+ const cellId = "cell-1" as CellId;
103
+ const mockElement = document.createElement("div");
104
+ const mockDataUrl = "data:image/png;base64,mockImageData";
105
+
106
+ // Mock document.getElementById
107
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
108
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
109
+
110
+ setCellsRuntime(
111
+ createMockCellRuntimes({
112
+ [cellId]: {
113
+ output: {
114
+ channel: "output",
115
+ mimetype: "text/html",
116
+ data: "<div>Chart</div>",
117
+ timestamp: 0,
118
+ },
119
+ },
120
+ }),
121
+ );
122
+
123
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
124
+
125
+ const enrichCellOutputs = result.current;
126
+ const output = await enrichCellOutputs();
127
+
128
+ expect(document.getElementById).toHaveBeenCalledWith(
129
+ CellOutputId.create(cellId),
130
+ );
131
+ expect(toPng).toHaveBeenCalledWith(mockElement);
132
+ expect(output).toEqual({
133
+ [cellId]: ["image/png", mockDataUrl],
134
+ });
135
+ });
136
+
137
+ it("should skip cells where output has not changed", async () => {
138
+ const cellId = "cell-1" as CellId;
139
+ const mockElement = document.createElement("div");
140
+ const mockDataUrl = "data:image/png;base64,mockImageData";
141
+ const htmlData = "<div>Chart</div>";
142
+
143
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
144
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
145
+
146
+ setCellsRuntime(
147
+ createMockCellRuntimes({
148
+ [cellId]: {
149
+ output: {
150
+ channel: "output",
151
+ mimetype: "text/html",
152
+ data: htmlData,
153
+ timestamp: 0,
154
+ },
155
+ },
156
+ }),
157
+ );
158
+
159
+ const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
160
+ wrapper,
161
+ });
162
+
163
+ // First call - should capture
164
+ let enrichCellOutputs = result.current;
165
+ let output = await enrichCellOutputs();
166
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl] });
167
+ expect(toPng).toHaveBeenCalledTimes(1);
168
+
169
+ // Rerender to get updated atom state
170
+ rerender();
171
+
172
+ // Second call with same output - should not capture again
173
+ enrichCellOutputs = result.current;
174
+ output = await enrichCellOutputs();
175
+ expect(output).toEqual({}); // Empty because output hasn't changed
176
+ expect(toPng).toHaveBeenCalledTimes(1); // Still only 1 call
177
+ });
178
+
179
+ it("should handle screenshot errors gracefully", async () => {
180
+ const cellId = "cell-1" as CellId;
181
+ const mockElement = document.createElement("div");
182
+ const error = new Error("Screenshot failed");
183
+
184
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
185
+ vi.mocked(toPng).mockRejectedValue(error);
186
+
187
+ setCellsRuntime(
188
+ createMockCellRuntimes({
189
+ [cellId]: {
190
+ output: {
191
+ channel: "output",
192
+ mimetype: "text/html",
193
+ data: "<div>Chart</div>",
194
+ timestamp: 0,
195
+ },
196
+ },
197
+ }),
198
+ );
199
+
200
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
201
+
202
+ const enrichCellOutputs = result.current;
203
+ const output = await enrichCellOutputs();
204
+
205
+ expect(output).toEqual({}); // Failed screenshot should be filtered out
206
+ expect(Logger.error).toHaveBeenCalledWith(
207
+ `Error screenshotting cell ${cellId}:`,
208
+ error,
209
+ );
210
+ });
211
+
212
+ it("should handle missing DOM elements", async () => {
213
+ const cellId = "cell-1" as CellId;
214
+
215
+ vi.spyOn(document, "getElementById").mockReturnValue(null);
216
+
217
+ setCellsRuntime(
218
+ createMockCellRuntimes({
219
+ [cellId]: {
220
+ output: {
221
+ channel: "output",
222
+ mimetype: "text/html",
223
+ data: "<div>Chart</div>",
224
+ timestamp: 0,
225
+ },
226
+ },
227
+ }),
228
+ );
229
+
230
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
231
+
232
+ const enrichCellOutputs = result.current;
233
+ const output = await enrichCellOutputs();
234
+
235
+ expect(output).toEqual({});
236
+ expect(Logger.error).toHaveBeenCalledWith(
237
+ `Output element not found for cell ${cellId}`,
238
+ );
239
+ expect(toPng).not.toHaveBeenCalled();
240
+ });
241
+
242
+ it("should process multiple cells in parallel", async () => {
243
+ const cell1 = "cell-1" as CellId;
244
+ const cell2 = "cell-2" as CellId;
245
+ const mockElement1 = document.createElement("div");
246
+ const mockElement2 = document.createElement("div");
247
+ const mockDataUrl1 = "data:image/png;base64,image1";
248
+ const mockDataUrl2 = "data:image/png;base64,image2";
249
+
250
+ vi.spyOn(document, "getElementById")
251
+ .mockReturnValueOnce(mockElement1)
252
+ .mockReturnValueOnce(mockElement2);
253
+
254
+ vi.mocked(toPng)
255
+ .mockResolvedValueOnce(mockDataUrl1)
256
+ .mockResolvedValueOnce(mockDataUrl2);
257
+
258
+ setCellsRuntime(
259
+ createMockCellRuntimes({
260
+ [cell1]: {
261
+ output: {
262
+ channel: "output",
263
+ mimetype: "text/html",
264
+ data: "<div>Chart 1</div>",
265
+ timestamp: 0,
266
+ },
267
+ },
268
+ [cell2]: {
269
+ output: {
270
+ channel: "output",
271
+ mimetype: "text/html",
272
+ data: "<div>Chart 2</div>",
273
+ timestamp: 0,
274
+ },
275
+ },
276
+ }),
277
+ );
278
+
279
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
280
+
281
+ const enrichCellOutputs = result.current;
282
+ const output = await enrichCellOutputs();
283
+
284
+ expect(output).toEqual({
285
+ [cell1]: ["image/png", mockDataUrl1],
286
+ [cell2]: ["image/png", mockDataUrl2],
287
+ });
288
+ expect(toPng).toHaveBeenCalledTimes(2);
289
+ });
290
+
291
+ it("should filter out null results from failed screenshots", async () => {
292
+ // Setup: one successful, one failed screenshot
293
+ const cell1 = "cell-1" as CellId;
294
+ const cell2 = "cell-2" as CellId;
295
+ const mockElement1 = document.createElement("div");
296
+ const mockDataUrl = "data:image/png;base64,image1";
297
+
298
+ vi.spyOn(document, "getElementById")
299
+ .mockReturnValueOnce(mockElement1)
300
+ .mockReturnValueOnce(null); // Second cell fails to find element
301
+
302
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
303
+
304
+ setCellsRuntime(
305
+ createMockCellRuntimes({
306
+ [cell1]: {
307
+ output: {
308
+ channel: "output",
309
+ mimetype: "text/html",
310
+ data: "<div>Chart 1</div>",
311
+ timestamp: 0,
312
+ },
313
+ },
314
+ [cell2]: {
315
+ output: {
316
+ channel: "output",
317
+ mimetype: "text/html",
318
+ data: "<div>Chart 2</div>",
319
+ timestamp: 0,
320
+ },
321
+ },
322
+ }),
323
+ );
324
+
325
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
326
+
327
+ const enrichCellOutputs = result.current;
328
+ const output = await enrichCellOutputs();
329
+
330
+ // Only the successful screenshot should be in the result
331
+ expect(output).toEqual({
332
+ [cell1]: ["image/png", mockDataUrl],
333
+ });
334
+ expect(Logger.error).toHaveBeenCalledWith(
335
+ `Output element not found for cell ${cell2}`,
336
+ );
337
+ });
338
+
339
+ it("should only capture screenshots for cells with changed output", async () => {
340
+ const cellId = "cell-1" as CellId;
341
+ const mockElement = document.createElement("div");
342
+ const mockDataUrl1 = "data:image/png;base64,image1";
343
+ const mockDataUrl2 = "data:image/png;base64,image2";
344
+
345
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
346
+ vi.mocked(toPng)
347
+ .mockResolvedValueOnce(mockDataUrl1)
348
+ .mockResolvedValueOnce(mockDataUrl2);
349
+
350
+ // First call - cell should be captured
351
+ setCellsRuntime(
352
+ createMockCellRuntimes({
353
+ [cellId]: {
354
+ output: {
355
+ channel: "output",
356
+ mimetype: "text/html",
357
+ data: "<div>Chart v1</div>",
358
+ timestamp: 0,
359
+ },
360
+ },
361
+ }),
362
+ );
363
+
364
+ const { result, rerender } = renderHook(() => useEnrichCellOutputs(), {
365
+ wrapper,
366
+ });
367
+
368
+ // First screenshot
369
+ let enrichCellOutputs = result.current;
370
+ let output = await enrichCellOutputs();
371
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl1] });
372
+
373
+ // Second call - same output, should not be captured
374
+ rerender();
375
+ enrichCellOutputs = result.current;
376
+ output = await enrichCellOutputs();
377
+ expect(output).toEqual({});
378
+
379
+ // Third call - output changed, should be captured
380
+ setCellsRuntime(
381
+ createMockCellRuntimes({
382
+ [cellId]: {
383
+ output: {
384
+ channel: "output",
385
+ mimetype: "text/html",
386
+ data: "<div>Chart v2</div>", // Changed!
387
+ timestamp: 0,
388
+ },
389
+ },
390
+ }),
391
+ );
392
+
393
+ rerender();
394
+ enrichCellOutputs = result.current;
395
+ output = await enrichCellOutputs();
396
+ expect(output).toEqual({ [cellId]: ["image/png", mockDataUrl2] });
397
+ expect(toPng).toHaveBeenCalledTimes(2);
398
+ });
399
+
400
+ it("should ignore cells with non-text/html mimetype", async () => {
401
+ vi.spyOn(document, "getElementById");
402
+
403
+ setCellsRuntime(
404
+ createMockCellRuntimes({
405
+ "cell-1": {
406
+ output: {
407
+ channel: "output",
408
+ mimetype: "application/json",
409
+ data: '{"key": "value"}',
410
+ timestamp: 0,
411
+ },
412
+ },
413
+ "cell-2": {
414
+ output: {
415
+ channel: "output",
416
+ mimetype: "text/plain",
417
+ data: "Plain text",
418
+ timestamp: 0,
419
+ },
420
+ },
421
+ "cell-3": {
422
+ output: {
423
+ channel: "output",
424
+ mimetype: "image/png",
425
+ data: "data:image/png;base64,existing",
426
+ timestamp: 0,
427
+ },
428
+ },
429
+ }),
430
+ );
431
+
432
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
433
+
434
+ const enrichCellOutputs = result.current;
435
+ const output = await enrichCellOutputs();
436
+
437
+ // None of these should trigger screenshots
438
+ expect(output).toEqual({});
439
+ expect(document.getElementById).not.toHaveBeenCalled();
440
+ expect(toPng).not.toHaveBeenCalled();
441
+ });
442
+
443
+ it("should ignore cells with null or undefined output", async () => {
444
+ vi.spyOn(document, "getElementById");
445
+
446
+ setCellsRuntime(
447
+ createMockCellRuntimes({
448
+ "cell-1": {
449
+ output: null,
450
+ },
451
+ "cell-2": {
452
+ output: undefined,
453
+ },
454
+ }),
455
+ );
456
+
457
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
458
+
459
+ const enrichCellOutputs = result.current;
460
+ const output = await enrichCellOutputs();
461
+
462
+ expect(output).toEqual({});
463
+ expect(document.getElementById).not.toHaveBeenCalled();
464
+ expect(toPng).not.toHaveBeenCalled();
465
+ });
466
+
467
+ it("should return correctly formatted result with CellId and tuple", async () => {
468
+ // Expected format: Record<CellId, ["image/png", string]>
469
+ const cellId = "test-cell" as CellId;
470
+ const mockElement = document.createElement("div");
471
+ const mockDataUrl = "data:image/png;base64,testData";
472
+
473
+ vi.spyOn(document, "getElementById").mockReturnValue(mockElement);
474
+ vi.mocked(toPng).mockResolvedValue(mockDataUrl);
475
+
476
+ setCellsRuntime(
477
+ createMockCellRuntimes({
478
+ [cellId]: {
479
+ output: {
480
+ channel: "output",
481
+ mimetype: "text/html",
482
+ data: "<div>Content</div>",
483
+ timestamp: 0,
484
+ },
485
+ },
486
+ }),
487
+ );
488
+
489
+ const { result } = renderHook(() => useEnrichCellOutputs(), { wrapper });
490
+
491
+ const enrichCellOutputs = result.current;
492
+ const output = await enrichCellOutputs();
493
+
494
+ // Verify the exact return type structure
495
+ expect(output).toHaveProperty(cellId);
496
+ const cellOutput = output[cellId];
497
+ expect(cellOutput).toBeDefined();
498
+ expect(Array.isArray(cellOutput)).toBe(true);
499
+ if (cellOutput) {
500
+ expect(cellOutput[0]).toBe("image/png");
501
+ expect(cellOutput[1]).toBe(mockDataUrl);
502
+ }
503
+ });
504
+ });
@@ -1,7 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- import { useAtomValue } from "jotai";
2
+ import { toPng } from "html-to-image";
3
+ import { atom, useAtom, useAtomValue } from "jotai";
3
4
  import { appConfigAtom } from "@/core/config/config";
4
5
  import { useInterval } from "@/hooks/useInterval";
6
+ import { Logger } from "@/utils/Logger";
7
+ import { Objects } from "@/utils/objects";
8
+ import { cellsRuntimeAtom } from "../cells/cells";
9
+ import { type CellId, CellOutputId } from "../cells/ids";
5
10
  import { connectionAtom } from "../network/connection";
6
11
  import { useRequestClient } from "../network/requests";
7
12
  import { VirtualFileTracker } from "../static/virtual-file-tracker";
@@ -21,8 +26,13 @@ export function useAutoExport() {
21
26
  const markdownDisabled = !markdownEnabled || !isConnected;
22
27
  const htmlDisabled = !htmlEnabled || !isConnected;
23
28
  const ipynbDisabled = !ipynbEnabled || !isConnected;
24
- const { autoExportAsHTML, autoExportAsIPYNB, autoExportAsMarkdown } =
25
- useRequestClient();
29
+ const {
30
+ autoExportAsHTML,
31
+ autoExportAsIPYNB,
32
+ autoExportAsMarkdown,
33
+ updateCellOutputs,
34
+ } = useRequestClient();
35
+ const takeScreenshots = useEnrichCellOutputs();
26
36
 
27
37
  useInterval(
28
38
  async () => {
@@ -50,12 +60,91 @@ export function useAutoExport() {
50
60
 
51
61
  useInterval(
52
62
  async () => {
63
+ const cellsToOutput = await takeScreenshots();
64
+ if (Object.keys(cellsToOutput).length > 0) {
65
+ await updateCellOutputs({
66
+ cellIdsToOutput: cellsToOutput,
67
+ });
68
+ }
53
69
  await autoExportAsIPYNB({
54
70
  download: false,
55
71
  });
56
72
  },
57
73
  // Run every 5 seconds, or when the document becomes visible
58
74
  // Ignore if the document is not visible
59
- { delayMs: DELAY, whenVisible: true, disabled: ipynbDisabled },
75
+ // Skip if running to ensure no race conditions between screenshot and export
76
+ {
77
+ delayMs: DELAY,
78
+ whenVisible: true,
79
+ disabled: ipynbDisabled,
80
+ skipIfRunning: true,
81
+ },
60
82
  );
61
83
  }
84
+
85
+ // We track cells that need screenshots, these will be exported to IPYNB
86
+ const richCellsToOutputAtom = atom<Record<CellId, unknown>>({});
87
+
88
+ /**
89
+ * Take screenshots of cells with HTML outputs. These images will be sent to the backend to be exported to IPYNB.
90
+ * @returns A map of cell IDs to their screenshots data.
91
+ */
92
+ export function useEnrichCellOutputs() {
93
+ const [richCellsOutput, setRichCellsOutput] = useAtom(richCellsToOutputAtom);
94
+ const cellRuntimes = useAtomValue(cellsRuntimeAtom);
95
+
96
+ return async (): Promise<Record<CellId, ["image/png", string]>> => {
97
+ const trackedCellsOutput: Record<CellId, unknown> = {};
98
+
99
+ const cellsToCaptureScreenshot: [CellId, unknown][] = [];
100
+ for (const [cellId, runtime] of Objects.entries(cellRuntimes)) {
101
+ const outputData = runtime.output?.data;
102
+ const outputHasChanged = richCellsOutput[cellId] !== outputData;
103
+ // Track latest output for this cell
104
+ trackedCellsOutput[cellId] = outputData;
105
+ if (
106
+ runtime.output?.mimetype === "text/html" &&
107
+ outputData &&
108
+ outputHasChanged
109
+ ) {
110
+ cellsToCaptureScreenshot.push([cellId, runtime]);
111
+ }
112
+ }
113
+ // Always update tracked outputs, this ensures data is fresh for the next run
114
+ setRichCellsOutput(trackedCellsOutput);
115
+
116
+ if (cellsToCaptureScreenshot.length === 0) {
117
+ return {};
118
+ }
119
+
120
+ // Capture screenshots
121
+ const results = await Promise.all(
122
+ cellsToCaptureScreenshot.map(async ([cellId]) => {
123
+ const outputElement = document.getElementById(
124
+ CellOutputId.create(cellId),
125
+ );
126
+ if (!outputElement) {
127
+ Logger.error(`Output element not found for cell ${cellId}`);
128
+ return null;
129
+ }
130
+
131
+ try {
132
+ const dataUrl = await toPng(outputElement);
133
+ return [cellId, ["image/png", dataUrl]] as [
134
+ CellId,
135
+ ["image/png", string],
136
+ ];
137
+ } catch (error) {
138
+ Logger.error(`Error screenshotting cell ${cellId}:`, error);
139
+ return null;
140
+ }
141
+ }),
142
+ );
143
+
144
+ return Objects.fromEntries(
145
+ results.filter(
146
+ (result): result is [CellId, ["image/png", string]] => result !== null,
147
+ ),
148
+ );
149
+ };
150
+ }
@@ -171,6 +171,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
171
171
  autoExportAsHTML = throwNotImplemented;
172
172
  autoExportAsMarkdown = throwNotImplemented;
173
173
  autoExportAsIPYNB = throwNotImplemented;
174
+ updateCellOutputs = throwNotImplemented;
174
175
  addPackage = throwNotImplemented;
175
176
  removePackage = throwNotImplemented;
176
177
  getPackageList = throwNotImplemented;
@@ -142,8 +142,8 @@ describe("buildCellData", () => {
142
142
  cell2: "y = 2",
143
143
  },
144
144
  last_execution_time: {
145
- cell1: 1234567890,
146
- cell2: 1234567891,
145
+ cell1: 1_234_567_890,
146
+ cell2: 1_234_567_891,
147
147
  },
148
148
  app_config: {
149
149
  width: "normal",
@@ -1,3 +1,4 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
1
2
  import { atom } from "jotai";
2
3
  import { waitFor } from "../state/jotai";
3
4
 
@@ -1,4 +1,4 @@
1
- /* Copyright 2024 Marimo. All rights reserved. */
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import type { RuntimeManager } from "../../runtime/runtime";
@@ -34,12 +34,6 @@ vi.mock("../connection", () => ({
34
34
  waitForConnectionOpen: vi.fn().mockResolvedValue(undefined),
35
35
  }));
36
36
 
37
- vi.mock("../../state/jotai", () => ({
38
- store: {
39
- get: vi.fn(() => true),
40
- },
41
- }));
42
-
43
37
  describe("createNetworkRequests", () => {
44
38
  let mockClient: any;
45
39
  let capturedCalls: Map<string, { hasParams: boolean; endpoint: string }>;
@@ -80,18 +74,6 @@ describe("createNetworkRequests", () => {
80
74
  });
81
75
 
82
76
  describe("special behavior", () => {
83
- it("sendRun should drop requests if not connected", async () => {
84
- const { store } = await import("../../state/jotai");
85
-
86
- vi.mocked(store.get).mockReturnValue(false);
87
-
88
- const requests = createNetworkRequests();
89
- const result = await requests.sendRun({} as any);
90
-
91
- expect(result).toBe(null);
92
- expect(mockClient.POST).not.toHaveBeenCalled();
93
- });
94
-
95
77
  it("exportAsHTML should set assetUrl in dev/test mode", async () => {
96
78
  const originalEnv = process.env.NODE_ENV;
97
79
  process.env.NODE_ENV = "development";