@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
@@ -4,13 +4,9 @@ Production code may import host modules, `@marimo-team/client`,
4
4
  `jotai/vanilla`, `zod`, and `@/utils/Logger`. Enforced by
5
5
  `marimo/host-imports`. Tests are exempt.
6
6
 
7
+ Code outside this directory imports it as `@/core/host`. Enforced by
8
+ `marimo/module-entry`.
9
+
7
10
  The protocol schemas and types live in `@marimo-team/client/protocol`.
8
11
 
9
12
  Define interfaces here and inject application behavior through adapters.
10
-
11
- `notebook-client.ts` follows the notebook with `NotebookEvents` from
12
- `@marimo-team/client`, over a `NotebookTransport` the editor supplies. The
13
- editor's transports for its session routes live in
14
- `core/websocket/transports/notebook.ts`. Each kind of object is a `NotebookResource`
15
- in `resources/` with pure handlers keyed by event name, composed with
16
- `createNotebookReducer`. The client holds one state atom and exposes derived atoms.
@@ -2,7 +2,37 @@
2
2
 
3
3
  import NodeFs from "node:fs";
4
4
  import NodeUrl from "node:url";
5
+ import type { NotebookTransport } from "@marimo-team/client";
5
6
  import * as Protocol from "@marimo-team/client/protocol";
7
+ import { vi } from "vitest";
8
+ import type { NotebookBackend } from "../backend";
9
+
10
+ type Commands = ReturnType<NotebookBackend["notebook"]>;
11
+
12
+ /** A backend command, with the notebook it was sent to. */
13
+ export type FakeEdit = (
14
+ notebookId: Protocol.NotebookId,
15
+ ...args: Parameters<Commands["edit"]>
16
+ ) => ReturnType<Commands["edit"]>;
17
+
18
+ /** A backend over `transport` whose edits go to `edit`. */
19
+ export function fakeBackend({
20
+ transport = async () => undefined,
21
+ edit = vi.fn<FakeEdit>(),
22
+ checkHealth,
23
+ }: {
24
+ transport?: NotebookTransport;
25
+ edit?: FakeEdit;
26
+ checkHealth?: () => Promise<boolean>;
27
+ } = {}): NotebookBackend {
28
+ return {
29
+ transport,
30
+ notebook: (notebookId) => ({
31
+ edit: (...args) => edit(notebookId, ...args),
32
+ }),
33
+ checkHealth,
34
+ };
35
+ }
6
36
 
7
37
  /** Read the protocol's portable examples as transport messages. */
8
38
  export function fixtureMessages(name: string) {
@@ -3,13 +3,12 @@
3
3
 
4
4
  import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
5
5
  import { createStore } from "jotai";
6
- import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
7
7
  import { Deferred } from "@/utils/Deferred";
8
8
  import { ConnectionState } from "../../websocket/types";
9
- import type { NotebookBackend } from "../backend";
10
9
  import type { ConnectionStatus } from "../connection";
11
- import { createNotebookClient } from "../notebook-client";
12
- import { fixtureEvents, fixtureMessages } from "./fixtures";
10
+ import { createNotebookReplica } from "../replica";
11
+ import { fakeBackend, fixtureEvents, fixtureMessages } from "./fixtures";
13
12
 
14
13
  /** One connection the fake transport opened. */
15
14
  interface Attempt {
@@ -23,14 +22,7 @@ interface Attempt {
23
22
  stop(status?: ConnectionStatus): void;
24
23
  }
25
24
 
26
- type Commands = ReturnType<NotebookBackend["notebook"]>;
27
- /** A backend edit with the notebook it was sent to. */
28
- type Edit = (
29
- id: string,
30
- ...args: Parameters<Commands["edit"]>
31
- ) => ReturnType<Commands["edit"]>;
32
-
33
- function setup(edit = vi.fn<Edit>()) {
25
+ function setup() {
34
26
  const attempts: Attempt[] = [];
35
27
  const transport: NotebookTransport = ({ cursor, signal }, handlers) =>
36
28
  new Promise((resolve, reject) => {
@@ -48,19 +40,15 @@ function setup(edit = vi.fn<Edit>()) {
48
40
  const onNotification = vi.fn();
49
41
  const checkHealth = vi.fn(async () => true);
50
42
  const store = createStore();
51
- const backend: NotebookBackend = {
52
- transport,
53
- notebook: (id) => ({ edit: (...args) => edit(id, ...args) }),
54
- };
55
- const controller = createNotebookClient(store);
56
- const client = controller.client;
57
- const connect = (signal: AbortSignal) =>
58
- controller.connect({
59
- backend,
60
- signal,
61
- checkHealth,
43
+ const backend = fakeBackend({ transport, checkHealth });
44
+ const client = createNotebookReplica(store);
45
+ /** Follow the backend until `signal` aborts. */
46
+ const connect = (signal: AbortSignal) => {
47
+ const disconnect = client.connect(backend, {
62
48
  onNotification: (notification) => onNotification({ data: notification }),
63
49
  });
50
+ signal.addEventListener("abort", disconnect, { once: true });
51
+ };
64
52
  let next = 0;
65
53
  /** Wait for the next connection, and open it. */
66
54
  const opened = async () => {
@@ -71,6 +59,7 @@ function setup(edit = vi.fn<Edit>()) {
71
59
  };
72
60
  return {
73
61
  attempts,
62
+ backend,
74
63
  client,
75
64
  connect,
76
65
  onNotification,
@@ -109,61 +98,40 @@ const notebook = {
109
98
  updated_at: null,
110
99
  };
111
100
 
112
- describe("NotebookClient lifetime", () => {
113
- it("keeps an edit's target and request identity across a stream reset", async () => {
114
- const edit = vi.fn<Edit>().mockResolvedValue({
115
- ok: false,
116
- status: 409,
117
- problem: { type: "conflict", title: "conflict", status: 409 },
118
- });
119
- const { connect, opened, client } = setup(edit);
120
- connect(lifetime().signal);
101
+ describe("NotebookReplica lifetime", () => {
102
+ it("ends the previous connection when it connects again", async () => {
103
+ const { attempts, backend, client, opened, store } = setup();
104
+ const first = client.connect(backend, { onNotification: vi.fn() });
121
105
  const { receive } = await opened();
122
106
  fixtureMessages("notebook-events.sse").forEach(receive);
123
- const options = { idempotencyKey: "edit-1", signal: lifetime().signal };
124
- await client.edit("n1", [], options);
125
- receive({ event: "reset", data: {} });
126
- await client.edit("n1", [], options);
127
- expect(edit.mock.calls).toEqual([
128
- ["n1", [], options],
129
- ["n1", [], options],
130
- ]);
131
-
132
- receive({
133
- event: "notebook",
134
- data: { notebook: { ...notebook, id: "n2" } },
107
+ const second = client.connect(backend, { onNotification: vi.fn() });
108
+ onTestFinished(second);
109
+ expect(attempts[0].signal.aborted).toBe(true);
110
+ expect(store.get(client.documentAtom)).toBeNull();
111
+ await opened();
112
+ first();
113
+ expect(attempts[1].signal.aborted).toBe(false);
114
+ expect(store.get(client.connectionAtom)).toEqual({
115
+ state: ConnectionState.OPEN,
135
116
  });
136
- await expect(client.edit("n1", [], options)).rejects.toThrow(
137
- "notebook changed",
138
- );
139
- expect(edit).toHaveBeenCalledTimes(2);
140
117
  });
141
118
 
142
- it("rejects aborted edits and reports edits before connecting", async () => {
143
- const { client } = setup();
144
- const controller = lifetime();
145
- await expect(
146
- client.edit("n1", [], {
147
- idempotencyKey: "edit-1",
148
- signal: controller.signal,
149
- }),
150
- ).resolves.toEqual({
151
- ok: false,
152
- status: 501,
153
- problem: {
154
- type: "tag:marimo.io,2026:host/unsupported-operation",
155
- title: "Document editing is unavailable on this connection",
156
- status: 501,
157
- },
158
- });
159
- const reason = new Error("Session ended");
160
- controller.abort(reason);
161
- await expect(
162
- client.edit("n1", [], {
163
- idempotencyKey: "edit-1",
164
- signal: controller.signal,
165
- }),
166
- ).rejects.toBe(reason);
119
+ it("exposes the backend it follows until that connection ends", async () => {
120
+ const { backend, client, opened, store } = setup();
121
+ expect(store.get(client.backendAtom)).toBeNull();
122
+ const disconnect = client.connect(backend, { onNotification: vi.fn() });
123
+ expect(store.get(client.backendAtom)).toBe(backend);
124
+ const { receive, drop } = await opened();
125
+ fixtureMessages("notebook-events.sse").forEach(receive);
126
+ receive({ event: "reset", data: {} });
127
+ drop();
128
+ expect(store.get(client.backendAtom)).toBe(backend);
129
+
130
+ const other = fakeBackend();
131
+ onTestFinished(client.connect(other, { onNotification: vi.fn() }));
132
+ expect(store.get(client.backendAtom)).toBe(other);
133
+ disconnect();
134
+ expect(store.get(client.backendAtom)).toBe(other);
167
135
  });
168
136
 
169
137
  it("exposes identity and capabilities only after the notebook is ready", async () => {
@@ -450,7 +418,7 @@ describe("NotebookClient lifetime", () => {
450
418
  });
451
419
  });
452
420
 
453
- describe("NotebookClient attachments", () => {
421
+ describe("NotebookReplica attachments", () => {
454
422
  const [browser, agent] = fixtureEvents("notebook-events.sse").flatMap(
455
423
  (event) => (event.event === "attachment" ? [event.data.attachment] : []),
456
424
  );
@@ -99,6 +99,14 @@ describe("WASM notebook backend", () => {
99
99
  ).rejects.toBeInstanceOf(StreamStopped);
100
100
  });
101
101
 
102
+ it("refuses commands for any notebook but the worker's", () => {
103
+ const { bridge } = worker();
104
+ expect(() => createWasmNotebookBackend(bridge).notebook("n2")).toThrow(
105
+ "serves notebook n1, not n2",
106
+ );
107
+ expect(bridge.call).not.toHaveBeenCalled();
108
+ });
109
+
102
110
  it("sends edits as calls with the retry key and validates replies", async () => {
103
111
  const { bridge, reply } = worker();
104
112
  const backend = createWasmNotebookBackend(bridge);
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** A notebook's events and commands, from a host or a stand-in for one. */
4
+
3
5
  import type { NotebookClient, NotebookTransport } from "@marimo-team/client";
4
6
  import type { NotebookId } from "@marimo-team/client/protocol";
5
7
 
@@ -12,4 +14,6 @@ export interface NotebookBackend {
12
14
  * Method presence is not permission. The notebook advertises its operations.
13
15
  */
14
16
  notebook(id: NotebookId): Pick<NotebookClient, "edit">;
17
+ /** Whether the host is reachable before an explicit reconnect. Absent means always. */
18
+ checkHealth?(): Promise<boolean>;
15
19
  }
@@ -0,0 +1,14 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /** Notebook backends and the replica that follows them. */
4
+
5
+ export type { NotebookBackend } from "./backend";
6
+ export {
7
+ type CloseDecision,
8
+ ConnectionClosedReason,
9
+ ConnectionState,
10
+ type ConnectionStatus,
11
+ } from "./connection";
12
+ export { createNotebookReplica, type NotebookReplica } from "./replica";
13
+ export type { Document } from "./resources/document";
14
+ export { createWasmNotebookBackend } from "./wasm";
@@ -1,20 +1,15 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** What a backend's stream has confirmed about a notebook, kept in atoms. */
4
+
3
5
  import { Logger } from "@/utils/Logger";
4
6
  import {
5
7
  createNotebookReducer,
6
8
  type NotebookEvent,
7
9
  NotebookEvents,
8
- type Result,
9
10
  StreamStopped,
10
11
  } from "@marimo-team/client";
11
- import type {
12
- Attachment,
13
- Change,
14
- EditResponseOutput,
15
- NotebookId,
16
- NotebookOutput,
17
- } from "@marimo-team/client/protocol";
12
+ import type { Attachment, NotebookOutput } from "@marimo-team/client/protocol";
18
13
  import { type Atom, atom, type createStore } from "jotai/vanilla";
19
14
  import { z } from "zod";
20
15
  import type { NotebookBackend } from "./backend";
@@ -34,9 +29,9 @@ type EditorEvent = NotebookEvent<(typeof TYPES)[number]>;
34
29
  /** A native marimo notification, passed through without interpretation. */
35
30
  const MarimoMessage = z.object({ marimo: z.unknown() });
36
31
 
37
- /** A connection to a notebook on a marimo host. */
38
- export interface NotebookClient {
39
- /** Client connection status, independent of application session readiness. */
32
+ /** A notebook as its backend's stream reports it, kept current while connected. */
33
+ export interface NotebookReplica {
34
+ /** Connection status, independent of application session readiness. */
40
35
  readonly connectionAtom: Atom<ConnectionStatus>;
41
36
  /** Identity and capabilities, available while the stream is connected and ready. */
42
37
  readonly notebookAtom: Atom<NotebookOutput | null>;
@@ -48,30 +43,32 @@ export interface NotebookClient {
48
43
  * retains the last document. Partial updates are not exposed.
49
44
  */
50
45
  readonly documentAtom: Atom<Document | null>;
51
- /** Apply an edit to the notebook captured by the caller, even across a reset. */
52
- edit(
53
- notebookId: NotebookId,
54
- changes: readonly Change[],
55
- options: { readonly idempotencyKey: string; readonly signal: AbortSignal },
56
- ): Promise<Result<EditResponseOutput>>;
46
+ /**
47
+ * The backend this replica follows, or null while not connected.
48
+ *
49
+ * Its commands reach the host that confirmed `documentAtom`. A temporary
50
+ * disconnection keeps it. Connecting again or disconnecting replaces it.
51
+ */
52
+ readonly backendAtom: Atom<NotebookBackend | null>;
57
53
  /** Live attachments, empty until the stream is ready or while disconnected. */
58
54
  readonly attachmentsAtom: Atom<readonly Attachment[]>;
59
- /** Attempt to restore a closed connection after checking host health. */
55
+ /**
56
+ * Follow a backend, replacing any current connection.
57
+ *
58
+ * The returned function ends this connection and clears the replica.
59
+ * It does nothing once another connection has replaced this one.
60
+ */
61
+ connect(
62
+ backend: NotebookBackend,
63
+ options: {
64
+ /** Handle a native marimo notification. Its payload has not been validated. */
65
+ onNotification: (notification: unknown) => void;
66
+ },
67
+ ): () => void;
68
+ /** Restore the current connection after its stream closed, if the host is healthy. */
60
69
  reconnect(): Promise<void>;
61
70
  }
62
71
 
63
- /** The backend and application callbacks needed to follow a notebook. */
64
- export interface NotebookConnection {
65
- /** Follows the notebook's stream and sends its commands. */
66
- backend: NotebookBackend;
67
- /** Abort to close the stream and clear the client's state. */
68
- signal: AbortSignal;
69
- /** Check whether the host is reachable before an explicit reconnect. */
70
- checkHealth: () => Promise<boolean>;
71
- /** Handle a native marimo notification. Its payload has not been validated. */
72
- onNotification: (notification: unknown) => void;
73
- }
74
-
75
72
  const disconnected = {
76
73
  state: ConnectionState.CLOSED,
77
74
  code: ConnectionClosedReason.KERNEL_DISCONNECTED,
@@ -103,8 +100,10 @@ function statusAfter(error: unknown): ConnectionStatus {
103
100
  return disconnected;
104
101
  }
105
102
 
106
- /** Create a client without opening a connection. */
107
- export function createNotebookClient(store: ReturnType<typeof createStore>) {
103
+ /** Create a replica without connecting it. */
104
+ export function createNotebookReplica(
105
+ store: ReturnType<typeof createStore>,
106
+ ): NotebookReplica {
108
107
  // `useNotebookSession` copies this into the editor's shared `connectionAtom`,
109
108
  // which gates features until MO-8051 derives availability separately.
110
109
  const connectionAtom = atom<ConnectionStatus>({
@@ -130,50 +129,40 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
130
129
  ? get(streamAtom).resources.attachments
131
130
  : attachments.initial,
132
131
  );
133
- let reconnect: (() => Promise<void>) | undefined;
134
- let backend: NotebookBackend | undefined;
135
- const client: NotebookClient = {
136
- connectionAtom,
137
- notebookAtom,
138
- documentAtom,
139
- edit: async (notebookId, changes, options) => {
140
- options.signal.throwIfAborted();
141
- const currentId = store.get(streamAtom).notebookId;
142
- if (currentId !== null && currentId !== notebookId) {
143
- throw new Error("The notebook changed before the edit was sent");
144
- }
145
- if (backend === undefined) {
146
- return {
147
- ok: false,
148
- status: 501,
149
- problem: {
150
- type: "tag:marimo.io,2026:host/unsupported-operation",
151
- title: "Document editing is unavailable on this connection",
152
- status: 501,
153
- },
154
- };
155
- }
156
- return backend.notebook(notebookId).edit(changes, options);
157
- },
158
- attachmentsAtom,
159
- reconnect: async () => reconnect?.(),
160
- };
132
+ const backendAtom = atom<NotebookBackend | null>(null);
133
+ /** The connection this replica follows. Connecting again ends it. */
134
+ let current: { end(): void; reconnect(): Promise<void> } | undefined;
161
135
 
162
136
  function setStatus(status: ConnectionStatus) {
163
137
  store.set(readyAtom, false);
164
138
  store.set(connectionAtom, status);
165
139
  }
166
140
 
167
- function connect({
168
- backend: connected,
169
- signal,
170
- checkHealth,
171
- onNotification,
172
- }: NotebookConnection) {
173
- if (signal.aborted) {
174
- return;
175
- }
176
- backend = connected;
141
+ /** Drop what the stream reported and show `status`. */
142
+ function clear(status: ConnectionStatus) {
143
+ store.set(streamAtom, reducer.initial);
144
+ setStatus(status);
145
+ }
146
+
147
+ return {
148
+ connectionAtom,
149
+ notebookAtom,
150
+ documentAtom,
151
+ backendAtom,
152
+ attachmentsAtom,
153
+ connect,
154
+ reconnect: async () => current?.reconnect(),
155
+ };
156
+
157
+ function connect(
158
+ connected: NotebookBackend,
159
+ { onNotification }: { onNotification: (notification: unknown) => void },
160
+ ) {
161
+ current?.end();
162
+ const lifetime = new AbortController();
163
+ const { signal } = lifetime;
164
+ const checkHealth = connected.checkHealth ?? (async () => true);
165
+ store.set(backendAtom, connected);
177
166
  const { transport } = connected;
178
167
  let events: NotebookEvents<(typeof TYPES)[number]> | undefined;
179
168
  let checkingHealth = false;
@@ -222,8 +211,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
222
211
  source.close();
223
212
  // The cursor can already include an event the replica rejected.
224
213
  resumable = false;
225
- store.set(streamAtom, reducer.initial);
226
- setStatus(malformed);
214
+ clear(malformed);
227
215
  }
228
216
 
229
217
  function apply(source: NotebookEvents<string>, event: EditorEvent) {
@@ -250,7 +238,7 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
250
238
  }
251
239
  }
252
240
 
253
- reconnect = async () => {
241
+ const reconnect = async () => {
254
242
  if (signal.aborted || checkingHealth || events?.status !== "closed") {
255
243
  return;
256
244
  }
@@ -276,22 +264,23 @@ export function createNotebookClient(store: ReturnType<typeof createStore>) {
276
264
  }
277
265
  };
278
266
 
279
- signal.addEventListener(
280
- "abort",
281
- () => {
282
- reconnect = undefined;
283
- if (backend === connected) {
284
- backend = undefined;
267
+ const connection = {
268
+ end: () => {
269
+ if (signal.aborted) {
270
+ return;
285
271
  }
286
- store.set(streamAtom, reducer.initial);
287
- setStatus({ state: ConnectionState.NOT_STARTED });
272
+ lifetime.abort();
273
+ if (current === connection) {
274
+ current = undefined;
275
+ store.set(backendAtom, null);
276
+ }
277
+ clear({ state: ConnectionState.NOT_STARTED });
288
278
  },
289
- { once: true },
290
- );
291
-
279
+ reconnect,
280
+ };
281
+ current = connection;
292
282
  setStatus({ state: ConnectionState.CONNECTING });
293
283
  follow();
284
+ return connection.end;
294
285
  }
295
-
296
- return { client, connect };
297
286
  }
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** A backend that serves a document snapshot without a host. */
4
+
3
5
  import type * as Protocol from "@marimo-team/client/protocol";
4
6
  import type { NotebookBackend } from "./backend";
5
7
 
@@ -1,5 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
+ /** A backend for the notebook running in the WASM worker. */
4
+
3
5
  import {
4
6
  NotebookClient,
5
7
  StreamStopped,
@@ -88,6 +90,13 @@ export function createWasmNotebookBackend(
88
90
  signal.removeEventListener("abort", abort);
89
91
  }
90
92
  },
91
- notebook: () => client,
93
+ notebook: (id) => {
94
+ if (id !== bridge.notebookId) {
95
+ throw new Error(
96
+ `The worker serves notebook ${bridge.notebookId}, not ${id}`,
97
+ );
98
+ }
99
+ return client;
100
+ },
92
101
  };
93
102
  }
@@ -11,7 +11,7 @@ import { reloadSafe } from "@/utils/reload-safe";
11
11
  import { generateUUID } from "@/utils/uuid";
12
12
  import { createModuleWorker } from "@/utils/worker";
13
13
  import { notebookIsRunningAtom } from "../cells/cells";
14
- import { createWasmNotebookBackend } from "../host/wasm";
14
+ import { createWasmNotebookBackend } from "../host";
15
15
  import type { ClientCommand } from "../kernel/messages";
16
16
  import { getInitialAppMode } from "../mode";
17
17
  import { API } from "../network/api";