@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
@@ -1,7 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { NotebookEvents } from "@marimo-team/client";
4
- import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { createStore } from "jotai/vanilla";
5
+ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
6
+ import { Deferred } from "@/utils/Deferred";
7
+ import { ConnectionState } from "../../host/connection";
8
+ import { createNotebookReplica } from "../../host/replica";
5
9
  import { createEditorBackend } from "../backend";
6
10
 
7
11
  const wasm = vi.hoisted(() => ({ enabled: false, consumer: vi.fn() }));
@@ -16,16 +20,34 @@ afterEach(() => {
16
20
  vi.unstubAllGlobals();
17
21
  });
18
22
 
19
- describe("createEditorBackend", () => {
20
- const options = {
21
- static: false,
22
- transportType: "websocket" as const,
23
- url: () => "ws://localhost/ws",
24
- headers: () => ({}),
25
- notebookUrl: (id: string) =>
26
- new URL(`http://localhost/api/marimo/v1/notebooks/${id}`),
27
- };
23
+ const options = {
24
+ static: false,
25
+ transportType: "websocket" as const,
26
+ url: () => "ws://localhost/ws",
27
+ headers: () => ({}),
28
+ notebookUrl: (id: string) =>
29
+ new URL(`http://localhost/api/marimo/v1/notebooks/${id}`),
30
+ waitUntilReady: async () => undefined,
31
+ checkHealth: async () => true,
32
+ };
33
+
34
+ /** Record each socket the transport opens. They never connect. */
35
+ function sockets() {
36
+ const opened: string[] = [];
37
+ vi.stubGlobal(
38
+ "WebSocket",
39
+ class extends EventTarget {
40
+ public constructor(url: URL) {
41
+ super();
42
+ opened.push(String(url));
43
+ }
44
+ public close() {}
45
+ },
46
+ );
47
+ return opened;
48
+ }
28
49
 
50
+ describe("createEditorBackend", () => {
29
51
  it.each([
30
52
  ["a static notebook", { ...options, static: true }, "idle"],
31
53
  ["WASM", options, "wasm"],
@@ -34,18 +56,8 @@ describe("createEditorBackend", () => {
34
56
  ] as const)("follows %s", async (_name, chosen, expected) => {
35
57
  wasm.enabled = expected === "wasm";
36
58
  const fetch = vi.fn(() => new Promise<Response>(() => undefined));
37
- const socket = vi.fn();
59
+ const opened = sockets();
38
60
  vi.stubGlobal("fetch", fetch);
39
- vi.stubGlobal(
40
- "WebSocket",
41
- class extends EventTarget {
42
- public constructor(url: URL) {
43
- super();
44
- socket(url);
45
- }
46
- public close() {}
47
- },
48
- );
49
61
  const events = new NotebookEvents(createEditorBackend(chosen).transport);
50
62
  await vi.waitFor(() =>
51
63
  expect(
@@ -54,7 +66,7 @@ describe("createEditorBackend", () => {
54
66
  events.status === "connected" && !wasm.consumer.mock.calls.length,
55
67
  wasm: wasm.consumer.mock.calls.length === 1,
56
68
  fetch: fetch.mock.calls.length === 1,
57
- socket: socket.mock.calls.length === 1,
69
+ socket: opened.length === 1,
58
70
  }[expected],
59
71
  ).toBe(true),
60
72
  );
@@ -109,3 +121,80 @@ describe("createEditorBackend", () => {
109
121
  expect(fetch).not.toHaveBeenCalled();
110
122
  });
111
123
  });
124
+
125
+ describe("the session backend's startup", () => {
126
+ it.each([true, false])(
127
+ "rechecks health after failed startup (healthy: %s)",
128
+ async (healthy) => {
129
+ const opened = sockets();
130
+ const waitUntilReady = vi
131
+ .fn()
132
+ .mockRejectedValue(new Error("Startup gave up"));
133
+ const checkHealth = vi.fn().mockResolvedValue(healthy);
134
+ const store = createStore();
135
+ const client = createNotebookReplica(store);
136
+ onTestFinished(
137
+ client.connect(
138
+ createEditorBackend({ ...options, waitUntilReady, checkHealth }),
139
+ { onNotification: vi.fn() },
140
+ ),
141
+ );
142
+ await vi.waitFor(() =>
143
+ expect(store.get(client.connectionAtom).state).toBe(
144
+ ConnectionState.CLOSED,
145
+ ),
146
+ );
147
+
148
+ await client.reconnect();
149
+ await vi.waitFor(() => expect(opened).toHaveLength(healthy ? 1 : 0));
150
+ expect({
151
+ healthChecks: checkHealth.mock.calls.length,
152
+ startupWaits: waitUntilReady.mock.calls.length,
153
+ state: store.get(client.connectionAtom).state,
154
+ }).toEqual({
155
+ healthChecks: 1,
156
+ startupWaits: 1,
157
+ state: healthy ? ConnectionState.CONNECTING : ConnectionState.CLOSED,
158
+ });
159
+ },
160
+ );
161
+
162
+ it("shares one startup wait across connections", async () => {
163
+ const opened = sockets();
164
+ const ready = new Deferred<void>();
165
+ const waitUntilReady = vi.fn(() => ready.promise);
166
+ const backend = createEditorBackend({ ...options, waitUntilReady });
167
+ const client = createNotebookReplica(createStore());
168
+ // Strict Mode connects, disconnects, and connects again.
169
+ client.connect(backend, { onNotification: vi.fn() })();
170
+ onTestFinished(client.connect(backend, { onNotification: vi.fn() }));
171
+ await Promise.resolve();
172
+ expect(opened).toEqual([]);
173
+
174
+ ready.resolve();
175
+ await vi.waitFor(() => expect(opened).toHaveLength(1));
176
+ expect(waitUntilReady).toHaveBeenCalledOnce();
177
+ });
178
+
179
+ it("opens nothing for a connection that ended during startup", async () => {
180
+ const opened = sockets();
181
+ const ready = new Deferred<void>();
182
+ const store = createStore();
183
+ const client = createNotebookReplica(store);
184
+ const disconnect = client.connect(
185
+ createEditorBackend({ ...options, waitUntilReady: () => ready.promise }),
186
+ { onNotification: vi.fn() },
187
+ );
188
+ expect(store.get(client.connectionAtom)).toEqual({
189
+ state: ConnectionState.CONNECTING,
190
+ });
191
+ disconnect();
192
+ ready.resolve();
193
+ await ready.promise;
194
+ await Promise.resolve();
195
+ expect(opened).toEqual([]);
196
+ expect(store.get(client.connectionAtom)).toEqual({
197
+ state: ConnectionState.NOT_STARTED,
198
+ });
199
+ });
200
+ });
@@ -0,0 +1,141 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { act, renderHook } from "@testing-library/react";
5
+ import { createStore, Provider } from "jotai";
6
+ import type { PropsWithChildren } from "react";
7
+ import { beforeEach, describe, expect, it, vi } from "vitest";
8
+ import type { NotebookTransport } from "@marimo-team/client";
9
+ import { ConnectionState } from "../types";
10
+ import { createEditorBackend } from "../backend";
11
+ import { useNotebookReplica } from "../useNotebookReplica";
12
+ import { fakeBackend, fixtureMessages } from "../../host/__tests__/fixtures";
13
+ import { agentAttachmentsAtom } from "../../attachments/state";
14
+ import { notebookReplicaAtom } from "../state";
15
+
16
+ vi.mock("../backend", () => ({ createEditorBackend: vi.fn() }));
17
+
18
+ /** A host-framed native notification. */
19
+ const reconnected = {
20
+ event: "marimo",
21
+ data: { marimo: { op: "reconnected" } },
22
+ };
23
+
24
+ /** A transport whose connections the test drives. */
25
+ function makeTransport() {
26
+ const opened: {
27
+ signal: AbortSignal;
28
+ open(): void;
29
+ receive(message: unknown): void;
30
+ }[] = [];
31
+ const transport: NotebookTransport = ({ signal }, handlers) =>
32
+ new Promise((resolve) => {
33
+ opened.push({ signal, open: handlers.open, receive: handlers.message });
34
+ signal.addEventListener("abort", () => resolve(), { once: true });
35
+ });
36
+ return {
37
+ transport,
38
+ opened,
39
+ /** Open the latest connection and deliver a host-framed notification. */
40
+ notify: () => opened.at(-1)?.receive(reconnected),
41
+ };
42
+ }
43
+
44
+ function setup(strict = false) {
45
+ const store = createStore();
46
+ const options = {
47
+ static: false,
48
+ transportType: "websocket" as const,
49
+ url: () => "ws://localhost/ws",
50
+ headers: () => ({}),
51
+ notebookUrl: (id: string) => new URL(`http://localhost/notebooks/${id}`),
52
+ waitUntilReady: async () => undefined,
53
+ checkHealth: async () => true,
54
+ onNotification: vi.fn(),
55
+ };
56
+ const wrapper = ({ children }: PropsWithChildren) => (
57
+ <Provider store={store}>{children}</Provider>
58
+ );
59
+ const hook = renderHook((props) => useNotebookReplica(props), {
60
+ initialProps: options,
61
+ reactStrictMode: strict,
62
+ wrapper,
63
+ });
64
+ return { ...hook, options, store };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ vi.mocked(createEditorBackend).mockReset();
69
+ });
70
+
71
+ describe("useNotebookReplica", () => {
72
+ it("exposes host presence to the UI without waiting for kernel-ready", async () => {
73
+ const fake = makeTransport();
74
+ vi.mocked(createEditorBackend).mockReturnValue(
75
+ fakeBackend({ transport: fake.transport }),
76
+ );
77
+ const { result, store, unmount } = setup();
78
+ await act(async () => Promise.resolve());
79
+ expect(store.get(notebookReplicaAtom)).toBe(result.current);
80
+ const [connection] = fake.opened;
81
+ act(() => {
82
+ connection.open();
83
+ fixtureMessages("notebook-events.sse").forEach(connection.receive);
84
+ });
85
+ expect(store.get(agentAttachmentsAtom)).toEqual([
86
+ {
87
+ id: "a2",
88
+ notebook_id: "n1",
89
+ kind: "agent",
90
+ name: "Notebook CLI",
91
+ since: "2026-10-01T09:20:03Z",
92
+ },
93
+ ]);
94
+ unmount();
95
+ expect(store.get(notebookReplicaAtom)).toBeNull();
96
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
97
+ });
98
+
99
+ it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
100
+ const fake = makeTransport();
101
+ vi.mocked(createEditorBackend).mockReturnValue(
102
+ fakeBackend({ transport: fake.transport }),
103
+ );
104
+ const { rerender, options, unmount } = setup();
105
+ await act(async () => Promise.resolve());
106
+ const onNotification = vi.fn();
107
+ rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
108
+ act(() => fake.notify());
109
+ expect(options.onNotification).not.toHaveBeenCalled();
110
+ expect(onNotification).toHaveBeenCalledExactlyOnceWith({
111
+ data: { op: "reconnected" },
112
+ });
113
+ const [{ url }] = vi.mocked(createEditorBackend).mock.calls[0];
114
+ expect(url()).toBe("ws://redirected/ws");
115
+ expect(fake.opened).toHaveLength(1);
116
+ unmount();
117
+ });
118
+
119
+ it("follows only the active connection under Strict Mode", async () => {
120
+ const fake = makeTransport();
121
+ vi.mocked(createEditorBackend).mockReturnValue(
122
+ fakeBackend({ transport: fake.transport }),
123
+ );
124
+ const { result, store, options, unmount } = setup(true);
125
+ await act(async () => Promise.resolve());
126
+ const live = fake.opened.filter((attempt) => !attempt.signal.aborted);
127
+ expect(live).toHaveLength(1);
128
+ const [active] = live;
129
+ act(() => active.open());
130
+ expect(store.get(result.current.connectionAtom)).toEqual({
131
+ state: ConnectionState.OPEN,
132
+ });
133
+ act(() => fake.opened.forEach((attempt) => attempt.receive(reconnected)));
134
+ expect(options.onNotification).toHaveBeenCalledOnce();
135
+ unmount();
136
+ expect(active.signal.aborted).toBe(true);
137
+ const attempts = fake.opened.length;
138
+ await result.current.reconnect();
139
+ expect(fake.opened).toHaveLength(attempts);
140
+ });
141
+ });
@@ -52,6 +52,7 @@ import { RuntimeManager } from "../../runtime/runtime";
52
52
  import { WebSocketClosedReason, ConnectionState } from "../types";
53
53
  import { classifyCloseEvent } from "../connection-state";
54
54
  import { createEditorBackend } from "../backend";
55
+ import { fakeBackend } from "../../host/__tests__/fixtures";
55
56
  import { useNotebookSession } from "../useNotebookSession";
56
57
  import { type NotebookTransport, StreamStopped } from "@marimo-team/client";
57
58
 
@@ -85,10 +86,9 @@ type FakeTransport = ReturnType<typeof makeTransport>;
85
86
 
86
87
  /** Use a fake transport for the next session the hook follows. */
87
88
  function withTransport(fake: FakeTransport = makeTransport()) {
88
- vi.mocked(createEditorBackend).mockReturnValue({
89
- transport: fake.transport,
90
- notebook: () => ({ edit: vi.fn() }),
91
- });
89
+ vi.mocked(createEditorBackend).mockImplementation(({ checkHealth }) =>
90
+ fakeBackend({ transport: fake.transport, checkHealth }),
91
+ );
92
92
  return fake;
93
93
  }
94
94
 
@@ -1,8 +1,10 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { NotebookClient, type StreamUrl } from "@marimo-team/client";
3
+ /** The backend the editor follows, chosen once for its mode. */
4
+
5
+ import { NotebookClient, StreamStopped } from "@marimo-team/client";
4
6
  import type { NotebookId } from "@marimo-team/client/protocol";
5
- import type { NotebookBackend } from "../host/backend";
7
+ import type { NotebookBackend } from "../host";
6
8
  import { PyodideBridge } from "../wasm/bridge";
7
9
  import { isWasm } from "../wasm/utils";
8
10
  import {
@@ -18,11 +20,45 @@ export interface EditorBackendOptions {
18
20
  static: boolean;
19
21
  transportType: "websocket" | "sse";
20
22
  /** The session route, read before each connection. */
21
- url: StreamUrl;
23
+ url: () => string;
22
24
  /** The runtime's headers, read before each request. */
23
25
  headers: () => Record<string, string>;
24
26
  /** The host URL of a notebook the stream reported. */
25
27
  notebookUrl: (notebookId: NotebookId) => URL;
28
+ /** Wait until the host can accept the first connection. */
29
+ waitUntilReady: () => Promise<void>;
30
+ /** Whether the host is reachable before an explicit reconnect. */
31
+ checkHealth: () => Promise<boolean>;
32
+ }
33
+
34
+ /**
35
+ * Read the session route once the host has started.
36
+ *
37
+ * Every connection waits for the same startup. Once it settles, even by
38
+ * giving up, connections read the route directly. An explicit reconnect
39
+ * checks the host's health instead.
40
+ */
41
+ function afterStartup({
42
+ url,
43
+ waitUntilReady,
44
+ }: Pick<EditorBackendOptions, "url" | "waitUntilReady">) {
45
+ let startup: Promise<void> | undefined;
46
+ let settled = false;
47
+ return async () => {
48
+ if (!settled) {
49
+ startup ??= waitUntilReady().finally(() => {
50
+ settled = true;
51
+ });
52
+ try {
53
+ await startup;
54
+ } catch (error) {
55
+ throw new StreamStopped("Healthy connection never made", {
56
+ cause: error,
57
+ });
58
+ }
59
+ }
60
+ return url();
61
+ };
26
62
  }
27
63
 
28
64
  /** Commands for a notebook the editor cannot change through its backend. */
@@ -55,14 +91,16 @@ export function createEditorBackend(
55
91
  notebook: uneditable,
56
92
  };
57
93
  }
94
+ const url = afterStartup(options);
58
95
  return {
59
96
  transport:
60
97
  options.transportType === "sse"
61
- ? sessionSse(options.url, options.headers)
62
- : websocket(options.url),
98
+ ? sessionSse(url, options.headers)
99
+ : websocket(url),
63
100
  notebook: (id) =>
64
101
  new NotebookClient(options.notebookUrl(id), {
65
102
  fetch: withHeaders(options.headers),
66
103
  }),
104
+ checkHealth: options.checkHealth,
67
105
  };
68
106
  }
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { CloseDecision } from "../host/connection";
3
+ import type { CloseDecision } from "../host";
4
4
  import { Logger } from "@/utils/Logger";
5
5
  import { ConnectionState, WebSocketClosedReason } from "./types";
6
6
 
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { atom } from "jotai";
4
- import type { NotebookClient } from "../host/notebook-client";
4
+ import type { NotebookReplica } from "../host";
5
5
 
6
- /** The client owned by the mounted notebook session. */
7
- export const notebookClientAtom = atom<NotebookClient | null>(null);
6
+ /** The replica owned by the mounted notebook session. */
7
+ export const notebookReplicaAtom = atom<NotebookReplica | null>(null);
@@ -7,8 +7,8 @@ import {
7
7
  } from "@marimo-team/client";
8
8
  import { createStore } from "jotai/vanilla";
9
9
  import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
10
- import { createNotebookClient } from "../../../host/notebook-client";
11
- import { fixtureMessages } from "../../../host/__tests__/fixtures";
10
+ import { createNotebookReplica } from "../../../host/replica";
11
+ import { fakeBackend, fixtureMessages } from "../../../host/__tests__/fixtures";
12
12
  import { ConnectionState } from "../../../host/connection";
13
13
  import { Deferred } from "@/utils/Deferred";
14
14
  import { pyodide, sessionSse, websocket } from "../notebook";
@@ -76,7 +76,7 @@ function network(transportType: "websocket" | "sse", url: StreamUrl) {
76
76
  transportType === "websocket"
77
77
  ? websocket(url)
78
78
  : sessionSse(url, () => ({}));
79
- const backend = { transport, notebook: () => ({ edit: vi.fn() }) };
79
+ const backend = fakeBackend({ transport });
80
80
  let index = 0;
81
81
  return {
82
82
  backend,
@@ -296,15 +296,10 @@ describe.each(["websocket", "sse"] as const)(
296
296
  const url = vi.fn(() => target);
297
297
  const connection = network(transportType, url);
298
298
  const store = createStore();
299
- const { client, connect } = createNotebookClient(store);
300
- const lifetime = new AbortController();
301
- onTestFinished(() => lifetime.abort());
302
- connect({
303
- backend: connection.backend,
304
- signal: lifetime.signal,
305
- checkHealth: async () => true,
306
- onNotification: vi.fn(),
307
- });
299
+ const client = createNotebookReplica(store);
300
+ onTestFinished(
301
+ client.connect(connection.backend, { onNotification: vi.fn() }),
302
+ );
308
303
  const first = await connection.opened();
309
304
  const snapshot = fixtureMessages("notebook-events.sse");
310
305
  snapshot.forEach(first.receive);
@@ -363,15 +358,10 @@ describe.each(["websocket", "sse"] as const)(
363
358
  let target = `${base}?session_id=s1&file=a.py&access_token=a`;
364
359
  const connection = network(transportType, () => target);
365
360
  const store = createStore();
366
- const { client, connect } = createNotebookClient(store);
367
- const lifetime = new AbortController();
368
- onTestFinished(() => lifetime.abort());
369
- connect({
370
- backend: connection.backend,
371
- signal: lifetime.signal,
372
- checkHealth: async () => true,
373
- onNotification: vi.fn(),
374
- });
361
+ const client = createNotebookReplica(store);
362
+ onTestFinished(
363
+ client.connect(connection.backend, { onNotification: vi.fn() }),
364
+ );
375
365
  const first = await connection.opened();
376
366
  fixtureMessages("notebook-events.sse").forEach(first.receive);
377
367
  await vi.waitFor(() =>
@@ -1,12 +1,12 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { ConnectionStatus as HostConnectionStatus } from "../host/connection";
3
+ import type { ConnectionStatus as HostConnectionStatus } from "../host";
4
4
  import type { NotificationMessageData } from "../kernel/messages";
5
5
 
6
6
  export {
7
7
  ConnectionState,
8
8
  ConnectionClosedReason as WebSocketClosedReason,
9
- } from "../host/connection";
9
+ } from "../host";
10
10
 
11
11
  export type ConnectionPhase =
12
12
  | NotificationMessageData<"startup-progress">["phase"]
@@ -0,0 +1,69 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ /** The editor session's replica, following its backend while mounted. */
4
+
5
+ import { useStore } from "jotai";
6
+ import { useEffect, useMemo } from "react";
7
+ import useEvent from "react-use-event-hook";
8
+ import type {
9
+ NotificationMessage,
10
+ NotificationPayload,
11
+ } from "../kernel/messages";
12
+ import { createNotebookReplica, type NotebookReplica } from "../host";
13
+ import { notebookReplicaAtom } from "./state";
14
+ import { createEditorBackend, type EditorBackendOptions } from "./backend";
15
+
16
+ type Options = EditorBackendOptions & {
17
+ onNotification: (notification: NotificationPayload) => void;
18
+ };
19
+
20
+ export function useNotebookReplica(options: Options): NotebookReplica {
21
+ const store = useStore();
22
+ const { static: isStatic, transportType } = options;
23
+ const replica = useMemo(() => createNotebookReplica(store), [store]);
24
+ const url = useEvent(options.url);
25
+ const headers = useEvent(options.headers);
26
+ const notebookUrl = useEvent(options.notebookUrl);
27
+ const waitUntilReady = useEvent(options.waitUntilReady);
28
+ const checkHealth = useEvent(options.checkHealth);
29
+ const onNotification = useEvent(options.onNotification);
30
+ // Opens nothing until the replica follows it.
31
+ const backend = useMemo(
32
+ () =>
33
+ createEditorBackend({
34
+ static: isStatic,
35
+ transportType,
36
+ url,
37
+ headers,
38
+ notebookUrl,
39
+ waitUntilReady,
40
+ checkHealth,
41
+ }),
42
+ [
43
+ isStatic,
44
+ transportType,
45
+ url,
46
+ headers,
47
+ notebookUrl,
48
+ waitUntilReady,
49
+ checkHealth,
50
+ ],
51
+ );
52
+
53
+ useEffect(() => {
54
+ store.set(notebookReplicaAtom, replica);
55
+ const disconnect = replica.connect(backend, {
56
+ // The host validates the envelope, not the native notification contents.
57
+ onNotification: (notification) =>
58
+ onNotification({ data: notification as NotificationMessage }),
59
+ });
60
+ return () => {
61
+ disconnect();
62
+ if (store.get(notebookReplicaAtom) === replica) {
63
+ store.set(notebookReplicaAtom, null);
64
+ }
65
+ };
66
+ }, [replica, backend, store, onNotification]);
67
+
68
+ return replica;
69
+ }
@@ -12,7 +12,7 @@ import type {
12
12
  NotificationMessageData,
13
13
  NotificationPayload,
14
14
  } from "@/core/kernel/messages";
15
- import { useNotebookClient } from "@/core/websocket/useNotebookClient";
15
+ import { useNotebookReplica } from "@/core/websocket/useNotebookReplica";
16
16
  import { renderHTML } from "@/plugins/core/RenderHTML";
17
17
  import {
18
18
  handleWidgetMessage,
@@ -408,7 +408,7 @@ export function useNotebookSession(opts: {
408
408
  variant: "danger",
409
409
  });
410
410
  };
411
- const notebook = useNotebookClient({
411
+ const replica = useNotebookReplica({
412
412
  static: isStaticNotebook(),
413
413
  transportType,
414
414
  url: () =>
@@ -438,7 +438,7 @@ export function useNotebookSession(opts: {
438
438
  // Subscribe before passive connection setup: static/WASM transports can emit synchronously.
439
439
  useLayoutEffect(() => {
440
440
  const updateConnection = () => {
441
- const status = store.get(notebook.connectionAtom);
441
+ const status = store.get(replica.connectionAtom);
442
442
  if (status.state === ConnectionState.NOT_STARTED) {
443
443
  return;
444
444
  }
@@ -469,16 +469,16 @@ export function useNotebookSession(opts: {
469
469
  return status;
470
470
  });
471
471
  };
472
- const unsubscribe = store.sub(notebook.connectionAtom, updateConnection);
472
+ const unsubscribe = store.sub(replica.connectionAtom, updateConnection);
473
473
  updateConnection();
474
474
  return unsubscribe;
475
475
  }, [
476
- notebook,
476
+ replica,
477
477
  store,
478
478
  setConnection,
479
479
  setKernelStartupError,
480
480
  updateStartupProgress,
481
481
  ]);
482
482
 
483
- return { notebook, connection, reconnect: notebook.reconnect };
483
+ return { replica, connection, reconnect: replica.reconnect };
484
484
  }
@@ -495,7 +495,7 @@ export const Chatbot: React.FC<Props> = (props) => {
495
495
  : undefined,
496
496
  })}
497
497
  </div>
498
- <div className="flex justify-end text-xs gap-2 invisible group-hover:visible">
498
+ <div className="flex justify-end text-xs gap-2 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
499
499
  <CopyClipboardIcon
500
500
  value={textContent}
501
501
  className="h-3 w-3"
@@ -503,6 +503,7 @@ export const Chatbot: React.FC<Props> = (props) => {
503
503
  />
504
504
  <button
505
505
  type="button"
506
+ aria-label="Delete message"
506
507
  onClick={() => handleDelete(message.id)}
507
508
  className="text-xs text-(--slate-9) hover:text-(--slate-11)"
508
509
  >
@@ -60,7 +60,9 @@ export const ColumnSummary: React.FC<Props> = ({ schema }) => {
60
60
  field: field,
61
61
  });
62
62
  return (
63
- <span
63
+ <button
64
+ type="button"
65
+ aria-pressed={selectedField === field}
64
66
  key={field}
65
67
  className={cn(
66
68
  "hover:bg-muted self-start px-2 py-2 rounded flex flex-row gap-1 items-center cursor-pointer lg:justify-center text-sm truncate shrink-0 overflow-hidden",
@@ -81,7 +83,7 @@ export const ColumnSummary: React.FC<Props> = ({ schema }) => {
81
83
  ({cardinality})
82
84
  </span>
83
85
  )}
84
- </span>
86
+ </button>
85
87
  );
86
88
  })}
87
89
  {hasMore && (