@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,101 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "../protocol";
4
+ import type { Resource } from "./resource";
5
+
6
+ /**
7
+ * The notebook's content at one revision.
8
+ *
9
+ * The revision record carries the cell order, app config, etags, and
10
+ * version. Each cell in its order has a record in `cells`.
11
+ */
12
+ export interface Document {
13
+ readonly revision: Protocol.Document;
14
+ readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
15
+ }
16
+
17
+ /** The records of a revision still being received. */
18
+ interface PendingDocument {
19
+ /** Null until the `document` event that closes the group arrives. */
20
+ readonly revision: Protocol.Document | null;
21
+ readonly cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>;
22
+ }
23
+
24
+ /**
25
+ * The confirmed document and the revision being assembled after it.
26
+ *
27
+ * A snapshot's records are pending until `ready`. A live revision is pending
28
+ * from its first cell until its `document` event.
29
+ */
30
+ export interface DocumentState {
31
+ readonly current: Document | null;
32
+ readonly pending: PendingDocument | null;
33
+ }
34
+
35
+ function pendingOf(state: DocumentState): PendingDocument {
36
+ return (
37
+ state.pending ?? {
38
+ revision: state.current?.revision ?? null,
39
+ cells: state.current?.cells ?? new Map(),
40
+ }
41
+ );
42
+ }
43
+
44
+ /** Confirm a revision whose records are complete. */
45
+ function publish(
46
+ revision: Protocol.Document,
47
+ cells: ReadonlyMap<Protocol.CellId, Protocol.Cell>,
48
+ ): DocumentState {
49
+ const order = new Set(revision.order);
50
+ if (
51
+ order.size !== revision.order.length ||
52
+ order.size !== cells.size ||
53
+ [...order].some((id) => !cells.has(id)) ||
54
+ [...cells.values()].some((cell) => cell.version > revision.version)
55
+ ) {
56
+ throw new Error("Document records do not match its revision and order");
57
+ }
58
+ return { current: { revision, cells }, pending: null };
59
+ }
60
+
61
+ export const document: Resource<DocumentState> = {
62
+ initial: { current: null, pending: null },
63
+ on: {
64
+ cell: (state, { data }) => {
65
+ const pending = pendingOf(state);
66
+ return {
67
+ ...state,
68
+ pending: {
69
+ revision: pending.revision,
70
+ cells: new Map(pending.cells).set(data.cell.id, data.cell),
71
+ },
72
+ };
73
+ },
74
+ "cell.removed": (state, { data }) => {
75
+ const pending = pendingOf(state);
76
+ if (!pending.cells.has(data.id)) {
77
+ return state;
78
+ }
79
+ const cells = new Map(pending.cells);
80
+ cells.delete(data.id);
81
+ return { ...state, pending: { ...pending, cells } };
82
+ },
83
+ document: (state, { data }, { phase }) => {
84
+ const { cells } = pendingOf(state);
85
+ return phase === "snapshot"
86
+ ? { ...state, pending: { revision: data.document, cells } }
87
+ : publish(data.document, cells);
88
+ },
89
+ ready: (state, _event, { phase }) => {
90
+ if (state.pending === null) {
91
+ return state;
92
+ }
93
+ if (phase === "live") {
94
+ throw new Error("Stream ended with an incomplete document update");
95
+ }
96
+ return state.pending.revision === null
97
+ ? { current: null, pending: null }
98
+ : publish(state.pending.revision, state.pending.cells);
99
+ },
100
+ },
101
+ };
@@ -0,0 +1,59 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "../protocol";
4
+
5
+ /** An event name from the protocol. */
6
+ export type EventName = Protocol.EventOutput["event"];
7
+
8
+ /** The decoded event with the given name. */
9
+ export type EventOf<Name extends EventName> = Extract<
10
+ Protocol.EventOutput,
11
+ { event: Name }
12
+ >;
13
+
14
+ /**
15
+ * Events the stream handles itself.
16
+ *
17
+ * They identify the notebook, discard state, or carry a native
18
+ * notification. Resources do not receive them.
19
+ */
20
+ export type StreamEventName =
21
+ | "reset"
22
+ | "notebook"
23
+ | "notebook.removed"
24
+ | "marimo";
25
+
26
+ /** Events a resource may handle. `ready` marks the end of the snapshot. */
27
+ export type ResourceEventName = Exclude<EventName, StreamEventName>;
28
+
29
+ /** `snapshot` until `ready` arrives and `live` after it. */
30
+ export type StreamPhase = "snapshot" | "live";
31
+
32
+ /** What a handler may know about the stream beyond the event. */
33
+ export interface HandlerContext {
34
+ readonly phase: StreamPhase;
35
+ }
36
+
37
+ /**
38
+ * A pure handler for each event the resource reads.
39
+ *
40
+ * A handler returns `state` when the event does not apply to it.
41
+ */
42
+ export type EventHandlers<State> = {
43
+ readonly [Name in ResourceEventName]?: (
44
+ state: State,
45
+ event: EventOf<Name>,
46
+ context: HandlerContext,
47
+ ) => State;
48
+ };
49
+
50
+ /**
51
+ * One kind of object assembled from its events.
52
+ *
53
+ * The stream returns a resource to `initial` on `reset` and when the
54
+ * notebook is removed.
55
+ */
56
+ export interface Resource<State> {
57
+ readonly initial: State;
58
+ readonly on: EventHandlers<State>;
59
+ }
@@ -0,0 +1,213 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { NotebookEvent } from "./events";
4
+ import type * as Protocol from "./protocol";
5
+ import type {
6
+ EventOf,
7
+ HandlerContext,
8
+ Resource,
9
+ ResourceEventName,
10
+ StreamPhase,
11
+ } from "./resources/resource";
12
+
13
+ /** A resource for each entry of a state map under the same name. */
14
+ export type Resources<States> = {
15
+ readonly [Name in keyof States]: Resource<States[Name]>;
16
+ };
17
+
18
+ /**
19
+ * The state of one notebook stream.
20
+ *
21
+ * Holds the phase, the notebook identity, the resume cursor, and the state
22
+ * of each resource.
23
+ */
24
+ export interface StreamState<States> {
25
+ readonly phase: StreamPhase;
26
+ /**
27
+ * The notebook the stream describes.
28
+ *
29
+ * Retained across `reset`.
30
+ */
31
+ readonly notebookId: Protocol.NotebookIdOutput | null;
32
+ /**
33
+ * The id of the last live event.
34
+ *
35
+ * Null before `ready` and after `reset`.
36
+ */
37
+ readonly cursor: string | null;
38
+ /** The state of each resource by name. */
39
+ readonly resources: States;
40
+ }
41
+
42
+ /** A reducer over the events of one notebook stream. */
43
+ export interface Stream<States> {
44
+ readonly initial: StreamState<States>;
45
+ /**
46
+ * Apply one event.
47
+ *
48
+ * Throws on a live event without a cursor or on an object from another
49
+ * notebook.
50
+ */
51
+ reduce(state: StreamState<States>, event: NotebookEvent): StreamState<States>;
52
+ /**
53
+ * The state to hold while reconnecting.
54
+ *
55
+ * A live stream keeps its state and resumes from `cursor`. An incomplete
56
+ * snapshot starts over.
57
+ */
58
+ resume(state: StreamState<States>): StreamState<States>;
59
+ }
60
+
61
+ /** Events that may arrive without a cursor after `ready`. */
62
+ const UNCURSORED = new Set<NotebookEvent["event"]>([
63
+ "reset",
64
+ "marimo",
65
+ "unknown",
66
+ ]);
67
+
68
+ /**
69
+ * The notebook the event's object belongs to.
70
+ *
71
+ * Undefined for objects that do not name one.
72
+ */
73
+ function notebookIdOf(
74
+ event: EventOf<ResourceEventName>,
75
+ ): Protocol.NotebookIdOutput | undefined {
76
+ switch (event.event) {
77
+ case "document":
78
+ return event.data.document.notebook_id;
79
+ case "cell":
80
+ return event.data.cell.notebook_id;
81
+ case "analysis":
82
+ return event.data.analysis.notebook_id;
83
+ case "runtime":
84
+ return event.data.runtime.notebook_id;
85
+ case "attachment":
86
+ return event.data.attachment.notebook_id;
87
+ case "run":
88
+ return event.data.run.notebook_id;
89
+ default:
90
+ return undefined;
91
+ }
92
+ }
93
+
94
+ /** Compose resources into one stream. */
95
+ export function createStream<States extends object>(
96
+ resources: Resources<States>,
97
+ ): Stream<States> {
98
+ const names = Object.keys(resources) as (keyof States & string)[];
99
+ const initialResources = Object.fromEntries(
100
+ names.map((name) => [name, resources[name].initial]),
101
+ ) as States;
102
+ const initial: StreamState<States> = {
103
+ phase: "snapshot",
104
+ notebookId: null,
105
+ cursor: null,
106
+ resources: initialResources,
107
+ };
108
+
109
+ function identify(
110
+ state: StreamState<States>,
111
+ notebookId: Protocol.NotebookIdOutput,
112
+ ): StreamState<States> {
113
+ if (state.notebookId === notebookId) {
114
+ return state;
115
+ }
116
+ if (state.notebookId !== null) {
117
+ throw new Error("Notebook stream changed identity");
118
+ }
119
+ return { ...state, notebookId };
120
+ }
121
+
122
+ function clear(state: StreamState<States>): StreamState<States> {
123
+ return state.resources === initialResources
124
+ ? state
125
+ : { ...state, resources: initialResources };
126
+ }
127
+
128
+ function fanOut(
129
+ state: StreamState<States>,
130
+ event: EventOf<ResourceEventName>,
131
+ ): StreamState<States> {
132
+ const context: HandlerContext = { phase: state.phase };
133
+ let next = state.resources;
134
+ for (const name of names) {
135
+ type State = States[typeof name];
136
+ // Handlers are keyed by the event name they accept.
137
+ const handler = resources[name].on[event.event] as
138
+ | ((
139
+ state: State,
140
+ event: Protocol.EventOutput,
141
+ context: HandlerContext,
142
+ ) => State)
143
+ | undefined;
144
+ if (handler === undefined) {
145
+ continue;
146
+ }
147
+ const before = next[name];
148
+ const after = handler(before, event, context);
149
+ if (after !== before) {
150
+ next = { ...next, [name]: after };
151
+ }
152
+ }
153
+ return next === state.resources ? state : { ...state, resources: next };
154
+ }
155
+
156
+ /** Record the position of a live event. */
157
+ function advance(
158
+ state: StreamState<States>,
159
+ id: string | undefined,
160
+ ): StreamState<States> {
161
+ return state.phase === "live" && id !== undefined && id !== state.cursor
162
+ ? { ...state, cursor: id }
163
+ : state;
164
+ }
165
+
166
+ return {
167
+ initial,
168
+ reduce(state, event) {
169
+ if (
170
+ state.phase === "live" &&
171
+ event.id === undefined &&
172
+ !UNCURSORED.has(event.event)
173
+ ) {
174
+ throw new Error("Live notebook event is missing its cursor");
175
+ }
176
+ switch (event.event) {
177
+ case "marimo":
178
+ case "unknown":
179
+ return advance(state, event.id);
180
+ case "reset":
181
+ return { ...initial, notebookId: state.notebookId };
182
+ case "ready":
183
+ // Handlers see the phase that ended, so a snapshot's ready differs
184
+ // from one that ends a replay.
185
+ return { ...fanOut(state, event), phase: "live", cursor: event.id };
186
+ case "notebook":
187
+ return advance(identify(state, event.data.notebook.id), event.id);
188
+ case "notebook.removed":
189
+ return advance(
190
+ state.notebookId !== null && event.data.id !== state.notebookId
191
+ ? state
192
+ : clear(state),
193
+ event.id,
194
+ );
195
+ default: {
196
+ const notebookId = notebookIdOf(event);
197
+ return advance(
198
+ fanOut(
199
+ notebookId === undefined ? state : identify(state, notebookId),
200
+ event,
201
+ ),
202
+ event.id,
203
+ );
204
+ }
205
+ }
206
+ },
207
+ resume(state) {
208
+ return state.phase === "snapshot"
209
+ ? { ...initial, notebookId: state.notebookId }
210
+ : state;
211
+ },
212
+ };
213
+ }
@@ -3,6 +3,8 @@
3
3
  import { createStore } from "jotai";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
5
  import { RuntimeState } from "@/core/kernel/RuntimeState";
6
+ import { attachmentsAtom } from "@/core/attachments/state";
7
+ import { Logger } from "@/utils/Logger";
6
8
  import { initializeIslands } from "../bootstrap";
7
9
  import type { IslandsPyodideBridge } from "../bridge";
8
10
  import { islandsHydratedAtom, islandsPendingInitialRunsAtom } from "../state";
@@ -48,6 +50,30 @@ describe("initializeIslands", () => {
48
50
  expect(store.get(islandsHydratedAtom)).toBe(false);
49
51
  store.set(islandsPendingInitialRunsAtom, new Set([1, 2]));
50
52
 
53
+ const errors = vi.spyOn(Logger, "error");
54
+ consumeMessage?.({
55
+ sessionGeneration: 1,
56
+ message: JSON.stringify({
57
+ op: "attachments",
58
+ data: {
59
+ op: "attachments",
60
+ attachments: [
61
+ {
62
+ id: "a1",
63
+ kind: "agent",
64
+ name: "Pi",
65
+ since: "2026-10-14T00:00:00Z",
66
+ notebook_id: "nb-test",
67
+ identity: "pi-thread",
68
+ },
69
+ ],
70
+ },
71
+ }) as IslandsKernelMessage["message"],
72
+ });
73
+ expect(errors).not.toHaveBeenCalled();
74
+ expect(store.get(attachmentsAtom)).toEqual([]);
75
+ expect(store.get(islandsPendingInitialRunsAtom)).toEqual(new Set([1, 2]));
76
+
51
77
  consumeMessage?.(completedRun(1));
52
78
  expect(store.get(islandsHydratedAtom)).toBe(false);
53
79
 
@@ -209,6 +209,7 @@ function handleMessage(
209
209
  case "cache-info":
210
210
  case "kernel-startup-error":
211
211
  case "notebook-document-transaction":
212
+ case "attachments":
212
213
  return;
213
214
 
214
215
  case "completed-run":
@@ -7,8 +7,10 @@ import { waitFor } from "../state/jotai";
7
7
  import { type ConnectionStatus, ConnectionState } from "../websocket/types";
8
8
 
9
9
  /**
10
- * Atom for storing the connection status.
11
- * Initialized to NOT_STARTED.
10
+ * Shared application state, not the actual event-stream status.
11
+ *
12
+ * TODO(MO-8051) Expose read-only connection state and derive UI availability
13
+ * separately.
12
14
  */
13
15
  export const connectionAtom = atom<ConnectionStatus>({
14
16
  state: ConnectionState.NOT_STARTED,
@@ -22,7 +22,7 @@ import { getSessionId } from "./kernel/session";
22
22
  import { connectionAtom } from "./network/connection";
23
23
  import { useRequestClient } from "./network/requests";
24
24
  import { isAppConnecting } from "./websocket/connection-utils";
25
- import { useMarimoKernelConnection } from "./websocket/useMarimoKernelConnection";
25
+ import { useNotebookSession } from "./websocket/useNotebookSession";
26
26
 
27
27
  interface AppProps {
28
28
  appConfig: AppConfig;
@@ -51,7 +51,7 @@ export const RunApp: React.FC<AppProps> = ({ appConfig, hideHeader }) => {
51
51
  };
52
52
  }, []);
53
53
 
54
- const { connection, reconnect } = useMarimoKernelConnection({
54
+ const { connection, reconnect } = useNotebookSession({
55
55
  autoInstantiate: true,
56
56
  setCells: setCells,
57
57
  sessionId: getSessionId(),
@@ -2,14 +2,19 @@
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { Mocks } from "@/__mocks__/common";
5
- import type { SessionId } from "@/core/kernel/session";
5
+ import {
6
+ generateSessionId,
7
+ isSessionId,
8
+ type SessionId,
9
+ } from "@/core/kernel/session";
6
10
  import { Logger } from "@/utils/Logger";
7
11
  import { RuntimeManager } from "../runtime";
8
12
  import type { RuntimeConfig } from "../types";
9
13
 
10
14
  // Mock the session module
11
- vi.mock("@/core/kernel/session", () => ({
12
- getSessionId: () => "test-session-id" as SessionId,
15
+ vi.mock("@/core/kernel/session", async (importOriginal) => ({
16
+ ...(await importOriginal<typeof import("@/core/kernel/session")>()),
17
+ getSessionId: () => "test-session-id",
13
18
  }));
14
19
 
15
20
  vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
@@ -51,6 +56,38 @@ describe("RuntimeManager", () => {
51
56
  });
52
57
 
53
58
  describe("getWsURL", () => {
59
+ it("uses host notebook events with the existing session and auth parameters", () => {
60
+ const original = window.location.href;
61
+ window.history.replaceState({}, "", "/?types=run");
62
+ try {
63
+ const runtime = new RuntimeManager({
64
+ ...mockConfig,
65
+ url: "https://example.com/nested?file=notebook.py",
66
+ notebookId: "nb_123",
67
+ });
68
+ const sessionId = "s_123456";
69
+ if (!isSessionId(sessionId)) {
70
+ throw new Error("Invalid test session ID");
71
+ }
72
+ const url = runtime.getWsURL(sessionId);
73
+ expect({
74
+ endpoint: `${url.origin}${url.pathname}`,
75
+ params: Object.fromEntries(url.searchParams),
76
+ }).toEqual({
77
+ endpoint:
78
+ "wss://example.com/nested/api/marimo/v1/notebooks/nb_123/events/ws",
79
+ params: {
80
+ file: "notebook.py",
81
+ types: "marimo",
82
+ session_id: sessionId,
83
+ access_token: "test-token",
84
+ },
85
+ });
86
+ } finally {
87
+ window.history.replaceState({}, "", original);
88
+ }
89
+ });
90
+
54
91
  it("should return WebSocket URL with session ID for https", () => {
55
92
  const runtime = new RuntimeManager(mockConfig);
56
93
  const sessionId = "1234" as SessionId;
@@ -59,6 +96,7 @@ describe("RuntimeManager", () => {
59
96
  expect(url.protocol).toBe("wss:");
60
97
  expect(url.hostname).toBe("example.com");
61
98
  expect(url.pathname).toBe("/ws");
99
+ expect(url.searchParams.has("types")).toBe(false);
62
100
  expect(url.searchParams.get("session_id")).toBe(sessionId);
63
101
  });
64
102
 
@@ -219,14 +257,20 @@ describe("RuntimeManager", () => {
219
257
  });
220
258
 
221
259
  describe("getSseURL", () => {
222
- it("should return an http(s) URL with the session ID", () => {
223
- const runtime = new RuntimeManager(mockConfig);
224
- const url = runtime.getSseURL("1234" as SessionId);
225
-
226
- expect(url.protocol).toBe("https:");
227
- expect(url.pathname).toBe("/sse");
228
- expect(url.searchParams.get("session_id")).toBe("1234");
229
- });
260
+ it.each([undefined, "nb_123"])(
261
+ "uses the legacy session stream when notebookId is %s",
262
+ (notebookId) => {
263
+ const runtime = new RuntimeManager({
264
+ ...mockConfig,
265
+ url: "https://example.com/nested?file=notebook.py",
266
+ notebookId,
267
+ });
268
+ const sessionId = generateSessionId();
269
+ expect(runtime.getSseURL(sessionId).toString()).toBe(
270
+ `https://example.com/nested/sse?file=notebook.py&session_id=${sessionId}`,
271
+ );
272
+ },
273
+ );
230
274
 
231
275
  it("should never put the auth token in the URL, even cross-origin", () => {
232
276
  // SSE requests can send headers, so auth travels in the
@@ -9,6 +9,8 @@ import { isStaticNotebook } from "../static/static-state";
9
9
  import { isWasm } from "../wasm/utils";
10
10
  import type { RuntimeConfig } from "./types";
11
11
 
12
+ const HOST_EVENT_TYPES = ["marimo"];
13
+
12
14
  export class RuntimeManager {
13
15
  private initialHealthyCheck = new Deferred<void>();
14
16
  private config: RuntimeConfig;
@@ -154,7 +156,18 @@ export class RuntimeManager {
154
156
  * The WebSocket URL of the runtime.
155
157
  */
156
158
  public getWsURL(sessionId: SessionId): URL {
157
- return this.formatWsURL("/ws", this.getSessionSearchParams(sessionId));
159
+ const searchParams = this.getSessionSearchParams(sessionId);
160
+ // TODO(MO-8039): Rediscover hosts and resolve notebooks on reconnect.
161
+ const notebookId = this.config.notebookId;
162
+ const path = notebookId
163
+ ? `/api/marimo/v1/notebooks/${encodeURIComponent(notebookId)}/events/ws`
164
+ : "/ws";
165
+ const url = this.formatWsURL(path, searchParams);
166
+ if (notebookId) {
167
+ // TODO(MO-8034): Separate host options from notebook query params.
168
+ url.searchParams.set("types", HOST_EVENT_TYPES.join(","));
169
+ }
170
+ return url;
158
171
  }
159
172
 
160
173
  /**
@@ -1,6 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  export interface RuntimeConfig {
4
+ /** The host notebook for WebSocket events. SSE uses the legacy session stream. */
5
+ readonly notebookId?: string;
4
6
  /**
5
7
  * The URL of the runtime server.
6
8
  */
@@ -3,7 +3,7 @@
3
3
  import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { Logger } from "@/utils/Logger";
5
5
  import { WebSocketClosedReason, ConnectionState } from "../types";
6
- import { classifyCloseEvent } from "../useMarimoKernelConnection";
6
+ import { classifyCloseEvent } from "../connection-state";
7
7
 
8
8
  function classify(reason: string | undefined) {
9
9
  return classifyCloseEvent({ reason });
@@ -13,8 +13,10 @@ describe("classifyCloseEvent", () => {
13
13
  describe("transient closes (default branch)", () => {
14
14
  it("retries on empty/undefined reason", () => {
15
15
  const decision = classify(undefined);
16
- expect(decision.kind).toBe("retry");
17
- expect(decision.status).toEqual({ state: ConnectionState.CONNECTING });
16
+ expect(decision).toEqual({
17
+ kind: "retry",
18
+ status: { state: ConnectionState.CONNECTING },
19
+ });
18
20
  });
19
21
 
20
22
  it("treats unknown reason strings as transient and logs a warning", () => {
@@ -40,29 +42,25 @@ describe("classifyCloseEvent", () => {
40
42
  // terminal or a rejected client would reconnect forever.
41
43
  "MARIMO_UNAUTHORIZED",
42
44
  "MARIMO_KIOSK_NOT_ALLOWED",
43
- ])("%s → terminal with KERNEL_DISCONNECTED, closes transport", (reason) => {
45
+ ])("%s → terminal with KERNEL_DISCONNECTED", (reason) => {
44
46
  const decision = classify(reason);
45
- expect(decision.kind).toBe("terminal");
46
- expect(decision.status).toMatchObject({
47
- state: ConnectionState.CLOSED,
48
- code: WebSocketClosedReason.KERNEL_DISCONNECTED,
49
- });
50
47
  expect(decision).toMatchObject({
51
48
  kind: "terminal",
52
- closeTransport: true,
49
+ status: {
50
+ state: ConnectionState.CLOSED,
51
+ code: WebSocketClosedReason.KERNEL_DISCONNECTED,
52
+ },
53
53
  });
54
54
  });
55
55
 
56
- it("MARIMO_KERNEL_STARTUP_ERROR → terminal + closeTransport", () => {
56
+ it("MARIMO_KERNEL_STARTUP_ERROR → terminal", () => {
57
57
  const decision = classify("MARIMO_KERNEL_STARTUP_ERROR");
58
- expect(decision.kind).toBe("terminal");
59
- expect(decision.status).toMatchObject({
60
- state: ConnectionState.CLOSED,
61
- code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
62
- });
63
58
  expect(decision).toMatchObject({
64
59
  kind: "terminal",
65
- closeTransport: true,
60
+ status: {
61
+ state: ConnectionState.CLOSED,
62
+ code: WebSocketClosedReason.KERNEL_STARTUP_ERROR,
63
+ },
66
64
  });
67
65
  });
68
66
  });
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
4
4
  import { BasicTransport } from "../transports/basic";
5
5
  import { SseTransport } from "../transports/sse";
6
6
  import { WsTransport } from "../transports/ws";
7
- import { createConnectionTransport } from "../useWebSocket";
7
+ import { createConnectionTransport } from "../transports/create";
8
8
 
9
9
  vi.mock("../../wasm/utils", () => ({
10
10
  isWasm: () => false,