@marimo-team/frontend 0.25.3-dev10 → 0.25.3-dev12

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 (123) hide show
  1. package/dist/assets/{CellStatus-nZpuDkm7.js → CellStatus-DVOHpdeJ.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-B4n9EnHB.js → ErrorBoundary-CM13wrd8.js} +2 -2
  4. package/dist/assets/{JsonOutput-BxB0NL8w.js → JsonOutput-Cx1iyibB.js} +1 -1
  5. package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
  6. package/dist/assets/{MarimoErrorOutput-BTy4MOzX.js → MarimoErrorOutput-s_5GOX8k.js} +1 -1
  7. package/dist/assets/{RenderHTML-9bMgbYmo.js → RenderHTML-DX6i99Op.js} +1 -1
  8. package/dist/assets/{RunButton-CFQUNN5c.js → RunButton-BKoh0zYG.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-f_MnLCwI.js → add-cell-with-ai-BpD_790A.js} +6 -6
  10. package/dist/assets/{add-connection-dialog-CoE_PhuD.js → add-connection-dialog-B1BXCjex.js} +2 -2
  11. package/dist/assets/{agent-panel-BWZz24jK.js → agent-panel-CDFUc1uu.js} +1 -1
  12. package/dist/assets/{ai-model-dropdown-zm-2fXzf.js → ai-model-dropdown-i1vfGeVi.js} +2 -2
  13. package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
  14. package/dist/assets/{app-config-button-BhU0ulYs.js → app-config-button-AdV-QDD7.js} +1 -1
  15. package/dist/assets/{blob-iOBwGaP9.js → blob-BG8yoQmx.js} +1 -1
  16. package/dist/assets/{cell-editor-CAj1UKxx.js → cell-editor-BKJ06H5F.js} +5 -5
  17. package/dist/assets/cell-link-8Auigi8P.js +1 -0
  18. package/dist/assets/{cells-rA_jsZCj.js → cells-C6YGYGdi.js} +56 -56
  19. package/dist/assets/{chat-display-BmEPmhDP.js → chat-display-DhgI2MS9.js} +1 -1
  20. package/dist/assets/{chat-panel-Ce2Xh7dM.js → chat-panel-Cau71Ygr.js} +1 -1
  21. package/dist/assets/{chat-ui-DTy98rP3.js → chat-ui-C5ql1Up-.js} +1 -1
  22. package/dist/assets/code-visibility-BJImdpDi.js +1 -0
  23. package/dist/assets/coerce-DWjFeXOY.js +1 -0
  24. package/dist/assets/{column-preview-DU6dT80y.js → column-preview-BZ_iNhtJ.js} +1 -1
  25. package/dist/assets/{command-palette-BMqFKXVr.js → command-palette-DDC3kBet.js} +1 -1
  26. package/dist/assets/{common-lisZ0l6a.js → common-Sl3uthjg.js} +1 -1
  27. package/dist/assets/{components-Dvd1GnD6.js → components-3cou6Mka.js} +1 -1
  28. package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
  29. package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
  30. package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
  31. package/dist/assets/{config-DJLAC21v.js → config-DRIpOKqh.js} +1 -1
  32. package/dist/assets/config-Dy28fDwn.js +1 -0
  33. package/dist/assets/{datasource-Dr1xNKUn.js → datasource-DfbNb83e.js} +1 -1
  34. package/dist/assets/{dependency-graph-panel-B0uP-_id.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
  35. package/dist/assets/{documentation-panel-DVl_ebWy.js → documentation-panel-D7S8JIc2.js} +1 -1
  36. package/dist/assets/{download-Bx55LMki.js → download-C03FOElq.js} +1 -1
  37. package/dist/assets/{edit-page-DlfCLUaq.js → edit-page-D_Rg0gQO.js} +6 -6
  38. package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
  39. package/dist/assets/{error-entries-DOWpdGvI.js → error-entries-CLnPDv7b.js} +1 -1
  40. package/dist/assets/{error-panel-DpbNKSLs.js → error-panel-D2xrYLue.js} +1 -1
  41. package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
  42. package/dist/assets/{feedback-button-B99ThNJW.js → feedback-button-DzPunIqU.js} +2 -2
  43. package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
  44. package/dist/assets/{file-explorer-panel-Bo9eN3rW.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
  45. package/dist/assets/{file-name-input-ChnBsDE_.js → file-name-input-B4fRaQkI.js} +1 -1
  46. package/dist/assets/{floating-outline-MYqQW4Vp.js → floating-outline-B5MaM1JS.js} +1 -1
  47. package/dist/assets/{gallery-page-DSL0J4md.js → gallery-page-BefvdsM7.js} +1 -1
  48. package/dist/assets/{glide-data-editor-B1MWlKeM.js → glide-data-editor-C3eaYlXg.js} +1 -1
  49. package/dist/assets/{home-page-KrhQ55Vg.js → home-page-hmvKlc1w.js} +1 -1
  50. package/dist/assets/{hooks-BIogTGCr.js → hooks-DzML9nez.js} +1 -1
  51. package/dist/assets/index-ptK1gxmo.js +30 -0
  52. package/dist/assets/{layout-DOpdUwym.js → layout-Ck1RoL1B.js} +5 -5
  53. package/dist/assets/{logs-panel-y0XnfulJ.js → logs-panel-BRytaWaO.js} +1 -1
  54. package/dist/assets/{markdown-renderer-Q5AhbRU3.js → markdown-renderer-DX0Xrrfy.js} +1 -1
  55. package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
  56. package/dist/assets/{name-cell-input-BNOnF6-0.js → name-cell-input-DlpTvPLa.js} +1 -1
  57. package/dist/assets/{outline-panel-Be7stWwv.js → outline-panel-B2WIpz-1.js} +1 -1
  58. package/dist/assets/{packages-panel-ClxOAEGy.js → packages-panel-CI5ff9b-.js} +1 -1
  59. package/dist/assets/{panel-accordion-state-GeMEdCPw.js → panel-accordion-state-BqSH5ttk.js} +1 -1
  60. package/dist/assets/panels-BCcZ_yLK.js +107 -0
  61. package/dist/assets/{process-output-DNFKMODr.js → process-output-f3C3PD0Z.js} +1 -1
  62. package/dist/assets/{readonly-code-display-YULOXf4h.js → readonly-code-display-MnMEoYW5.js} +1 -1
  63. package/dist/assets/{readonly-python-code-CadgXgRu.js → readonly-python-code-ggs4hAzC.js} +1 -1
  64. package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
  65. package/dist/assets/{reveal-component-BVAR7ayZ.js → reveal-component-PiV1eQk9.js} +1 -1
  66. package/dist/assets/run-page-AfZhtEh1.js +1 -0
  67. package/dist/assets/{runtime-details-state-DuChzsA6.js → runtime-details-state-CoL-p0MC.js} +1 -1
  68. package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
  69. package/dist/assets/{scratchpad-panel-By-ybhjg.js → scratchpad-panel-D2rWg4k8.js} +1 -1
  70. package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
  71. package/dist/assets/{session-panel-BmW6PH4z.js → session-panel-CnANhcTo.js} +1 -1
  72. package/dist/assets/{snippets-panel-CQ1dn3lA.js → snippets-panel-ClGHtEKQ.js} +1 -1
  73. package/dist/assets/{state-z3dlofZB.js → state-CbjBtkdd.js} +3 -3
  74. package/dist/assets/{state-Bfh_2SLl.js → state-DtGB7xeJ.js} +1 -1
  75. package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
  76. package/dist/assets/{tracing-BARlJ_Dk.js → tracing-Dz2rYOFL.js} +1 -1
  77. package/dist/assets/{tracing-panel-dgnr2fIN.js → tracing-panel-DqQY9V91.js} +2 -2
  78. package/dist/assets/{tree-actions-DAd80MbP.js → tree-actions-DnUokmva.js} +1 -1
  79. package/dist/assets/{useAddCell-DGstJK0S.js → useAddCell-IN24thff.js} +1 -1
  80. package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
  81. package/dist/assets/{useCellActionButton-DY9RlJTb.js → useCellActionButton-DmaFZ4oF.js} +1 -1
  82. package/dist/assets/{useDeleteCell-DFBGlSQk.js → useDeleteCell-fQlM46Y2.js} +1 -1
  83. package/dist/assets/{useDependencyPanelTab-DydxsE5d.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
  84. package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
  85. package/dist/assets/{useNotebookActions-DfvB7RD_.js → useNotebookActions-DHt3s-ia.js} +1 -1
  86. package/dist/assets/{useRunCells-BFjFX3hz.js → useRunCells-DSJPXE-j.js} +1 -1
  87. package/dist/assets/{useSplitCell-Tqv5pPtL.js → useSplitCell-DQyMF5gd.js} +1 -1
  88. package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
  89. package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
  90. package/dist/index.html +32 -32
  91. package/package.json +1 -1
  92. package/src/__tests__/mount.test.ts +20 -0
  93. package/src/core/edit-app.tsx +2 -2
  94. package/src/core/host/__tests__/events.test.ts +46 -0
  95. package/src/core/host/__tests__/notebook-client.test.ts +191 -0
  96. package/src/core/host/connection.ts +81 -0
  97. package/src/core/host/events.ts +60 -0
  98. package/src/core/host/notebook-client.ts +226 -0
  99. package/src/core/run-app.tsx +2 -2
  100. package/src/core/runtime/__tests__/runtime.test.ts +55 -11
  101. package/src/core/runtime/runtime.ts +14 -1
  102. package/src/core/runtime/types.ts +2 -0
  103. package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
  104. package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
  105. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
  106. package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +227 -133
  107. package/src/core/websocket/connection-state.ts +70 -0
  108. package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
  109. package/src/core/websocket/transports/create.ts +31 -0
  110. package/src/core/websocket/transports/transport.ts +12 -23
  111. package/src/core/websocket/types.ts +9 -39
  112. package/src/core/websocket/useNotebookClient.ts +80 -0
  113. package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +64 -231
  114. package/src/mount.tsx +4 -0
  115. package/dist/assets/cell-link-1CynTVGQ.js +0 -1
  116. package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
  117. package/dist/assets/coerce-CpE6kUdX.js +0 -1
  118. package/dist/assets/config-DX8Y2WbJ.js +0 -1
  119. package/dist/assets/feedback-button-DlqZkJwz.js +0 -1
  120. package/dist/assets/index-B3fcrmuf.js +0 -30
  121. package/dist/assets/panels-DfCmzLtT.js +0 -4
  122. package/dist/assets/run-page-Cxu1yV8Q.js +0 -1
  123. package/src/core/websocket/useWebSocket.tsx +0 -88
@@ -21,14 +21,9 @@ import {
21
21
  vi,
22
22
  } from "vitest";
23
23
 
24
- vi.mock("@/core/websocket/useWebSocket", async () => {
25
- const actual =
26
- await vi.importActual<typeof import("../useWebSocket")>("../useWebSocket");
27
- return {
28
- ...actual,
29
- useConnectionTransport: vi.fn(),
30
- };
31
- });
24
+ vi.mock("../transports/create", () => ({
25
+ createConnectionTransport: vi.fn(),
26
+ }));
32
27
 
33
28
  vi.mock("@/core/runtime/config", async () => {
34
29
  const actual = await vi.importActual<typeof import("@/core/runtime/config")>(
@@ -41,6 +36,9 @@ vi.mock("@/core/runtime/config", async () => {
41
36
  });
42
37
 
43
38
  import { MockNotebook } from "@/__mocks__/notebook";
39
+ import * as Toast from "@/components/ui/use-toast";
40
+ import { Logger } from "@/utils/Logger";
41
+ import { Deferred } from "@/utils/Deferred";
44
42
  import { cellId } from "@/__tests__/branded";
45
43
  import { alertAtom, getPackageAlert } from "@/core/alerts/state";
46
44
  import {
@@ -52,15 +50,19 @@ import { notebookAtom } from "@/core/cells/cells";
52
50
  import { AppConfigSchema } from "@/core/config/config-schema";
53
51
  import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
54
52
  import { kernelStartupErrorAtom } from "@/core/errors/state";
53
+ import type { RunEvent } from "@/core/host/protocol";
55
54
  import type { NotificationPayload } from "@/core/kernel/messages";
56
55
  import { useRuntimeManager } from "@/core/runtime/config";
57
56
  import { initialRunCompletedAtom } from "../../kernel/state";
58
57
  import { connectionAtom, startupProgressAtom } from "../../network/connection";
59
- import type { SessionId } from "../../kernel/session";
58
+ import { generateSessionId } from "../../kernel/session";
59
+ import { RuntimeManager } from "../../runtime/runtime";
60
60
  import { WebSocketClosedReason, ConnectionState } from "../types";
61
61
  import type { IConnectionTransport } from "../transports/transport";
62
- import { useMarimoKernelConnection } from "../useMarimoKernelConnection";
63
- import { useConnectionTransport } from "../useWebSocket";
62
+ import { useNotebookSession } from "../useNotebookSession";
63
+ import { createConnectionTransport } from "../transports/create";
64
+
65
+ const SESSION_ID = generateSessionId();
64
66
 
65
67
  interface MockTransport {
66
68
  readyState: 0 | 1 | 2 | 3;
@@ -84,16 +86,47 @@ function makeTransport(
84
86
  };
85
87
  }
86
88
 
89
+ function getTransportHandlers() {
90
+ const result = vi.mocked(createConnectionTransport).mock.results.at(-1);
91
+ if (!result || result.type !== "return") {
92
+ throw new Error("No connection transport was created");
93
+ }
94
+ const transport = result.value;
95
+ function emit(
96
+ event: "open" | "close" | "error" | "message",
97
+ message: Event | CloseEvent | MessageEvent,
98
+ ) {
99
+ const callback = vi
100
+ .mocked(transport.addEventListener)
101
+ .mock.calls.find(([name]) => name === event)?.[1];
102
+ callback?.(message);
103
+ }
104
+ return {
105
+ onError: (event: Event) => emit("error", event),
106
+ onOpen: (event: Event) => emit("open", event),
107
+ onClose: (event: CloseEvent) => emit("close", event),
108
+ onMessage: (event: MessageEvent) => emit("message", event),
109
+ };
110
+ }
111
+
87
112
  function makeRuntimeManager(
88
113
  reconcileFromHealth = vi.fn<() => Promise<boolean>>().mockResolvedValue(true),
89
114
  ) {
90
- return {
115
+ const runtime = new RuntimeManager(
116
+ { url: "http://localhost", lazy: true },
117
+ true,
118
+ );
119
+ vi.spyOn(runtime, "reconcileFromHealth").mockImplementation(
91
120
  reconcileFromHealth,
92
- probeHealth: vi.fn().mockResolvedValue(true),
93
- getWsURL: () => new URL("ws://localhost/ws"),
94
- waitForHealthy: vi.fn().mockResolvedValue(undefined),
95
- isSameOrigin: true,
96
- };
121
+ );
122
+ vi.spyOn(runtime, "probeHealth").mockResolvedValue(true);
123
+ vi.spyOn(runtime, "getWsURL").mockReturnValue(new URL("ws://localhost/ws"));
124
+ // Keep the initial connection pending; these tests exercise session retries.
125
+ vi.spyOn(runtime, "waitForHealthy").mockReturnValue(
126
+ new Deferred<void>().promise,
127
+ );
128
+ vi.spyOn(runtime, "isSameOrigin", "get").mockReturnValue(false);
129
+ return runtime;
97
130
  }
98
131
 
99
132
  function renderConnectionHook(store: ReturnType<typeof createStore>) {
@@ -104,16 +137,16 @@ function renderConnectionHook(store: ReturnType<typeof createStore>) {
104
137
  );
105
138
  return renderHook(
106
139
  () =>
107
- useMarimoKernelConnection({
108
- sessionId: "test-session" as SessionId,
140
+ useNotebookSession({
141
+ sessionId: SESSION_ID,
109
142
  autoInstantiate: false,
110
- setCells: () => {},
143
+ setCells: vi.fn(),
111
144
  }),
112
145
  { wrapper },
113
146
  );
114
147
  }
115
148
 
116
- describe("useMarimoKernelConnection.reconnect()", () => {
149
+ describe("useNotebookSession.reconnect()", () => {
117
150
  let transport: MockTransport;
118
151
  let isHealthy: Mock<() => Promise<boolean>>;
119
152
  let store: ReturnType<typeof createStore>;
@@ -127,12 +160,8 @@ describe("useMarimoKernelConnection.reconnect()", () => {
127
160
  code: WebSocketClosedReason.KERNEL_DISCONNECTED,
128
161
  reason: "kernel not found",
129
162
  });
130
- vi.mocked(useConnectionTransport).mockReturnValue(transport);
131
- vi.mocked(useRuntimeManager).mockReturnValue(
132
- makeRuntimeManager(isHealthy) as unknown as ReturnType<
133
- typeof useRuntimeManager
134
- >,
135
- );
163
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
164
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager(isHealthy));
136
165
  });
137
166
 
138
167
  function renderUseHook() {
@@ -188,17 +217,29 @@ describe("useMarimoKernelConnection.reconnect()", () => {
188
217
  });
189
218
  });
190
219
 
191
- describe("useMarimoKernelConnection messages", () => {
192
- it("replaces attachment snapshots, including the last detach", () => {
193
- const store = createStore();
194
- vi.mocked(useConnectionTransport).mockReturnValue(
220
+ describe("useNotebookSession messages", () => {
221
+ let store: ReturnType<typeof createStore>;
222
+
223
+ beforeEach(() => {
224
+ store = createStore();
225
+ vi.mocked(createConnectionTransport).mockClear();
226
+ vi.mocked(createConnectionTransport).mockReturnValue(
195
227
  makeTransport(WebSocket.OPEN),
196
228
  );
197
- vi.mocked(useRuntimeManager).mockReturnValue(
198
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
199
- );
229
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
200
230
  renderConnectionHook(store);
201
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
231
+ });
232
+
233
+ function receive(payload: unknown) {
234
+ const options = getTransportHandlers();
235
+ act(() => {
236
+ options.onMessage(
237
+ new MessageEvent("message", { data: JSON.stringify(payload) }),
238
+ );
239
+ });
240
+ }
241
+
242
+ it("replaces attachment snapshots, including the last detach", () => {
202
243
  const agent: Attachment = {
203
244
  id: "a1",
204
245
  kind: "agent",
@@ -215,46 +256,157 @@ describe("useMarimoKernelConnection messages", () => {
215
256
  notebook_id: "nb-test",
216
257
  };
217
258
  for (const attachments of [[agent, client], [client], []]) {
218
- act(() =>
219
- options.onMessage(
220
- new MessageEvent("message", {
221
- data: JSON.stringify({
222
- op: "attachments",
223
- data: { op: "attachments", attachments },
224
- }),
225
- }),
226
- ),
227
- );
259
+ receive({ data: { op: "attachments", attachments } });
228
260
  expect(store.get(attachmentsAtom)).toEqual(attachments);
229
261
  }
230
262
  });
231
263
 
232
- it("records completion of the initial run", () => {
264
+ it.each(["{broken", "null"])(
265
+ "reports a malformed legacy frame and continues applying notifications: %s",
266
+ (data) => {
267
+ const toast = vi.spyOn(Toast, "toast");
268
+ try {
269
+ receive({ data: { op: "reconnected" } });
270
+ act(() =>
271
+ getTransportHandlers().onMessage(
272
+ new MessageEvent("message", { data }),
273
+ ),
274
+ );
275
+ expect(toast).toHaveBeenCalledExactlyOnceWith({
276
+ title: "Failed to handle message",
277
+ description: expect.any(String),
278
+ variant: "danger",
279
+ });
280
+ expect(store.get(connectionAtom)).toEqual({
281
+ state: ConnectionState.OPEN,
282
+ });
283
+ receive({
284
+ data: {
285
+ op: "completed-run",
286
+ run_id: "r1",
287
+ outcome: "succeeded",
288
+ error: null,
289
+ },
290
+ });
291
+ expect(store.get(initialRunCompletedAtom)).toBe(true);
292
+ } finally {
293
+ toast.mockRestore();
294
+ }
295
+ },
296
+ );
297
+
298
+ it.each(["session", "host"])(
299
+ "records completion of the initial run from the %s stream",
300
+ (stream) => {
301
+ const notification = {
302
+ op: "completed-run",
303
+ run_id: null,
304
+ outcome: "succeeded",
305
+ error: null,
306
+ } satisfies NotificationPayload["data"];
307
+ expect(store.get(initialRunCompletedAtom)).toBe(false);
308
+ receive(
309
+ stream === "host"
310
+ ? { id: "42", event: "marimo", data: { marimo: notification } }
311
+ : { op: notification.op, data: notification },
312
+ );
313
+ expect(store.get(initialRunCompletedAtom)).toBe(true);
314
+ },
315
+ );
316
+
317
+ it("ignores other host events before handling the next notification", () => {
318
+ const errors = vi.spyOn(Logger, "error").mockImplementation(vi.fn());
319
+ try {
320
+ receive({ id: "1", event: "ready", data: {} });
321
+ receive({
322
+ id: "2",
323
+ event: "run",
324
+ data: {
325
+ run: {
326
+ id: "r1",
327
+ notebook_id: "n1",
328
+ caused_by: null,
329
+ status: "completed",
330
+ outcome: "succeeded",
331
+ started_at: "2026-10-19T12:00:00Z",
332
+ completed_at: "2026-10-19T12:00:01Z",
333
+ },
334
+ },
335
+ } satisfies RunEvent);
336
+ receive({
337
+ id: "3",
338
+ event: "future-event",
339
+ data: { marimo: { op: "completed-run", run_id: "r1" } },
340
+ });
341
+ receive({ id: "4", event: "another-event", data: null });
342
+ expect(store.get(initialRunCompletedAtom)).toBe(false);
343
+ receive({
344
+ id: "5",
345
+ event: "marimo",
346
+ data: {
347
+ marimo: {
348
+ op: "completed-run",
349
+ run_id: "r1",
350
+ outcome: "succeeded",
351
+ error: null,
352
+ },
353
+ },
354
+ });
355
+ expect(store.get(initialRunCompletedAtom)).toBe(true);
356
+ expect(errors).not.toHaveBeenCalled();
357
+ } finally {
358
+ errors.mockRestore();
359
+ }
360
+ });
361
+ });
362
+
363
+ it.each(["kernel-ready", "reconnected"])(
364
+ "waits for %s before marking the session connected",
365
+ async (op) => {
233
366
  const store = createStore();
234
- vi.mocked(useConnectionTransport).mockClear();
235
- vi.mocked(useConnectionTransport).mockReturnValue(
367
+ store.set(connectionAtom, { state: ConnectionState.CONNECTING });
368
+ vi.mocked(createConnectionTransport).mockClear();
369
+ vi.mocked(createConnectionTransport).mockReturnValue(
236
370
  makeTransport(WebSocket.OPEN),
237
371
  );
238
- vi.mocked(useRuntimeManager).mockReturnValue(
239
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
240
- );
372
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
241
373
  renderConnectionHook(store);
374
+ const options = getTransportHandlers();
375
+
376
+ await act(async () => {
377
+ await options.onOpen(new Event("open"));
378
+ });
379
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.CONNECTING);
242
380
 
243
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)?.[0];
244
- expect(store.get(initialRunCompletedAtom)).toBe(false);
245
381
  act(() => {
246
- options?.onMessage(
382
+ options.onMessage(
247
383
  new MessageEvent("message", {
248
384
  data: JSON.stringify({
249
- op: "completed-run",
250
- data: { op: "completed-run", run_id: null },
385
+ op,
386
+ data: {
387
+ op,
388
+ cell_ids: [],
389
+ codes: [],
390
+ names: [],
391
+ configs: [],
392
+ layout: null,
393
+ resumed: true,
394
+ ui_values: {},
395
+ last_executed_code: {},
396
+ last_execution_time: {},
397
+ app_config: { width: "normal" },
398
+ kiosk: false,
399
+ capabilities: { terminal: false },
400
+ auto_instantiated: false,
401
+ consumer_capabilities: { edit: true, interact: true },
402
+ },
251
403
  }),
252
404
  }),
253
405
  );
254
406
  });
255
- expect(store.get(initialRunCompletedAtom)).toBe(true);
256
- });
257
- });
407
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.OPEN);
408
+ },
409
+ );
258
410
 
259
411
  describe("attachment snapshots across browser connections", () => {
260
412
  const agent: Attachment = {
@@ -271,12 +423,10 @@ describe("attachment snapshots across browser connections", () => {
271
423
  store.set(connectionAtom, { state: ConnectionState.OPEN });
272
424
  store.set(attachmentsAtom, [agent]);
273
425
  const transport = makeTransport(WebSocket.OPEN);
274
- vi.mocked(useConnectionTransport).mockReturnValue(transport);
275
- vi.mocked(useRuntimeManager).mockReturnValue(
276
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
277
- );
426
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
427
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
278
428
  renderConnectionHook(store);
279
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
429
+ const options = getTransportHandlers();
280
430
  const receive = (data: NotificationPayload["data"]) =>
281
431
  act(() => {
282
432
  options.onMessage(
@@ -351,56 +501,6 @@ describe("attachment snapshots across browser connections", () => {
351
501
  });
352
502
  });
353
503
 
354
- it.each(["kernel-ready", "reconnected"])(
355
- "waits for %s before marking the session connected",
356
- async (op) => {
357
- const store = createStore();
358
- store.set(connectionAtom, { state: ConnectionState.CONNECTING });
359
- vi.mocked(useConnectionTransport).mockClear();
360
- vi.mocked(useConnectionTransport).mockReturnValue(
361
- makeTransport(WebSocket.OPEN),
362
- );
363
- vi.mocked(useRuntimeManager).mockReturnValue(
364
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
365
- );
366
- renderConnectionHook(store);
367
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
368
-
369
- await act(async () => {
370
- await options.onOpen(new Event("open"));
371
- });
372
- expect(store.get(connectionAtom).state).toBe(ConnectionState.CONNECTING);
373
-
374
- act(() => {
375
- options.onMessage(
376
- new MessageEvent("message", {
377
- data: JSON.stringify({
378
- op,
379
- data: {
380
- op,
381
- cell_ids: [],
382
- codes: [],
383
- names: [],
384
- configs: [],
385
- layout: null,
386
- resumed: true,
387
- ui_values: {},
388
- last_executed_code: {},
389
- last_execution_time: {},
390
- app_config: { width: "normal" },
391
- kiosk: false,
392
- capabilities: { terminal: false },
393
- auto_instantiated: false,
394
- consumer_capabilities: { edit: true, interact: true },
395
- },
396
- }),
397
- }),
398
- );
399
- });
400
- expect(store.get(connectionAtom).state).toBe(ConnectionState.OPEN);
401
- },
402
- );
403
-
404
504
  it.each([true, false])(
405
505
  "keeps attachments only when kernel-ready resumes the session: %s",
406
506
  (resumed) => {
@@ -416,14 +516,12 @@ it.each([true, false])(
416
516
  },
417
517
  ];
418
518
  store.set(attachmentsAtom, attachments);
419
- vi.mocked(useConnectionTransport).mockReturnValue(
519
+ vi.mocked(createConnectionTransport).mockReturnValue(
420
520
  makeTransport(WebSocket.OPEN),
421
521
  );
422
- vi.mocked(useRuntimeManager).mockReturnValue(
423
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
424
- );
522
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
425
523
  renderConnectionHook(store);
426
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
524
+ const options = getTransportHandlers();
427
525
  act(() =>
428
526
  options.onMessage(
429
527
  new MessageEvent("message", {
@@ -462,15 +560,13 @@ describe("connection notice", () => {
462
560
  const store = createStore();
463
561
  store.set(connectionAtom, { state: ConnectionState.CONNECTING });
464
562
  const transport = makeTransport(WebSocket.OPEN);
465
- vi.mocked(useConnectionTransport).mockReturnValue(transport);
466
- vi.mocked(useRuntimeManager).mockReturnValue(
467
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
468
- );
563
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
564
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
469
565
  const Connection = () => {
470
- const { reconnect } = useMarimoKernelConnection({
471
- sessionId: "test-session" as SessionId,
566
+ const { reconnect } = useNotebookSession({
567
+ sessionId: SESSION_ID,
472
568
  autoInstantiate: false,
473
- setCells: () => {},
569
+ setCells: vi.fn(),
474
570
  });
475
571
  return (
476
572
  <ConnectionNotice
@@ -486,7 +582,7 @@ describe("connection notice", () => {
486
582
  </ErrorBoundary>
487
583
  </JotaiProvider>,
488
584
  );
489
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
585
+ const options = getTransportHandlers();
490
586
  const send = (data: NotificationPayload["data"]) =>
491
587
  act(() => {
492
588
  options.onMessage(
@@ -675,15 +771,13 @@ describe("connection notice", () => {
675
771
 
676
772
  it("replaces environment state from snapshots, then continues live progress", () => {
677
773
  const store = createStore();
678
- vi.mocked(useConnectionTransport).mockClear();
679
- vi.mocked(useConnectionTransport).mockReturnValue(
774
+ vi.mocked(createConnectionTransport).mockClear();
775
+ vi.mocked(createConnectionTransport).mockReturnValue(
680
776
  makeTransport(WebSocket.OPEN),
681
777
  );
682
- vi.mocked(useRuntimeManager).mockReturnValue(
683
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
684
- );
778
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
685
779
  renderConnectionHook(store);
686
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)?.[0];
780
+ const options = getTransportHandlers();
687
781
  function receive(data: NotificationPayload["data"]) {
688
782
  act(() =>
689
783
  options?.onMessage(
@@ -0,0 +1,70 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { CloseDecision } from "../host/connection";
4
+ import { Logger } from "@/utils/Logger";
5
+ import { TRANSPORT_EXHAUSTED_REASON } from "./transports/ws";
6
+ import { ConnectionState, WebSocketClosedReason } from "./types";
7
+
8
+ // Keep server reasons in sync with ws_endpoint.py and ws/*.py.
9
+ // SSE carries the same reasons in close events. Unclassified reasons retry.
10
+ export function classifyCloseEvent(event: { reason?: string }): CloseDecision {
11
+ switch (event.reason) {
12
+ case TRANSPORT_EXHAUSTED_REASON:
13
+ return {
14
+ kind: "gave-up",
15
+ status: {
16
+ state: ConnectionState.CLOSED,
17
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
18
+ reason: "kernel not found",
19
+ },
20
+ };
21
+ case "MARIMO_NO_FILE_KEY":
22
+ case "MARIMO_NO_SESSION_ID":
23
+ case "MARIMO_NO_SESSION":
24
+ case "MARIMO_SHUTDOWN":
25
+ return {
26
+ kind: "terminal",
27
+ status: {
28
+ state: ConnectionState.CLOSED,
29
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
30
+ reason: "kernel not found",
31
+ },
32
+ };
33
+ case "MARIMO_UNAUTHORIZED":
34
+ return {
35
+ kind: "terminal",
36
+ status: {
37
+ state: ConnectionState.CLOSED,
38
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
39
+ reason: "not authorized",
40
+ },
41
+ };
42
+ case "MARIMO_KIOSK_NOT_ALLOWED":
43
+ return {
44
+ kind: "terminal",
45
+ status: {
46
+ state: ConnectionState.CLOSED,
47
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
48
+ reason: "kiosk mode is not available for this session",
49
+ },
50
+ };
51
+ case "MARIMO_KERNEL_STARTUP_ERROR":
52
+ return {
53
+ kind: "terminal",
54
+ status: {
55
+ state: ConnectionState.CLOSED,
56
+ code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
57
+ reason: "Failed to start kernel sandbox",
58
+ },
59
+ };
60
+ default:
61
+ if (event.reason) {
62
+ Logger.warn("Unrecognized connection close reason", event.reason);
63
+ }
64
+ }
65
+
66
+ return {
67
+ kind: "retry",
68
+ status: { state: ConnectionState.CONNECTING },
69
+ };
70
+ }
@@ -108,8 +108,6 @@ describe("SseTransport", () => {
108
108
  transport.reconnect();
109
109
  await vi.waitFor(() => expect(opens).toHaveLength(1));
110
110
 
111
- // Stop retrying when the consumer treats the close as terminal,
112
- // mirroring useMarimoKernelConnection's terminal path.
113
111
  transport.addEventListener("close", () => transport.close());
114
112
  push('event: close\ndata: {"code": 1000, "reason": "MARIMO_SHUTDOWN"}\n\n');
115
113
  await vi.waitFor(() => expect(closes).toHaveLength(1));
@@ -146,8 +144,7 @@ describe("SseTransport", () => {
146
144
  fetchMock
147
145
  .mockResolvedValueOnce(sseResponse(first.stream))
148
146
  .mockResolvedValueOnce(sseResponse(second.stream));
149
- // Mirror useMarimoKernelConnection's retry path: reconnect()
150
- // synchronously from inside the close handler.
147
+ // Reconnect synchronously from the close handler.
151
148
  transport.addEventListener("close", () => transport.reconnect());
152
149
 
153
150
  transport.reconnect();
@@ -0,0 +1,31 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { createPyodideConnection } from "../../wasm/bridge";
4
+ import { isWasm } from "../../wasm/utils";
5
+ import { BasicTransport } from "./basic";
6
+ import { SseTransport } from "./sse";
7
+ import { WsTransport } from "./ws";
8
+ import type { IConnectionTransport } from "./transport";
9
+
10
+ export interface ConnectionTransportOptions {
11
+ url: () => string;
12
+ static: boolean;
13
+ transportType: "websocket" | "sse";
14
+ headers: () => Record<string, string>;
15
+ }
16
+
17
+ export function createConnectionTransport(
18
+ options: ConnectionTransportOptions,
19
+ ): IConnectionTransport {
20
+ if (options.static) {
21
+ return BasicTransport.empty();
22
+ }
23
+ if (isWasm()) {
24
+ return createPyodideConnection();
25
+ }
26
+ // Read the URL lazily to follow runtime redirects.
27
+ if (options.transportType === "sse") {
28
+ return new SseTransport(options.url, options.headers);
29
+ }
30
+ return new WsTransport(options.url);
31
+ }
@@ -1,30 +1,19 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
- export type ConnectionEvent = "open" | "close" | "message" | "error";
3
2
 
4
- export interface IConnectionTransportMap {
5
- open: WebSocketEventMap["open"];
6
- message: WebSocketEventMap["message"];
7
- close: WebSocketEventMap["close"];
8
- error: WebSocketEventMap["error"];
9
- }
10
-
11
- export type ConnectionTransportCallback<T extends ConnectionEvent> = (
12
- data: IConnectionTransportMap[T],
13
- ) => void;
3
+ import type {
4
+ NotebookTransport,
5
+ ConnectionEvent,
6
+ ConnectionTransportCallback,
7
+ IConnectionTransportMap,
8
+ } from "../../host/connection";
9
+ export type {
10
+ ConnectionEvent,
11
+ ConnectionTransportCallback,
12
+ IConnectionTransportMap,
13
+ } from "../../host/connection";
14
14
 
15
- export interface IConnectionTransport {
16
- reconnect(code?: number | undefined, reason?: string | undefined): void;
17
- close(): void;
15
+ export interface IConnectionTransport extends NotebookTransport {
18
16
  send(data: string | ArrayBuffer | Blob | ArrayBufferView<ArrayBuffer>): void;
19
- addEventListener<T extends ConnectionEvent>(
20
- event: T,
21
- callback: ConnectionTransportCallback<T>,
22
- ): void;
23
- removeEventListener<T extends ConnectionEvent>(
24
- event: T,
25
- callback: ConnectionTransportCallback<T>,
26
- ): void;
27
- readyState: WebSocket["readyState"];
28
17
  }
29
18
 
30
19
  export class ConnectionSubscriptions {