@marimo-team/frontend 0.25.3-dev129 → 0.25.3-dev134

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 (166) hide show
  1. package/dist/assets/{CellStatus-6xDEj3Hs.js → CellStatus-DC6FFQOg.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-DCcLAktu.js → ConnectedDataExplorerComponent-BV1mVpaE.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-98Xo04DS.js → ErrorBoundary-Ce-Ujlwj.js} +2 -2
  4. package/dist/assets/{JsonOutput-V47aiTIK.js → JsonOutput-CfEBzaqW.js} +4 -4
  5. package/dist/assets/{MarimoErrorOutput-DeWZNBgL.js → MarimoErrorOutput-Bh6o9zle.js} +1 -1
  6. package/dist/assets/{RenderHTML-Bjvcs70-.js → RenderHTML-COGZsXQV.js} +1 -1
  7. package/dist/assets/{RunButton-CnvvXD9d.js → RunButton-DQ51plUv.js} +1 -1
  8. package/dist/assets/{add-cell-with-ai-BbuqzITi.js → add-cell-with-ai-D7gtGUsg.js} +1 -1
  9. package/dist/assets/{add-connection-dialog-DIC7owvZ.js → add-connection-dialog-CpMCc97S.js} +1 -1
  10. package/dist/assets/{agent-panel-D8hO2P1x.js → agent-panel-BSFYqJ9B.js} +2 -2
  11. package/dist/assets/{ai-model-dropdown-DgmiG_5j.js → ai-model-dropdown-D575Vid8.js} +3 -3
  12. package/dist/assets/{app-config-button-UZHv1Iim.js → app-config-button-Cfcg_5Bz.js} +1 -1
  13. package/dist/assets/{blob-BPknzzkY.js → blob-CszMu60N.js} +1 -1
  14. package/dist/assets/{cell-editor-CM-K1_wq.js → cell-editor-BLpOlLnM.js} +2 -2
  15. package/dist/assets/cell-link-Cj0gGaMV.js +1 -0
  16. package/dist/assets/{cells-BypTg035.js → cells-SvbLY10y.js} +1 -1
  17. package/dist/assets/{chat-display-BRLNOoTF.js → chat-display-D43_g3TX.js} +1 -1
  18. package/dist/assets/{chat-panel-D02Rk9we.js → chat-panel-B_HAJMwx.js} +1 -1
  19. package/dist/assets/{chat-ui-DFN_RwxF.js → chat-ui-BEcYCphu.js} +2 -2
  20. package/dist/assets/{column-preview-85ORMhfB.js → column-preview-CrzaJKk8.js} +1 -1
  21. package/dist/assets/{command-palette-CtyjjdsA.js → command-palette-Bm9yIVcD.js} +1 -1
  22. package/dist/assets/{common-J265II7i.js → common-BJyUxCzv.js} +1 -1
  23. package/dist/assets/{components-Bd3Dpi92.js → components-D1f_Cz4i.js} +1 -1
  24. package/dist/assets/{datasource-Dy4rzzSP.js → datasource-C8KSVfSD.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-ByGFFT4E.js → dependency-graph-panel-BhmyJbUl.js} +1 -1
  26. package/dist/assets/{documentation-panel-LxuJCY3M.js → documentation-panel-DcVG0Q0d.js} +1 -1
  27. package/dist/assets/{download-BsBJWH_j.js → download-Bx52VRE4.js} +1 -1
  28. package/dist/assets/{edit-page--d6X-OO9.js → edit-page-DUrwOHR-.js} +6 -6
  29. package/dist/assets/{error-entries-0wTSSuK9.js → error-entries-11OuHzUF.js} +1 -1
  30. package/dist/assets/{error-panel-ZNdbE3Fo.js → error-panel-XJy5S25F.js} +1 -1
  31. package/dist/assets/{feedback-button-C6asPmsh.js → feedback-button-B7jh_B4w.js} +1 -1
  32. package/dist/assets/feedback-button-C-eKtxXE.js +1 -0
  33. package/dist/assets/{file-explorer-panel-0fUP_ptL.js → file-explorer-panel-CnVxbF4A.js} +1 -1
  34. package/dist/assets/{file-name-input-B8lzI5iS.js → file-name-input-BJuqjQqH.js} +2 -2
  35. package/dist/assets/floating-outline-8yyZ-MOJ.js +1 -0
  36. package/dist/assets/{gallery-page-ClR8U-km.js → gallery-page-DJvGHePf.js} +1 -1
  37. package/dist/assets/{glide-data-editor-B6QQBQwS.js → glide-data-editor-Cyc8DHZx.js} +1 -1
  38. package/dist/assets/{home-page-CJnqDcT9.js → home-page-tRZJmSU6.js} +1 -1
  39. package/dist/assets/{hooks-CCek4i9f.js → hooks-CxGwybGw.js} +1 -1
  40. package/dist/assets/index-CoxbqhGH.js +30 -0
  41. package/dist/assets/index-u08m0hQE.css +2 -0
  42. package/dist/assets/{layout-66Urh4Zg.js → layout-DvXJxBiL.js} +5 -5
  43. package/dist/assets/{logs-panel-DFhkIcge.js → logs-panel-KtsY7TrR.js} +1 -1
  44. package/dist/assets/{markdown-renderer-mYYQYD2m.js → markdown-renderer-DUgV7_fm.js} +1 -1
  45. package/dist/assets/{name-cell-input-Crcu3i2z.js → name-cell-input-C3-Cj-Ek.js} +1 -1
  46. package/dist/assets/{outline-panel-BFIPK0H_.js → outline-panel-BzyY4JA6.js} +1 -1
  47. package/dist/assets/{packages-panel-B4KtjgkF.js → packages-panel-DxosbWko.js} +1 -1
  48. package/dist/assets/{panel-accordion-state-D6KtwSF8.js → panel-accordion-state-BRbTib2B.js} +1 -1
  49. package/dist/assets/panels-Da3071Q4.js +1 -0
  50. package/dist/assets/{process-output-DDvMd6Jh.js → process-output-CSwx7rvo.js} +1 -1
  51. package/dist/assets/{readonly-code-display-VDqYn2d0.js → readonly-code-display-B9y9423Y.js} +1 -1
  52. package/dist/assets/{readonly-python-code-CZUKLXJ4.js → readonly-python-code-Dgw6trlk.js} +1 -1
  53. package/dist/assets/{reveal-component-BMmDU1uN.js → reveal-component-B9A4wkG-.js} +1 -1
  54. package/dist/assets/{run-page-BmOPPh2Y.js → run-page-C1MQ5b_A.js} +1 -1
  55. package/dist/assets/{runtime-details-state-CipqynUD.js → runtime-details-state-D_hOQL9T.js} +1 -1
  56. package/dist/assets/scratchpad-panel-CBCsPmMW.js +1 -0
  57. package/dist/assets/secrets-panel-ConQpchl.js +1 -0
  58. package/dist/assets/session-panel-CiHCKPlx.js +1 -0
  59. package/dist/assets/snippets-panel-C0VDE_4i.js +1 -0
  60. package/dist/assets/{state-QfGh3m2G.js → state-DrraMmqz.js} +1 -1
  61. package/dist/assets/{state-BLGvzMB6.js → state-DvU2AiFx.js} +22 -22
  62. package/dist/assets/tracing-C5d51Nmi.js +1 -0
  63. package/dist/assets/{tracing-panel-CsHuzRGP.js → tracing-panel-B89R8dWE.js} +2 -2
  64. package/dist/assets/{tree-actions-BQw_utcj.js → tree-actions-Cph8FVzh.js} +1 -1
  65. package/dist/assets/{useAddCell-_b8InOZC.js → useAddCell-VPkB72pn.js} +1 -1
  66. package/dist/assets/{useCellActionButton-BbgMzBdk.js → useCellActionButton--zI3GFnt.js} +1 -1
  67. package/dist/assets/{useDeleteCell-DhoFbEkU.js → useDeleteCell-CjewKeSr.js} +1 -1
  68. package/dist/assets/{useDependencyPanelTab-COTULhoH.js → useDependencyPanelTab-Bt_ka-wl.js} +1 -1
  69. package/dist/assets/{useNotebookActions-xnA_l3nY.js → useNotebookActions-DV2S6KaG.js} +1 -1
  70. package/dist/assets/{useRunCells-VBCe6CLn.js → useRunCells-BupCZpzk.js} +1 -1
  71. package/dist/assets/{useSplitCell-AvSpVAmq.js → useSplitCell-CaLIR0TS.js} +1 -1
  72. package/dist/index.html +21 -21
  73. package/package.json +1 -1
  74. package/src/__tests__/module-entry.test.ts +32 -0
  75. package/src/components/ai/__tests__/ai-model-dropdown.test.tsx +63 -0
  76. package/src/components/ai/ai-model-dropdown.tsx +11 -6
  77. package/src/components/chat/acp/__tests__/session-tabs.test.tsx +43 -0
  78. package/src/components/chat/acp/blocks.tsx +1 -0
  79. package/src/components/chat/acp/session-tabs.tsx +8 -3
  80. package/src/components/data-table/TableTopBar.tsx +2 -0
  81. package/src/components/data-table/__tests__/data-table.test.tsx +1 -0
  82. package/src/components/data-table/__tests__/keyboard-selection.test.tsx +188 -0
  83. package/src/components/data-table/data-table.tsx +6 -0
  84. package/src/components/data-table/range-focus/atoms.ts +4 -6
  85. package/src/components/data-table/range-focus/use-cell-range-selection.ts +65 -38
  86. package/src/components/data-table/range-focus/use-scroll-into-view.ts +21 -2
  87. package/src/components/data-table/renderers.tsx +16 -7
  88. package/src/components/data-table/row-viewer-panel/__tests__/row-viewer.test.tsx +24 -1
  89. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  90. package/src/components/datasources/datasources.tsx +1 -0
  91. package/src/components/editor/__tests__/package-alert.test.tsx +22 -1
  92. package/src/components/editor/cell/collapse.tsx +5 -3
  93. package/src/components/editor/chrome/panels/outline/__tests__/outline-keyboard.test.tsx +84 -0
  94. package/src/components/editor/chrome/panels/outline/floating-outline.tsx +39 -14
  95. package/src/components/editor/chrome/panels/outline/useActiveOutline.tsx +3 -2
  96. package/src/components/editor/chrome/panels/secrets-panel.tsx +1 -0
  97. package/src/components/editor/chrome/panels/snippets-panel.tsx +5 -1
  98. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +4 -4
  99. package/src/components/editor/controls/__tests__/keyboard-shortcuts.test.tsx +61 -0
  100. package/src/components/editor/controls/keyboard-shortcuts.tsx +14 -5
  101. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +4 -4
  102. package/src/components/editor/file-tree/__tests__/file-explorer-navigation.test.tsx +47 -0
  103. package/src/components/editor/file-tree/file-explorer.tsx +35 -3
  104. package/src/components/editor/file-tree/file-name-input.tsx +1 -0
  105. package/src/components/editor/header/status.tsx +28 -5
  106. package/src/components/editor/links/__tests__/cell-link.test.tsx +146 -0
  107. package/src/components/editor/links/cell-link.tsx +14 -5
  108. package/src/components/editor/navigation/focus-utils.ts +4 -4
  109. package/src/components/editor/notebook-cell.tsx +11 -2
  110. package/src/components/editor/output/MarimoTracebackOutput.tsx +17 -15
  111. package/src/components/editor/output/__tests__/traceback.test.tsx +27 -1
  112. package/src/components/editor/package-alert.tsx +8 -3
  113. package/src/components/scratchpad/scratchpad.test.tsx +95 -0
  114. package/src/components/scratchpad/scratchpad.tsx +14 -1
  115. package/src/components/tracing/tracing-keyboard.test.tsx +85 -0
  116. package/src/components/tracing/tracing.tsx +43 -39
  117. package/src/components/ui/__tests__/draggable-popover.test.tsx +24 -0
  118. package/src/components/ui/draggable-popover.tsx +24 -2
  119. package/src/core/attachments/state.ts +2 -2
  120. package/src/core/document/__tests__/codemirror.test.ts +3 -1
  121. package/src/core/document/__tests__/hooks.test.tsx +7 -7
  122. package/src/core/document/__tests__/model.test.ts +44 -11
  123. package/src/core/document/__tests__/scenario-fixture.ts +2 -1
  124. package/src/core/document/__tests__/test-notebook.ts +19 -9
  125. package/src/core/document/draft.ts +1 -1
  126. package/src/core/document/index.ts +3 -10
  127. package/src/core/document/model.ts +14 -11
  128. package/src/core/document/state.ts +1 -1
  129. package/src/core/document/sync.ts +24 -18
  130. package/src/core/host/AGENTS.md +3 -7
  131. package/src/core/host/__tests__/fixtures.ts +30 -0
  132. package/src/core/host/__tests__/{notebook-client.test.ts → replica.test.ts} +42 -74
  133. package/src/core/host/__tests__/wasm.test.ts +8 -0
  134. package/src/core/host/backend.ts +4 -0
  135. package/src/core/host/index.ts +14 -0
  136. package/src/core/host/{notebook-client.ts → replica.ts} +75 -86
  137. package/src/core/host/static.ts +2 -0
  138. package/src/core/host/wasm.ts +10 -1
  139. package/src/core/wasm/bridge.ts +1 -1
  140. package/src/core/websocket/__tests__/backend.test.ts +111 -22
  141. package/src/core/websocket/__tests__/useNotebookReplica.hook.test.tsx +141 -0
  142. package/src/core/websocket/__tests__/useNotebookSession.hook.test.tsx +4 -4
  143. package/src/core/websocket/backend.ts +43 -5
  144. package/src/core/websocket/connection-state.ts +1 -1
  145. package/src/core/websocket/state.ts +3 -3
  146. package/src/core/websocket/transports/__tests__/notebook.test.ts +11 -21
  147. package/src/core/websocket/types.ts +2 -2
  148. package/src/core/websocket/useNotebookReplica.ts +69 -0
  149. package/src/core/websocket/useNotebookSession.tsx +6 -6
  150. package/src/plugins/impl/chat/chat-ui.tsx +2 -1
  151. package/src/plugins/impl/data-explorer/components/column-summary.tsx +4 -2
  152. package/src/plugins/impl/data-frames/__tests__/sidebar.test.tsx +160 -0
  153. package/src/plugins/impl/data-frames/panel.tsx +43 -16
  154. package/dist/assets/cell-link-CAjuFcLK.js +0 -1
  155. package/dist/assets/feedback-button-Bt89tGG2.js +0 -1
  156. package/dist/assets/floating-outline-CRpmOv32.js +0 -1
  157. package/dist/assets/index-B5pWkt7a.js +0 -30
  158. package/dist/assets/index-BnhSnwvB.css +0 -2
  159. package/dist/assets/panels-DiwGKPzK.js +0 -1
  160. package/dist/assets/scratchpad-panel-Drr_kuwb.js +0 -1
  161. package/dist/assets/secrets-panel-D-W1PL76.js +0 -1
  162. package/dist/assets/session-panel-W7JOcog_.js +0 -1
  163. package/dist/assets/snippets-panel-cUPEdm66.js +0 -1
  164. package/dist/assets/tracing-BNFynaTu.js +0 -1
  165. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +0 -228
  166. package/src/core/websocket/useNotebookClient.ts +0 -89
@@ -52,6 +52,7 @@ export const Tracing: React.FC = () => {
52
52
 
53
53
  const { theme } = useTheme();
54
54
  const [chartPosition, setChartPosition] = useState<ChartPosition>("above");
55
+ const chartPositionId = React.useId();
55
56
  const panelSection = usePanelSection();
56
57
 
57
58
  const toggleChartPosition = () => {
@@ -76,11 +77,12 @@ export const Tracing: React.FC = () => {
76
77
  <div className="py-1 px-2 overflow-y-scroll h-full">
77
78
  <div className="flex flex-row justify-start gap-3">
78
79
  <div className="flex flex-row gap-1 items-center">
79
- <label htmlFor="chartPosition" className="text-xs">
80
+ <label htmlFor={chartPositionId} className="text-xs">
80
81
  Inline chart
81
82
  </label>
82
83
  <input
83
84
  type="checkbox"
85
+ id={chartPositionId}
84
86
  name="chartPosition"
85
87
  data-testid="chartPosition"
86
88
  onClick={toggleChartPosition}
@@ -163,28 +165,31 @@ const TraceBlock: React.FC<{
163
165
  const chevron = <Icon height={16} className="inline" />;
164
166
 
165
167
  const traceTitle = (
166
- <span className="text-sm cursor-pointer" onClick={onToggleExpanded}>
168
+ <button
169
+ type="button"
170
+ aria-expanded={isExpanded}
171
+ className="text-sm cursor-pointer"
172
+ onClick={onToggleExpanded}
173
+ >
167
174
  Run - {formatLogTimestamp(run.runStartTime)}
168
175
  {chevron}
169
- </span>
176
+ </button>
170
177
  );
171
178
 
172
- if (!isExpanded) {
173
- return (
174
- <div key={run.runId} className="flex flex-col">
175
- <pre className="font-mono font-semibold">{traceTitle}</pre>
176
- </div>
177
- );
178
- }
179
-
180
179
  return (
181
- <TraceBlockBody
182
- key={run.runId}
183
- run={run}
184
- chartPosition={chartPosition}
185
- theme={theme}
186
- title={traceTitle}
187
- />
180
+ <div
181
+ className={cn(
182
+ "grid grid-cols-1",
183
+ isExpanded &&
184
+ chartPosition === "sideBySide" &&
185
+ "grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr]",
186
+ )}
187
+ >
188
+ <pre className="font-mono font-semibold">{traceTitle}</pre>
189
+ {isExpanded && (
190
+ <TraceBlockBody run={run} chartPosition={chartPosition} theme={theme} />
191
+ )}
192
+ </div>
188
193
  );
189
194
  };
190
195
 
@@ -192,8 +197,7 @@ const TraceBlockBody: React.FC<{
192
197
  run: Run;
193
198
  chartPosition: ChartPosition;
194
199
  theme: ResolvedTheme;
195
- title: React.ReactNode;
196
- }> = ({ run, chartPosition, theme, title }) => {
200
+ }> = ({ run, chartPosition, theme }) => {
197
201
  const [hoveredCellId, setHoveredCellId] = useState<CellId | null>();
198
202
  const vegaRef = useRef<HTMLDivElement>(null);
199
203
  const { ref, width = 300 } = useResizeObserver<HTMLDivElement>();
@@ -264,16 +268,22 @@ const TraceBlockBody: React.FC<{
264
268
  }, [embed]);
265
269
 
266
270
  const traceRows = (
267
- <TraceRows
268
- run={run}
269
- hoveredCellId={hoveredCellId}
270
- hiddenInputElementId={hiddenInputElementId}
271
- />
271
+ <div className="font-mono font-semibold">
272
+ <TraceRows
273
+ run={run}
274
+ hoveredCellId={hoveredCellId}
275
+ hiddenInputElementId={hiddenInputElementId}
276
+ />
277
+ </div>
272
278
  );
273
279
 
274
280
  const chartElement = (
275
281
  <div
276
- className={chartPosition === "sideBySide" ? "-mt-0.5 flex-1" : ""}
282
+ className={
283
+ chartPosition === "sideBySide"
284
+ ? "-mt-0.5 col-start-2 row-start-1 row-span-2 min-w-0"
285
+ : "min-w-0"
286
+ }
277
287
  ref={ref}
278
288
  >
279
289
  <Suspense>
@@ -284,24 +294,18 @@ const TraceBlockBody: React.FC<{
284
294
 
285
295
  if (chartPosition === "above") {
286
296
  return (
287
- <div key={run.runId} className="flex flex-col">
288
- <pre className="font-mono font-semibold">
289
- {title}
290
- {chartElement}
291
- {traceRows}
292
- </pre>
293
- </div>
297
+ <>
298
+ {chartElement}
299
+ {traceRows}
300
+ </>
294
301
  );
295
302
  }
296
303
 
297
304
  return (
298
- <div key={run.runId} className="flex flex-row">
299
- <pre className="font-mono font-semibold">
300
- {title}
301
- {traceRows}
302
- </pre>
305
+ <>
306
+ {traceRows}
303
307
  {chartElement}
304
- </div>
308
+ </>
305
309
  );
306
310
  };
307
311
 
@@ -0,0 +1,24 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { expect, it } from "vitest";
5
+ import { DraggablePopover } from "../draggable-popover";
6
+
7
+ it("moves from the keyboard handle without intercepting child input arrows", () => {
8
+ render(
9
+ <DraggablePopover open={true}>
10
+ <input aria-label="Filter" />
11
+ </DraggablePopover>,
12
+ );
13
+ const handle = screen.getByRole("button", {
14
+ name: "Move popup with arrow keys",
15
+ });
16
+ const popup = screen.getByRole("dialog");
17
+ fireEvent.keyDown(handle, { key: "ArrowRight" });
18
+ fireEvent.keyDown(handle, { key: "ArrowDown" });
19
+ expect(popup).toHaveStyle({ left: "10px", top: "10px" });
20
+ expect(
21
+ fireEvent.keyDown(screen.getByRole("textbox"), { key: "ArrowLeft" }),
22
+ ).toBe(true);
23
+ expect(popup).toHaveStyle({ left: "10px", top: "10px" });
24
+ });
@@ -53,14 +53,36 @@ export const DraggablePopover = ({
53
53
  top: position.y,
54
54
  }}
55
55
  >
56
- <div
56
+ <button
57
+ type="button"
58
+ aria-label="Move popup with arrow keys"
57
59
  onMouseDown={handleMouseDown}
60
+ onKeyDown={(e) => {
61
+ const directions: Record<
62
+ string,
63
+ { x: number; y: number } | undefined
64
+ > = {
65
+ ArrowLeft: { x: -10, y: 0 },
66
+ ArrowRight: { x: 10, y: 0 },
67
+ ArrowUp: { x: 0, y: -10 },
68
+ ArrowDown: { x: 0, y: 10 },
69
+ };
70
+ const offset = directions[e.key];
71
+ if (offset) {
72
+ e.preventDefault();
73
+ e.stopPropagation();
74
+ setPosition((previous) => ({
75
+ x: previous.x + offset.x,
76
+ y: previous.y + offset.y,
77
+ }));
78
+ }
79
+ }}
58
80
  className={`flex items-center justify-center absolute top-0 left-1/2 -translate-x-1/2 ${
59
81
  isDragging ? "cursor-grabbing" : "cursor-grab"
60
82
  }`}
61
83
  >
62
84
  <GripHorizontalIcon className="h-5 w-5 mt-1 text-muted-foreground/40" />
63
- </div>
85
+ </button>
64
86
  {children}
65
87
  </PopoverContent>
66
88
  </Popover>
@@ -2,12 +2,12 @@
2
2
 
3
3
  import { atom } from "jotai";
4
4
  import type { Attachment } from "@marimo-team/client/protocol";
5
- import { notebookClientAtom } from "@/core/websocket/state";
5
+ import { notebookReplicaAtom } from "@/core/websocket/state";
6
6
 
7
7
  export type { Attachment } from "@marimo-team/client/protocol";
8
8
 
9
9
  export const agentAttachmentsAtom = atom((get) => {
10
- const notebook = get(notebookClientAtom);
10
+ const notebook = get(notebookReplicaAtom);
11
11
  return (notebook ? get(notebook.attachmentsAtom) : []).filter(
12
12
  (attachment) => attachment.kind === "agent",
13
13
  );
@@ -24,6 +24,7 @@ import { SCHEMA_CACHE } from "../../codemirror/language/languages/sql/completion
24
24
  import type { Document } from "../../host/resources/document";
25
25
  import { cellCodeBinding } from "../codemirror";
26
26
  import { createDocumentModel } from "../model";
27
+ import { fakeBackend } from "../../host/__tests__/fixtures";
27
28
  import { cell, document, withCells } from "./fixtures";
28
29
 
29
30
  const views: EditorView[] = [];
@@ -87,7 +88,8 @@ function setup({
87
88
  lifetimes.push(controller);
88
89
  const model = createDocumentModel({
89
90
  store,
90
- notebook: { documentAtom: source, edit: () => new Promise(() => {}) },
91
+ documentAtom: source,
92
+ backendAtom: atom(fakeBackend({ edit: () => new Promise(() => {}) })),
91
93
  signal: controller.signal,
92
94
  });
93
95
  const editor = (text = doc) => {
@@ -15,11 +15,10 @@ import {
15
15
  useCellOrder,
16
16
  useDocumentEdit,
17
17
  useDocumentSync,
18
- type DocumentHost,
19
18
  } from "..";
20
19
  import { cell, CONFIG, document, withCells } from "./fixtures";
20
+ import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
21
21
 
22
- type HostEdit = DocumentHost["edit"];
23
22
  const lifetimes: AbortController[] = [];
24
23
 
25
24
  afterEach(() => {
@@ -28,7 +27,7 @@ afterEach(() => {
28
27
 
29
28
  function setup(
30
29
  initial: Document | null = document(),
31
- edit: HostEdit = () => new Promise(() => {}),
30
+ edit: FakeEdit = () => new Promise(() => {}),
32
31
  ) {
33
32
  const store = createStore();
34
33
  const source = atom(initial);
@@ -38,7 +37,8 @@ function setup(
38
37
  lifetimes.push(controller);
39
38
  const model = createDocumentModel({
40
39
  store,
41
- notebook: { documentAtom: source, edit },
40
+ documentAtom: source,
41
+ backendAtom: atom(fakeBackend({ edit })),
42
42
  signal: controller.signal,
43
43
  });
44
44
  const wrapper = ({ children }: PropsWithChildren) => (
@@ -53,14 +53,14 @@ describe("document hooks", () => {
53
53
  it("flushes edits through the model and aborts work when the session ends", async () => {
54
54
  const accepted = withCells(document(), { c1: cell("c1", "local") });
55
55
  const edit = vi
56
- .fn<HostEdit>()
56
+ .fn<FakeEdit>()
57
57
  .mockResolvedValueOnce({
58
58
  ok: true,
59
59
  status: 200,
60
60
  data: { document: accepted.revision, cells: {} },
61
61
  })
62
62
  .mockImplementation(
63
- () => new Deferred<Awaited<ReturnType<HostEdit>>>().promise,
63
+ () => new Deferred<Awaited<ReturnType<FakeEdit>>>().promise,
64
64
  );
65
65
  const { store, source, wrapper, controller } = setup(document(), edit);
66
66
  const { result, unmount } = renderHook(
@@ -98,7 +98,7 @@ describe("document hooks", () => {
98
98
  await rejected;
99
99
  });
100
100
  expect(edit).toHaveBeenCalledTimes(2);
101
- expect(edit.mock.calls[1][2].signal.aborted).toBe(true);
101
+ expect(edit.mock.calls[1][2]?.signal?.aborted).toBe(true);
102
102
  });
103
103
 
104
104
  it("publishes compound edits atomically without changing host records", () => {
@@ -5,8 +5,10 @@ import type * as Protocol from "@marimo-team/client/protocol";
5
5
  import { atom, createStore } from "jotai/vanilla";
6
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { Deferred } from "@/utils/Deferred";
8
+ import type { NotebookBackend } from "../../host/backend";
8
9
  import type { Document } from "../../host/resources/document";
9
- import { createDocumentModel, type DocumentHost } from "../model";
10
+ import { createDocumentModel } from "../model";
11
+ import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
10
12
  import { cell, document, withCells } from "./fixtures";
11
13
 
12
14
  function response(confirmed: Document): Result<Protocol.EditResponseOutput> {
@@ -30,11 +32,11 @@ async function expectAbort(promise: Promise<unknown>, reason: Error) {
30
32
  interface SentEdit {
31
33
  notebookId: Protocol.NotebookId;
32
34
  changes: readonly Protocol.Change[];
33
- idempotencyKey: string;
34
- signal: AbortSignal;
35
+ idempotencyKey?: string;
36
+ signal?: AbortSignal;
35
37
  }
36
38
 
37
- function setup() {
39
+ function setup({ connected = true } = {}) {
38
40
  const store = createStore();
39
41
  const source = atom<Document | null>(document());
40
42
  const unmounted = vi.fn();
@@ -45,21 +47,24 @@ function setup() {
45
47
  replies.push(reply);
46
48
  return reply.promise;
47
49
  });
48
- const host: DocumentHost = {
49
- documentAtom: source,
50
+ const backend = fakeBackend({
50
51
  edit: (notebookId, changes, options) =>
51
52
  sent({ notebookId, changes, ...options }),
52
- };
53
+ });
54
+ const backendAtom = atom<NotebookBackend | null>(connected ? backend : null);
53
55
  const controller = new AbortController();
54
56
  lifetimes.push(controller);
55
57
  const model = createDocumentModel({
56
58
  store,
57
- notebook: host,
59
+ documentAtom: source,
60
+ backendAtom,
58
61
  signal: controller.signal,
59
62
  });
60
63
  return {
61
64
  store,
62
65
  source,
66
+ backend,
67
+ backendAtom,
63
68
  model,
64
69
  sent,
65
70
  replies,
@@ -88,11 +93,12 @@ describe("document synchronization", () => {
88
93
  const controller = new AbortController();
89
94
  const reason = new Error("Session ended");
90
95
  controller.abort(reason);
91
- const edit = vi.fn<DocumentHost["edit"]>();
96
+ const edit = vi.fn<FakeEdit>();
92
97
  expect(() =>
93
98
  createDocumentModel({
94
99
  store,
95
- notebook: { documentAtom: source, edit },
100
+ documentAtom: source,
101
+ backendAtom: atom(fakeBackend({ edit })),
96
102
  signal: controller.signal,
97
103
  }),
98
104
  ).toThrow(reason);
@@ -152,7 +158,34 @@ describe("document synchronization", () => {
152
158
  await vi.advanceTimersByTimeAsync(0);
153
159
  store.set(source, document("n2"));
154
160
  await rejected;
155
- expect(sent.mock.calls[0][0].signal.aborted).toBe(true);
161
+ expect(sent.mock.calls[0][0].signal?.aborted).toBe(true);
156
162
  expect(status()).toMatchObject({ status: "error", retryable: false });
157
163
  });
164
+
165
+ it("aborts an in-flight edit when the backend changes and never sends it to the new one", async () => {
166
+ const { store, backendAtom, sent, edit, model } = setup();
167
+ edit("local");
168
+ const rejected = expectFailure(model.flush(), "notebook changed");
169
+ await vi.advanceTimersByTimeAsync(0);
170
+ const other = vi.fn<FakeEdit>();
171
+ store.set(backendAtom, fakeBackend({ edit: other }));
172
+ await rejected;
173
+ expect(sent).toHaveBeenCalledOnce();
174
+ expect(sent.mock.calls[0][0].signal?.aborted).toBe(true);
175
+ expect(other).not.toHaveBeenCalled();
176
+ });
177
+
178
+ it("keeps edits as drafts until a backend is connected", async () => {
179
+ const { store, backend, backendAtom, sent, edit, status, value } = setup({
180
+ connected: false,
181
+ });
182
+ edit("local");
183
+ await vi.advanceTimersByTimeAsync(1000);
184
+ expect(status()).toEqual({ status: "unavailable" });
185
+ expect(value()?.code).toBe("local");
186
+
187
+ store.set(backendAtom, backend);
188
+ await vi.advanceTimersByTimeAsync(400);
189
+ expect(sent).toHaveBeenCalledOnce();
190
+ });
158
191
  });
@@ -13,7 +13,8 @@ function createSession(initial: Document, controller: AbortController) {
13
13
  const { notebook, control } = createTestNotebook(store, initial);
14
14
  const model = createDocumentModel({
15
15
  store,
16
- notebook,
16
+ documentAtom: notebook.documentAtom,
17
+ backendAtom: notebook.backendAtom,
17
18
  signal: controller.signal,
18
19
  });
19
20
  return {
@@ -6,8 +6,8 @@ import { atom, type createStore } from "jotai/vanilla";
6
6
  import { vi } from "vitest";
7
7
  import { Deferred } from "@/utils/Deferred";
8
8
  import { invariant } from "@/utils/invariant";
9
+ import { type FakeEdit, fakeBackend } from "../../host/__tests__/fixtures";
9
10
  import type { Document } from "../../host/resources/document";
10
- import type { DocumentHost } from "../model";
11
11
  import { createTestHost, type EditRequest } from "./test-host";
12
12
 
13
13
  export interface PendingRequest extends EditRequest {
@@ -33,19 +33,25 @@ export function createTestNotebook(
33
33
  documents.push(update);
34
34
  }
35
35
  }
36
- const edit = vi.fn<DocumentHost["edit"]>((notebookId, changes, options) => {
37
- options.signal.throwIfAborted();
36
+ const edit = vi.fn<FakeEdit>((notebookId, changes, options) => {
37
+ const signal = options?.signal;
38
+ const idempotencyKey = options?.idempotencyKey;
39
+ invariant(
40
+ signal !== undefined && idempotencyKey !== undefined,
41
+ "The test host answers edits that carry a key and a signal",
42
+ );
43
+ signal.throwIfAborted();
38
44
  const reply = new Deferred<Result<Protocol.EditResponseOutput>>();
39
45
  let outcome: Result<Protocol.EditResponseOutput> | undefined;
40
- const abort = () => reply.reject(options.signal.reason);
41
- const finish = () => options.signal.removeEventListener("abort", abort);
42
- options.signal.addEventListener("abort", abort, { once: true });
46
+ const abort = () => reply.reject(signal.reason);
47
+ const finish = () => signal.removeEventListener("abort", abort);
48
+ signal.addEventListener("abort", abort, { once: true });
43
49
  void reply.promise.then(finish, finish);
44
50
  const request: PendingRequest = {
45
51
  notebookId,
46
52
  changes: structuredClone(changes),
47
- idempotencyKey: options.idempotencyKey,
48
- signal: options.signal,
53
+ idempotencyKey,
54
+ signal,
49
55
  process() {
50
56
  invariant(outcome === undefined, "Request was already processed");
51
57
  // Cancellation of the client's wait does not roll back a server commit.
@@ -74,7 +80,11 @@ export function createTestNotebook(
74
80
  }
75
81
  return reply.promise;
76
82
  });
77
- const notebook = { documentAtom: source, edit } satisfies DocumentHost;
83
+ const notebook = {
84
+ documentAtom: source,
85
+ backendAtom: atom(fakeBackend({ edit })),
86
+ edit,
87
+ };
78
88
  return {
79
89
  notebook,
80
90
  control: {
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { Document } from "../host/resources/document";
3
+ import type { Document } from "../host";
4
4
  import * as Protocol from "@marimo-team/client/protocol";
5
5
 
6
6
  /** The fields a client edits. Null code means the host withheld source. */
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** The editable document, its drafts, and its synchronization with the host. */
4
+
3
5
  export {
4
6
  DocumentProvider,
5
7
  useCellCode,
@@ -10,13 +12,4 @@ export {
10
12
  useDocumentEdit,
11
13
  useDocumentSync,
12
14
  } from "./hooks";
13
- export { createDocumentModel } from "./model";
14
- export type {
15
- DocumentEdit,
16
- DocumentEditor,
17
- DocumentHost,
18
- DocumentModel,
19
- DocumentSyncStatus,
20
- EditableCell,
21
- EditableDocument,
22
- } from "./model";
15
+ export { createDocumentModel, type DocumentModel } from "./model";
@@ -2,16 +2,13 @@
2
2
 
3
3
  import type * as Protocol from "@marimo-team/client/protocol";
4
4
  import type { Atom, createStore } from "jotai/vanilla";
5
+ import type { Document, NotebookBackend } from "../host";
5
6
  import { createDocumentState, type DocumentState } from "./state";
6
- import {
7
- createDocumentSync,
8
- type DocumentHost,
9
- type DocumentSyncStatus,
10
- } from "./sync";
7
+ import { createDocumentSync, type DocumentSyncStatus } from "./sync";
11
8
 
12
9
  export type { DocumentEdit, DocumentEditor } from "./state";
13
10
  export type { EditableCell, EditableDocument } from "./draft";
14
- export type { DocumentHost, DocumentSyncStatus } from "./sync";
11
+ export type { DocumentSyncStatus } from "./sync";
15
12
 
16
13
  /** The editable document and its synchronization with the host. */
17
14
  export interface DocumentModel extends Pick<
@@ -28,20 +25,26 @@ export interface DocumentModel extends Pick<
28
25
  /** Create once per editing session; abort to stop editing and synchronization together. */
29
26
  export function createDocumentModel({
30
27
  store,
31
- notebook,
28
+ documentAtom,
29
+ backendAtom,
32
30
  signal,
33
31
  }: {
34
32
  store: ReturnType<typeof createStore>;
35
- notebook: DocumentHost;
33
+ /** The document the host last confirmed. */
34
+ documentAtom: Atom<Document | null>;
35
+ /** The backend that confirmed it, whose commands reach its host. */
36
+ backendAtom: Atom<NotebookBackend | null>;
36
37
  signal: AbortSignal;
37
38
  }): DocumentModel {
38
39
  signal.throwIfAborted();
39
- const state = createDocumentState({
40
+ const state = createDocumentState({ store, source: documentAtom, signal });
41
+ const sync = createDocumentSync({
40
42
  store,
41
- source: notebook.documentAtom,
43
+ documentAtom,
44
+ backendAtom,
45
+ state,
42
46
  signal,
43
47
  });
44
- const sync = createDocumentSync({ store, notebook, state, signal });
45
48
  return {
46
49
  documentAtom: state.documentAtom,
47
50
  editAtom: state.editAtom,
@@ -6,7 +6,7 @@ import {
6
6
  type createStore,
7
7
  type WritableAtom,
8
8
  } from "jotai/vanilla";
9
- import type { Document } from "../host/resources/document";
9
+ import type { Document } from "../host";
10
10
  import * as Protocol from "@marimo-team/client/protocol";
11
11
  import {
12
12
  acknowledge,
@@ -2,13 +2,10 @@
2
2
 
3
3
  import { idempotencyKey, type Result } from "@marimo-team/client";
4
4
  import type * as Protocol from "@marimo-team/client/protocol";
5
- import { atom, type createStore } from "jotai/vanilla";
6
- import type { NotebookClient } from "../host/notebook-client";
5
+ import { type Atom, atom, type createStore } from "jotai/vanilla";
6
+ import type { Document, NotebookBackend } from "../host";
7
7
  import type { DocumentState, DocumentSubmission } from "./state";
8
8
 
9
- /** The notebook state and operation needed by document editing. */
10
- export type DocumentHost = Pick<NotebookClient, "documentAtom" | "edit">;
11
-
12
9
  export type DocumentSyncStatus =
13
10
  | {
14
11
  readonly status:
@@ -27,6 +24,8 @@ export type DocumentSyncStatus =
27
24
 
28
25
  interface Submission {
29
26
  readonly submission: DocumentSubmission;
27
+ /** The backend that confirmed the submission's base. */
28
+ readonly backend: NotebookBackend;
30
29
  readonly idempotencyKey: string;
31
30
  readonly startedAt: number;
32
31
  readonly controller: AbortController;
@@ -74,20 +73,22 @@ function delay(ms: number, signal: AbortSignal): Promise<void> {
74
73
  /** Own submission scheduling; follow the existing source without opening a stream. */
75
74
  export function createDocumentSync({
76
75
  store,
77
- notebook,
76
+ documentAtom: source,
77
+ backendAtom,
78
78
  state,
79
79
  signal,
80
80
  }: {
81
81
  store: ReturnType<typeof createStore>;
82
- notebook: DocumentHost;
82
+ documentAtom: Atom<Document | null>;
83
+ backendAtom: Atom<NotebookBackend | null>;
83
84
  state: DocumentState;
84
85
  signal: AbortSignal;
85
86
  }) {
86
87
  signal.throwIfAborted();
87
- const source = notebook.documentAtom;
88
88
  const statusAtom = atom<DocumentSyncStatus>({ status: "idle" });
89
89
  const observed = atom((get) => ({
90
90
  document: get(source),
91
+ backend: get(backendAtom),
91
92
  pending: get(state.pendingAtom),
92
93
  changes: get(state.changesAtom),
93
94
  }));
@@ -117,8 +118,9 @@ export function createDocumentSync({
117
118
  const current = store.get(source);
118
119
  if (
119
120
  active &&
120
- current &&
121
- current.revision.notebook_id !== active.submission.notebookId
121
+ (store.get(backendAtom) !== active.backend ||
122
+ (current &&
123
+ current.revision.notebook_id !== active.submission.notebookId))
122
124
  ) {
123
125
  active.controller.abort(
124
126
  new Error("The notebook changed during synchronization"),
@@ -129,7 +131,7 @@ export function createDocumentSync({
129
131
  }
130
132
  clearTimer();
131
133
  const changes = store.get(state.changesAtom);
132
- if (changes === null) {
134
+ if (changes === null || store.get(backendAtom) === null) {
133
135
  store.set(statusAtom, { status: "unavailable" });
134
136
  } else if (changes.length === 0) {
135
137
  // Release unsent reversals instead of keeping an invisible override.
@@ -170,14 +172,12 @@ export function createDocumentSync({
170
172
  signal.throwIfAborted();
171
173
  request.controller.signal.throwIfAborted();
172
174
  result = await abortable(
173
- notebook.edit(
174
- request.submission.notebookId,
175
- request.submission.changes,
176
- {
175
+ request.backend
176
+ .notebook(request.submission.notebookId)
177
+ .edit(request.submission.changes, {
177
178
  idempotencyKey: request.idempotencyKey,
178
179
  signal: controller.signal,
179
- },
180
- ),
180
+ }),
181
181
  controller.signal,
182
182
  );
183
183
  } catch (error) {
@@ -248,7 +248,12 @@ export function createDocumentSync({
248
248
  throw new Error("The notebook changed during synchronization");
249
249
  }
250
250
  if (active === null) {
251
- if (current === null || store.get(state.changesAtom) === null) {
251
+ const backend = store.get(backendAtom);
252
+ if (
253
+ current === null ||
254
+ backend === null ||
255
+ store.get(state.changesAtom) === null
256
+ ) {
252
257
  throw unavailable();
253
258
  }
254
259
  const submission = store.set(state.submitAtom);
@@ -257,6 +262,7 @@ export function createDocumentSync({
257
262
  }
258
263
  active = {
259
264
  submission,
265
+ backend,
260
266
  idempotencyKey: idempotencyKey(),
261
267
  startedAt: Date.now(),
262
268
  controller: new AbortController(),