@marimo-team/frontend 0.25.3-dev2 → 0.25.3-dev20

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 (182) hide show
  1. package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-DVOHpdeJ.js} +1 -1
  2. package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
  3. package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-CM13wrd8.js} +2 -2
  4. package/dist/assets/JsonOutput-Cx1iyibB.js +11 -0
  5. package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
  6. package/dist/assets/MarimoErrorOutput-s_5GOX8k.js +10 -0
  7. package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-DX6i99Op.js} +1 -1
  8. package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-BKoh0zYG.js} +1 -1
  9. package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-BpD_790A.js} +11 -11
  10. package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-B1BXCjex.js} +2 -2
  11. package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-CDFUc1uu.js} +5 -5
  12. package/dist/assets/ai-model-dropdown-i1vfGeVi.js +13 -0
  13. package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
  14. package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-AdV-QDD7.js} +1 -1
  15. package/dist/assets/{blob-D5nfNHOo.js → blob-BG8yoQmx.js} +1 -1
  16. package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-BKJ06H5F.js} +9 -9
  17. package/dist/assets/cell-link-8Auigi8P.js +1 -0
  18. package/dist/assets/{cells-BRkCaSsh.js → cells-C6YGYGdi.js} +56 -56
  19. package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-DhgI2MS9.js} +1 -1
  20. package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-Cau71Ygr.js} +2 -2
  21. package/dist/assets/{chat-ui-Df8Vcj6n.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-Cm2D6vyX.js → column-preview-BZ_iNhtJ.js} +1 -1
  25. package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-DDC3kBet.js} +1 -1
  26. package/dist/assets/{common-Cu6CDbuw.js → common-Sl3uthjg.js} +1 -1
  27. package/dist/assets/{components-CGw16R_N.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-DRIpOKqh.js +1 -0
  32. package/dist/assets/config-Dy28fDwn.js +1 -0
  33. package/dist/assets/{datasource-Cq6x_5ML.js → datasource-DfbNb83e.js} +1 -1
  34. package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
  35. package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
  36. package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-D7S8JIc2.js} +1 -1
  37. package/dist/assets/{download-DCJXy66H.js → download-C03FOElq.js} +1 -1
  38. package/dist/assets/edit-page-CLF_VUTW.js +9 -0
  39. package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
  40. package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-CLnPDv7b.js} +1 -1
  41. package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-D2xrYLue.js} +1 -1
  42. package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
  43. package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-DzPunIqU.js} +2 -2
  44. package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
  45. package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
  46. package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-B4fRaQkI.js} +1 -1
  47. package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-B5MaM1JS.js} +1 -1
  48. package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-BefvdsM7.js} +1 -1
  49. package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-C3eaYlXg.js} +1 -1
  50. package/dist/assets/{home-page-DMHnsRxm.js → home-page-hmvKlc1w.js} +1 -1
  51. package/dist/assets/{hooks-93IwHd2u.js → hooks-DzML9nez.js} +1 -1
  52. package/dist/assets/index-6b4mf8Ai.css +2 -0
  53. package/dist/assets/index-CMx5iSyu.js +30 -0
  54. package/dist/assets/{layout-DmcNEu3P.js → layout-Ck1RoL1B.js} +5 -5
  55. package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-BRytaWaO.js} +1 -1
  56. package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-DX0Xrrfy.js} +1 -1
  57. package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
  58. package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-DlpTvPLa.js} +1 -1
  59. package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-B2WIpz-1.js} +1 -1
  60. package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-CI5ff9b-.js} +1 -1
  61. package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-BqSH5ttk.js} +1 -1
  62. package/dist/assets/panels-B_jlikjE.js +107 -0
  63. package/dist/assets/{process-output-D0KGq04R.js → process-output-f3C3PD0Z.js} +1 -1
  64. package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-MnMEoYW5.js} +1 -1
  65. package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-ggs4hAzC.js} +1 -1
  66. package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
  67. package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-PiV1eQk9.js} +1 -1
  68. package/dist/assets/run-page-CkP22x9D.js +1 -0
  69. package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-CoL-p0MC.js} +1 -1
  70. package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
  71. package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-D2rWg4k8.js} +1 -1
  72. package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
  73. package/dist/assets/session-panel-CnANhcTo.js +1 -0
  74. package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-ClGHtEKQ.js} +1 -1
  75. package/dist/assets/state-CbjBtkdd.js +3 -0
  76. package/dist/assets/{state-XsPO8hNi.js → state-DtGB7xeJ.js} +1 -1
  77. package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
  78. package/dist/assets/{tracing-Di6pUmX8.js → tracing-Dz2rYOFL.js} +1 -1
  79. package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-DqQY9V91.js} +2 -2
  80. package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-DnUokmva.js} +1 -1
  81. package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-IN24thff.js} +1 -1
  82. package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
  83. package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-DmaFZ4oF.js} +1 -1
  84. package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-fQlM46Y2.js} +1 -1
  85. package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
  86. package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
  87. package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-DHt3s-ia.js} +1 -1
  88. package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-DSJPXE-j.js} +1 -1
  89. package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-DQyMF5gd.js} +1 -1
  90. package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
  91. package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
  92. package/dist/index.html +34 -35
  93. package/package.json +1 -1
  94. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  95. package/src/__tests__/mount.test.ts +28 -8
  96. package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
  97. package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
  98. package/src/components/data-table/__tests__/export-actions.test.tsx +329 -6
  99. package/src/components/data-table/context-menu.tsx +125 -26
  100. package/src/components/data-table/date-filter-inputs.tsx +15 -42
  101. package/src/components/data-table/export-actions.tsx +192 -116
  102. package/src/components/data-table/schemas.ts +14 -36
  103. package/src/components/data-table/utils.ts +18 -0
  104. package/src/components/editor/Output.tsx +9 -2
  105. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  106. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  107. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  108. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  109. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  110. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
  111. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  112. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
  113. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  114. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
  115. package/src/components/editor/errors/send-error-report-button.tsx +69 -0
  116. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  117. package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
  118. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  119. package/src/components/editor/output/ansi-reduce.ts +6 -0
  120. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  121. package/src/core/attachments/state.ts +21 -0
  122. package/src/core/attachments/toast.ts +15 -0
  123. package/src/core/cells/__tests__/cells.test.ts +52 -0
  124. package/src/core/cells/__tests__/error-report.test.ts +139 -0
  125. package/src/core/cells/__tests__/session.test.ts +50 -1
  126. package/src/core/cells/error-report.ts +63 -0
  127. package/src/core/cells/session.ts +28 -1
  128. package/src/core/config/pair.ts +5 -3
  129. package/src/core/edit-app.tsx +2 -2
  130. package/src/core/host/AGENTS.md +5 -0
  131. package/src/core/host/__tests__/document.test.ts +124 -0
  132. package/src/core/host/__tests__/events.test.ts +46 -0
  133. package/src/core/host/__tests__/fixtures.ts +39 -0
  134. package/src/core/host/__tests__/notebook-client.test.ts +303 -0
  135. package/src/core/host/__tests__/stream.test.ts +211 -0
  136. package/src/core/host/connection.ts +81 -0
  137. package/src/core/host/events.ts +60 -0
  138. package/src/core/host/notebook-client.ts +272 -0
  139. package/src/core/host/resources/document.ts +101 -0
  140. package/src/core/host/resources/resource.ts +59 -0
  141. package/src/core/host/stream.ts +213 -0
  142. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  143. package/src/core/islands/bootstrap.ts +1 -0
  144. package/src/core/network/connection.ts +4 -2
  145. package/src/core/run-app.tsx +2 -2
  146. package/src/core/runtime/__tests__/runtime.test.ts +55 -11
  147. package/src/core/runtime/runtime.ts +14 -1
  148. package/src/core/runtime/types.ts +2 -0
  149. package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
  150. package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
  151. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
  152. package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +352 -65
  153. package/src/core/websocket/connection-state.ts +70 -0
  154. package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
  155. package/src/core/websocket/transports/create.ts +31 -0
  156. package/src/core/websocket/transports/transport.ts +12 -23
  157. package/src/core/websocket/types.ts +9 -39
  158. package/src/core/websocket/useNotebookClient.ts +80 -0
  159. package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +67 -225
  160. package/src/mount.tsx +7 -3
  161. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
  162. package/src/utils/__tests__/dates.test.ts +116 -0
  163. package/src/utils/dates.ts +94 -0
  164. package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
  165. package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
  166. package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
  167. package/dist/assets/api-BeC_VSGv.js +0 -1
  168. package/dist/assets/cell-link-LSth_8hT.js +0 -1
  169. package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
  170. package/dist/assets/coerce-CpE6kUdX.js +0 -1
  171. package/dist/assets/config-BOKzA3tF.js +0 -1
  172. package/dist/assets/config-DX8Y2WbJ.js +0 -1
  173. package/dist/assets/edit-page-ChcSgpke.js +0 -9
  174. package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
  175. package/dist/assets/index-Bcdzex-3.css +0 -2
  176. package/dist/assets/index-s-M1OXjs.js +0 -29
  177. package/dist/assets/pair-CaQyK6En.js +0 -1
  178. package/dist/assets/panels-CxVG8-v2.js +0 -4
  179. package/dist/assets/run-page-DF3c5cDk.js +0 -1
  180. package/dist/assets/session-panel-VdDhEBa-.js +0 -1
  181. package/dist/assets/state-_Qai_QyX.js +0 -3
  182. package/src/core/websocket/useWebSocket.tsx +0 -88
@@ -0,0 +1,140 @@
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 { ConnectionState } from "../types";
9
+ import { createConnectionTransport } from "../transports/create";
10
+ import type { IConnectionTransport } from "../transports/transport";
11
+ import { useNotebookClient } from "../useNotebookClient";
12
+
13
+ vi.mock("../transports/create", () => ({ createConnectionTransport: vi.fn() }));
14
+
15
+ function makeTransport() {
16
+ const events = new EventTarget();
17
+ const transport: IConnectionTransport = {
18
+ readyState: WebSocket.CLOSED,
19
+ reconnect: vi.fn(() => {
20
+ transport.readyState = WebSocket.OPEN;
21
+ events.dispatchEvent(new Event("open"));
22
+ }),
23
+ close: vi.fn(() => {
24
+ transport.readyState = WebSocket.CLOSED;
25
+ events.dispatchEvent(new CloseEvent("close"));
26
+ }),
27
+ send: vi.fn(),
28
+ addEventListener: events.addEventListener.bind(events),
29
+ removeEventListener: events.removeEventListener.bind(events),
30
+ };
31
+ return {
32
+ transport,
33
+ notify: () =>
34
+ events.dispatchEvent(
35
+ new MessageEvent("message", {
36
+ data: JSON.stringify({
37
+ event: "marimo",
38
+ data: { marimo: { op: "reconnected" } },
39
+ }),
40
+ }),
41
+ ),
42
+ };
43
+ }
44
+
45
+ function setup(strict = false) {
46
+ const store = createStore();
47
+ const options = {
48
+ sessionId: "first",
49
+ static: false,
50
+ transportType: "websocket" as const,
51
+ messageFormat: "host" as const,
52
+ url: () => "ws://localhost/ws",
53
+ headers: () => ({}),
54
+ waitUntilReady: async () => undefined,
55
+ checkHealth: async () => true,
56
+ onNotification: vi.fn(),
57
+ onMessageError: vi.fn(),
58
+ };
59
+ const wrapper = ({ children }: PropsWithChildren) => (
60
+ <Provider store={store}>{children}</Provider>
61
+ );
62
+ const hook = renderHook((props) => useNotebookClient(props), {
63
+ initialProps: options,
64
+ reactStrictMode: strict,
65
+ wrapper,
66
+ });
67
+ return { ...hook, options, store };
68
+ }
69
+
70
+ beforeEach(() => {
71
+ vi.mocked(createConnectionTransport).mockReset();
72
+ });
73
+
74
+ describe("useNotebookClient", () => {
75
+ it("uses current callbacks and URL providers without reconnecting on a rerender", async () => {
76
+ const first = makeTransport();
77
+ vi.mocked(createConnectionTransport).mockReturnValue(first.transport);
78
+ const { rerender, options, unmount } = setup();
79
+ await act(async () => Promise.resolve());
80
+ const onNotification = vi.fn();
81
+ rerender({ ...options, onNotification, url: () => "ws://redirected/ws" });
82
+ act(() => first.notify());
83
+ expect(options.onNotification).not.toHaveBeenCalled();
84
+ expect(onNotification).toHaveBeenCalledExactlyOnceWith({
85
+ data: { op: "reconnected" },
86
+ });
87
+ expect(createConnectionTransport).toHaveBeenCalledOnce();
88
+ expect(vi.mocked(createConnectionTransport).mock.calls[0][0].url()).toBe(
89
+ "ws://redirected/ws",
90
+ );
91
+ unmount();
92
+ });
93
+
94
+ it("replaces the transport when session identity changes and ignores old messages", async () => {
95
+ const first = makeTransport();
96
+ const second = makeTransport();
97
+ vi.mocked(createConnectionTransport)
98
+ .mockReturnValueOnce(first.transport)
99
+ .mockReturnValueOnce(second.transport);
100
+ const { rerender, options, unmount } = setup();
101
+ await act(async () => Promise.resolve());
102
+ rerender({ ...options, sessionId: "second" });
103
+ await act(async () => Promise.resolve());
104
+ expect(first.transport.close).toHaveBeenCalledOnce();
105
+ act(() => {
106
+ first.notify();
107
+ second.notify();
108
+ });
109
+ expect(options.onNotification).toHaveBeenCalledOnce();
110
+ unmount();
111
+ });
112
+
113
+ it("cleans up every transport under Strict Mode and only connects the active lifetime", async () => {
114
+ const transports: ReturnType<typeof makeTransport>[] = [];
115
+ vi.mocked(createConnectionTransport).mockImplementation(() => {
116
+ const next = makeTransport();
117
+ transports.push(next);
118
+ return next.transport;
119
+ });
120
+ const { result, store, options, unmount } = setup(true);
121
+ await act(async () => Promise.resolve());
122
+ expect(transports).toHaveLength(2);
123
+ expect(transports[0].transport.reconnect).not.toHaveBeenCalled();
124
+ expect(transports[0].transport.close).toHaveBeenCalledOnce();
125
+ expect(transports[1].transport.reconnect).toHaveBeenCalledOnce();
126
+ expect(store.get(result.current.connectionAtom)).toEqual({
127
+ state: ConnectionState.OPEN,
128
+ });
129
+ act(() => {
130
+ for (const transport of transports) {
131
+ transport.notify();
132
+ }
133
+ });
134
+ expect(options.onNotification).toHaveBeenCalledOnce();
135
+ unmount();
136
+ expect(transports[1].transport.close).toHaveBeenCalledOnce();
137
+ await result.current.reconnect();
138
+ expect(transports[1].transport.reconnect).toHaveBeenCalledOnce();
139
+ });
140
+ });
@@ -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,21 +36,33 @@ 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";
44
+ import {
45
+ type Attachment,
46
+ attachmentsAtom,
47
+ agentAttachmentsAtom,
48
+ } from "@/core/attachments/state";
46
49
  import { notebookAtom } from "@/core/cells/cells";
47
50
  import { AppConfigSchema } from "@/core/config/config-schema";
48
51
  import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
49
52
  import { kernelStartupErrorAtom } from "@/core/errors/state";
53
+ import type { RunEvent } from "@/core/host/protocol";
50
54
  import type { NotificationPayload } from "@/core/kernel/messages";
51
55
  import { useRuntimeManager } from "@/core/runtime/config";
52
56
  import { initialRunCompletedAtom } from "../../kernel/state";
53
57
  import { connectionAtom, startupProgressAtom } from "../../network/connection";
54
- import type { SessionId } from "../../kernel/session";
58
+ import { generateSessionId } from "../../kernel/session";
59
+ import { RuntimeManager } from "../../runtime/runtime";
55
60
  import { WebSocketClosedReason, ConnectionState } from "../types";
56
61
  import type { IConnectionTransport } from "../transports/transport";
57
- import { useMarimoKernelConnection } from "../useMarimoKernelConnection";
58
- import { useConnectionTransport } from "../useWebSocket";
62
+ import { useNotebookSession } from "../useNotebookSession";
63
+ import { createConnectionTransport } from "../transports/create";
64
+
65
+ const SESSION_ID = generateSessionId();
59
66
 
60
67
  interface MockTransport {
61
68
  readyState: 0 | 1 | 2 | 3;
@@ -79,16 +86,47 @@ function makeTransport(
79
86
  };
80
87
  }
81
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
+
82
112
  function makeRuntimeManager(
83
113
  reconcileFromHealth = vi.fn<() => Promise<boolean>>().mockResolvedValue(true),
84
114
  ) {
85
- return {
115
+ const runtime = new RuntimeManager(
116
+ { url: "http://localhost", lazy: true },
117
+ true,
118
+ );
119
+ vi.spyOn(runtime, "reconcileFromHealth").mockImplementation(
86
120
  reconcileFromHealth,
87
- probeHealth: vi.fn().mockResolvedValue(true),
88
- getWsURL: () => new URL("ws://localhost/ws"),
89
- waitForHealthy: vi.fn().mockResolvedValue(undefined),
90
- isSameOrigin: true,
91
- };
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;
92
130
  }
93
131
 
94
132
  function renderConnectionHook(store: ReturnType<typeof createStore>) {
@@ -99,16 +137,16 @@ function renderConnectionHook(store: ReturnType<typeof createStore>) {
99
137
  );
100
138
  return renderHook(
101
139
  () =>
102
- useMarimoKernelConnection({
103
- sessionId: "test-session" as SessionId,
140
+ useNotebookSession({
141
+ sessionId: SESSION_ID,
104
142
  autoInstantiate: false,
105
- setCells: () => {},
143
+ setCells: vi.fn(),
106
144
  }),
107
145
  { wrapper },
108
146
  );
109
147
  }
110
148
 
111
- describe("useMarimoKernelConnection.reconnect()", () => {
149
+ describe("useNotebookSession.reconnect()", () => {
112
150
  let transport: MockTransport;
113
151
  let isHealthy: Mock<() => Promise<boolean>>;
114
152
  let store: ReturnType<typeof createStore>;
@@ -122,12 +160,8 @@ describe("useMarimoKernelConnection.reconnect()", () => {
122
160
  code: WebSocketClosedReason.KERNEL_DISCONNECTED,
123
161
  reason: "kernel not found",
124
162
  });
125
- vi.mocked(useConnectionTransport).mockReturnValue(transport);
126
- vi.mocked(useRuntimeManager).mockReturnValue(
127
- makeRuntimeManager(isHealthy) as unknown as ReturnType<
128
- typeof useRuntimeManager
129
- >,
130
- );
163
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
164
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager(isHealthy));
131
165
  });
132
166
 
133
167
  function renderUseHook() {
@@ -183,31 +217,146 @@ describe("useMarimoKernelConnection.reconnect()", () => {
183
217
  });
184
218
  });
185
219
 
186
- describe("useMarimoKernelConnection messages", () => {
187
- it("records completion of the initial run", () => {
188
- const store = createStore();
189
- vi.mocked(useConnectionTransport).mockClear();
190
- 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(
191
227
  makeTransport(WebSocket.OPEN),
192
228
  );
193
- vi.mocked(useRuntimeManager).mockReturnValue(
194
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
195
- );
229
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
196
230
  renderConnectionHook(store);
231
+ });
197
232
 
198
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)?.[0];
199
- expect(store.get(initialRunCompletedAtom)).toBe(false);
233
+ function receive(payload: unknown) {
234
+ const options = getTransportHandlers();
200
235
  act(() => {
201
- options?.onMessage(
202
- new MessageEvent("message", {
203
- data: JSON.stringify({
204
- op: "completed-run",
205
- data: { op: "completed-run", run_id: null },
206
- }),
207
- }),
236
+ options.onMessage(
237
+ new MessageEvent("message", { data: JSON.stringify(payload) }),
208
238
  );
209
239
  });
210
- expect(store.get(initialRunCompletedAtom)).toBe(true);
240
+ }
241
+
242
+ it("replaces attachment snapshots, including the last detach", () => {
243
+ const agent: Attachment = {
244
+ id: "a1",
245
+ kind: "agent",
246
+ name: "Pi",
247
+ since: "2026-10-14T00:00:00Z",
248
+ notebook_id: "nb-test",
249
+ identity: "pi-thread",
250
+ };
251
+ const client: Attachment = {
252
+ id: "c1",
253
+ kind: "client",
254
+ name: null,
255
+ since: "2026-10-14T00:00:01Z",
256
+ notebook_id: "nb-test",
257
+ };
258
+ for (const attachments of [[agent, client], [client], []]) {
259
+ receive({ data: { op: "attachments", attachments } });
260
+ expect(store.get(attachmentsAtom)).toEqual(attachments);
261
+ }
262
+ });
263
+
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
+ }
211
360
  });
212
361
  });
213
362
 
@@ -216,15 +365,13 @@ it.each(["kernel-ready", "reconnected"])(
216
365
  async (op) => {
217
366
  const store = createStore();
218
367
  store.set(connectionAtom, { state: ConnectionState.CONNECTING });
219
- vi.mocked(useConnectionTransport).mockClear();
220
- vi.mocked(useConnectionTransport).mockReturnValue(
368
+ vi.mocked(createConnectionTransport).mockClear();
369
+ vi.mocked(createConnectionTransport).mockReturnValue(
221
370
  makeTransport(WebSocket.OPEN),
222
371
  );
223
- vi.mocked(useRuntimeManager).mockReturnValue(
224
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
225
- );
372
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
226
373
  renderConnectionHook(store);
227
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
374
+ const options = getTransportHandlers();
228
375
 
229
376
  await act(async () => {
230
377
  await options.onOpen(new Event("open"));
@@ -261,6 +408,150 @@ it.each(["kernel-ready", "reconnected"])(
261
408
  },
262
409
  );
263
410
 
411
+ describe("attachment snapshots across browser connections", () => {
412
+ const agent: Attachment = {
413
+ id: "a1",
414
+ kind: "agent",
415
+ name: "Pi",
416
+ since: "2026-10-14T00:00:00Z",
417
+ notebook_id: "nb-test",
418
+ identity: "pi-thread",
419
+ };
420
+
421
+ function connectedSession() {
422
+ const store = createStore();
423
+ store.set(connectionAtom, { state: ConnectionState.OPEN });
424
+ store.set(attachmentsAtom, [agent]);
425
+ const transport = makeTransport(WebSocket.OPEN);
426
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
427
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
428
+ renderConnectionHook(store);
429
+ const options = getTransportHandlers();
430
+ const receive = (data: NotificationPayload["data"]) =>
431
+ act(() => {
432
+ options.onMessage(
433
+ new MessageEvent("message", {
434
+ data: JSON.stringify({ op: data.op, data }),
435
+ }),
436
+ );
437
+ });
438
+ return { store, transport, options, receive };
439
+ }
440
+
441
+ it.each([
442
+ "MARIMO_NO_FILE_KEY",
443
+ "MARIMO_NO_SESSION_ID",
444
+ "MARIMO_NO_SESSION",
445
+ "MARIMO_SHUTDOWN",
446
+ "MARIMO_KERNEL_STARTUP_ERROR",
447
+ "MARIMO_UNAUTHORIZED",
448
+ "MARIMO_KIOSK_NOT_ALLOWED",
449
+ "MARIMO_TRANSPORT_EXHAUSTED",
450
+ ])("clears cached attachments on %s", (reason) => {
451
+ const { store, transport, options } = connectedSession();
452
+ act(() => options.onClose(new CloseEvent("close", { reason })));
453
+ expect(store.get(attachmentsAtom)).toEqual([]);
454
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
455
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.CLOSED);
456
+ expect(transport.reconnect).not.toHaveBeenCalled();
457
+ });
458
+
459
+ it("clears the snapshot on transport error", () => {
460
+ const { store, options } = connectedSession();
461
+ act(() => options.onError(new Event("error")));
462
+ expect(store.get(attachmentsAtom)).toEqual([]);
463
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
464
+ });
465
+
466
+ it("waits for a fresh snapshot after a transient reconnect", async () => {
467
+ const { store, transport, options, receive } = connectedSession();
468
+ act(() => options.onClose(new CloseEvent("close")));
469
+ expect(store.get(attachmentsAtom)).toEqual([]);
470
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.CONNECTING);
471
+ expect(transport.reconnect).toHaveBeenCalledOnce();
472
+
473
+ await act(async () => options.onOpen(new Event("open")));
474
+ receive({ op: "reconnected" });
475
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.OPEN);
476
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
477
+
478
+ const refreshed = { ...agent, id: "a2", name: "Codex" };
479
+ receive({ op: "attachments", attachments: [refreshed] });
480
+ expect(store.get(agentAttachmentsAtom)).toEqual([refreshed]);
481
+ receive({ op: "attachments", attachments: [] });
482
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
483
+ });
484
+
485
+ it("keeps a fresh snapshot hidden until the session is ready", async () => {
486
+ const { store, options, receive } = connectedSession();
487
+ act(() => options.onClose(new CloseEvent("close")));
488
+ await act(async () => options.onOpen(new Event("open")));
489
+ receive({ op: "attachments", attachments: [agent] });
490
+ expect(store.get(attachmentsAtom)).toEqual([agent]);
491
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
492
+ receive({ op: "reconnected" });
493
+ expect(store.get(agentAttachmentsAtom)).toEqual([agent]);
494
+ });
495
+
496
+ it("invalidates an old snapshot when a new transport opens", async () => {
497
+ const { store, options } = connectedSession();
498
+ await act(async () => options.onOpen(new Event("open")));
499
+ expect(store.get(attachmentsAtom)).toEqual([]);
500
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
501
+ });
502
+ });
503
+
504
+ it.each([true, false])(
505
+ "keeps attachments only when kernel-ready resumes the session: %s",
506
+ (resumed) => {
507
+ const store = createStore();
508
+ const attachments: Attachment[] = [
509
+ {
510
+ id: "a1",
511
+ kind: "agent",
512
+ name: "Pi",
513
+ since: "2026-10-14T00:00:00Z",
514
+ notebook_id: "nb-test",
515
+ identity: "pi-thread",
516
+ },
517
+ ];
518
+ store.set(attachmentsAtom, attachments);
519
+ vi.mocked(createConnectionTransport).mockReturnValue(
520
+ makeTransport(WebSocket.OPEN),
521
+ );
522
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
523
+ renderConnectionHook(store);
524
+ const options = getTransportHandlers();
525
+ act(() =>
526
+ options.onMessage(
527
+ new MessageEvent("message", {
528
+ data: JSON.stringify({
529
+ op: "kernel-ready",
530
+ data: {
531
+ op: "kernel-ready",
532
+ cell_ids: [],
533
+ codes: [],
534
+ names: [],
535
+ configs: [],
536
+ layout: null,
537
+ resumed,
538
+ ui_values: {},
539
+ last_executed_code: {},
540
+ last_execution_time: {},
541
+ app_config: { width: "normal" },
542
+ kiosk: false,
543
+ capabilities: { terminal: false },
544
+ auto_instantiated: true,
545
+ consumer_capabilities: { edit: true, interact: true },
546
+ },
547
+ }),
548
+ }),
549
+ ),
550
+ );
551
+ expect(store.get(attachmentsAtom)).toEqual(resumed ? attachments : []);
552
+ },
553
+ );
554
+
264
555
  describe("connection notice", () => {
265
556
  beforeEach(() => vi.useFakeTimers());
266
557
  afterEach(() => vi.useRealTimers());
@@ -269,15 +560,13 @@ describe("connection notice", () => {
269
560
  const store = createStore();
270
561
  store.set(connectionAtom, { state: ConnectionState.CONNECTING });
271
562
  const transport = makeTransport(WebSocket.OPEN);
272
- vi.mocked(useConnectionTransport).mockReturnValue(transport);
273
- vi.mocked(useRuntimeManager).mockReturnValue(
274
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
275
- );
563
+ vi.mocked(createConnectionTransport).mockReturnValue(transport);
564
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
276
565
  const Connection = () => {
277
- const { reconnect } = useMarimoKernelConnection({
278
- sessionId: "test-session" as SessionId,
566
+ const { reconnect } = useNotebookSession({
567
+ sessionId: SESSION_ID,
279
568
  autoInstantiate: false,
280
- setCells: () => {},
569
+ setCells: vi.fn(),
281
570
  });
282
571
  return (
283
572
  <ConnectionNotice
@@ -293,7 +582,7 @@ describe("connection notice", () => {
293
582
  </ErrorBoundary>
294
583
  </JotaiProvider>,
295
584
  );
296
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
585
+ const options = getTransportHandlers();
297
586
  const send = (data: NotificationPayload["data"]) =>
298
587
  act(() => {
299
588
  options.onMessage(
@@ -482,15 +771,13 @@ describe("connection notice", () => {
482
771
 
483
772
  it("replaces environment state from snapshots, then continues live progress", () => {
484
773
  const store = createStore();
485
- vi.mocked(useConnectionTransport).mockClear();
486
- vi.mocked(useConnectionTransport).mockReturnValue(
774
+ vi.mocked(createConnectionTransport).mockClear();
775
+ vi.mocked(createConnectionTransport).mockReturnValue(
487
776
  makeTransport(WebSocket.OPEN),
488
777
  );
489
- vi.mocked(useRuntimeManager).mockReturnValue(
490
- makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
491
- );
778
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
492
779
  renderConnectionHook(store);
493
- const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)?.[0];
780
+ const options = getTransportHandlers();
494
781
  function receive(data: NotificationPayload["data"]) {
495
782
  act(() =>
496
783
  options?.onMessage(