@marimo-team/islands 0.25.3-dev5 → 0.25.3-dev51

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 (220) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CNMliVzw.js → ConnectedDataExplorerComponent-Da5OoEUL.js} +14 -14
  2. package/dist/{ErrorBoundary-BiZWOrv4.js → ErrorBoundary-CbxCC2kk.js} +8 -8
  3. package/dist/{abnfDiagram-VCTEODGH-D-Ez91gV.js → abnfDiagram-VCTEODGH-Bz3mrprX.js} +2 -2
  4. package/dist/any-language-editor-k2B8YI3Y.js +75 -0
  5. package/dist/{architectureDiagram-5GKGNRK7-CTpcNtO8.js → architectureDiagram-5GKGNRK7-Cs5ujb9K.js} +1 -1
  6. package/dist/{arrays-CN1djC4k.js → arrays-R6JsImiT.js} +8 -0
  7. package/dist/{button-CvjkwdSk.js → button-BxB3RDjN.js} +1 -1
  8. package/dist/{capabilities-CQ5IjhzL.js → capabilities-BgR0osfN.js} +2 -2
  9. package/dist/{chat-ui-0lKdOoQ3.js → chat-ui-C1Gjgokv.js} +1103 -1103
  10. package/dist/{check-EDwSwP89.js → check-P35dc2dH.js} +1 -1
  11. package/dist/{chunk-IMKFNOWR-DGxi-zeo.js → chunk-IMKFNOWR-CcTSaxZt.js} +1 -1
  12. package/dist/{chunk-SHT3W25Y-CV9J3rAB.js → chunk-SHT3W25Y-DcJcjOaO.js} +1 -1
  13. package/dist/{chunk-SVP7TREG-BL-9Fqgg.js → chunk-SVP7TREG-BVtDTjaF.js} +1 -1
  14. package/dist/{chunk-TICWLB2K-Cu_XMjDM.js → chunk-TICWLB2K-BkDFkh8q.js} +1 -1
  15. package/dist/{chunk-YOKDWASO-DPZk8vhS.js → chunk-YOKDWASO-DKplTrBY.js} +20 -20
  16. package/dist/{classDiagram-ZZMXUADV-BXXV13Hq.js → classDiagram-ZZMXUADV-CfAt7HT5.js} +2 -2
  17. package/dist/{classDiagram-v2-VYDZK3BY-B7FXJ1Cp.js → classDiagram-v2-VYDZK3BY-CGEjNYa2.js} +2 -2
  18. package/dist/{code-visibility-CW5_8ZOI.js → code-visibility-DoZ9lxzx.js} +6 -6
  19. package/dist/{common-mzWw4FdI.js → common-D1PacyXO.js} +3112 -3015
  20. package/dist/{copy-icon-Ft7t9bqz.js → copy-icon-CPoxUG7G.js} +3 -3
  21. package/dist/{cynefinDiagram-5FMLGOSQ-CH96rwba.js → cynefinDiagram-5FMLGOSQ-BW6NmfJi.js} +1 -1
  22. package/dist/{dagre-GXQ25YYZ-BD3PmpFY.js → dagre-GXQ25YYZ-CWg3CTuF.js} +1 -1
  23. package/dist/{data-grid-overlay-editor-CJmehSdc.js → data-grid-overlay-editor-B50UpkXd.js} +5 -5
  24. package/dist/{data-grid-types-BH_r7g7l.js → data-grid-types-0PJ-9N5n.js} +1 -1
  25. package/dist/{diagram-S7CK7UJ4-BsSJJldB.js → diagram-S7CK7UJ4-BPxtoS-z.js} +1 -1
  26. package/dist/{diagram-UQ7AKVKN-muQtNZc4.js → diagram-UQ7AKVKN-DA-vJdFL.js} +1 -1
  27. package/dist/{diagram-VX7I27RA-CdhuZAqR.js → diagram-VX7I27RA-D6bkR84S.js} +1 -1
  28. package/dist/{diagram-Z3DM3KII-BD8mXjOj.js → diagram-Z3DM3KII-m60g4cAT.js} +1 -1
  29. package/dist/{dialog-CEBFZNyc.js → dialog-C1PcpCqX.js} +3 -3
  30. package/dist/{dist-DpBpA_n1.js → dist-Lm-D-Vr6.js} +3 -3
  31. package/dist/{dropdown-menu-CP_qCYHC.js → dropdown-menu-MG5hiCIa.js} +76 -76
  32. package/dist/{ebnfDiagram-PWID7BFC-BIN1sweE.js → ebnfDiagram-PWID7BFC-CK99sho2.js} +2 -2
  33. package/dist/{erDiagram-RLTQ6QDP-Ctp5cfoN.js → erDiagram-RLTQ6QDP-Bryq0tdE.js} +1 -1
  34. package/dist/{error-banner-D-WCIiMt.js → error-banner-Sl2FlSJ4.js} +2 -2
  35. package/dist/{error-entries-DSvFWT_M.js → error-entries-CMNtA8NK.js} +959 -959
  36. package/dist/{feedback-button-BcT1FBud.js → feedback-button-DqPqwryB.js} +29 -29
  37. package/dist/{flowDiagram-HODETNUW-3EfLrlFb.js → flowDiagram-HODETNUW-D-3aRpfS.js} +2 -2
  38. package/dist/{formats-CeOmU1MU.js → formats-6pLWWrY2.js} +3 -3
  39. package/dist/{ganttDiagram-EL5Y4UJY-BQT16JSc.js → ganttDiagram-EL5Y4UJY-Dh_4cBJZ.js} +2 -2
  40. package/dist/{glide-data-editor-XckatD0j.js → glide-data-editor-BxMhUd3K.js} +370 -368
  41. package/dist/{glide-utils-DJyqh0EM.js → glide-utils-Bt8_pBa2.js} +16 -16
  42. package/dist/{globals-jRrLCkrP.js → globals-DTJa_doJ.js} +3 -3
  43. package/dist/{highlighted-body-KPVGNVTW-zwjq21tf.js → highlighted-body-KPVGNVTW-3Ziwfz-V.js} +2 -2
  44. package/dist/{html-to-image-B8s5SgGm.js → html-to-image-By72ALyI.js} +29 -29
  45. package/dist/{infoDiagram-27XIBGKW-DipQoHxl.js → infoDiagram-27XIBGKW-s1C1kgfU.js} +1 -1
  46. package/dist/{ishikawaDiagram-5VMMS53U-DgFMjSGz.js → ishikawaDiagram-5VMMS53U-CCsRgolY.js} +1 -1
  47. package/dist/{kanban-definition-UXKFOSKX-w1ACVjgl.js → kanban-definition-UXKFOSKX-BrmxDlcc.js} +1 -1
  48. package/dist/{label-BDKF3Afc.js → label-BRnUC0NX.js} +2 -2
  49. package/dist/{linear-Cnaq3Tfb.js → linear-DWERh7eI.js} +121 -73
  50. package/dist/{loader-BQOfjTIJ.js → loader-Bm8cy3e8.js} +12 -12
  51. package/dist/main.js +2358 -2221
  52. package/dist/{mermaid-CHo49Yv-.js → mermaid-DKI0869I.js} +38 -38
  53. package/dist/mermaid-HWGCJPDP-Du3-HPQA.js +2 -0
  54. package/dist/{mindmap-definition-YA3MSWOX-CrT0KSfX.js → mindmap-definition-YA3MSWOX-641xJysF.js} +1 -1
  55. package/dist/{number-overlay-editor-pZx8dUDV.js → number-overlay-editor-Z_H6x5b-.js} +3 -3
  56. package/dist/{objects-CzUgoMIF.js → objects-DCmMvZtn.js} +1 -3
  57. package/dist/{once-BtE-wKRL.js → once-eG8Vmvjd.js} +1 -1
  58. package/dist/{pegDiagram-XKGWAZYB-BqvywNJA.js → pegDiagram-XKGWAZYB-keVUAUeS.js} +2 -2
  59. package/dist/{pieDiagram-E7YTZNPT-BfxJ_XCc.js → pieDiagram-E7YTZNPT-DHktXPO_.js} +1 -1
  60. package/dist/precisionRound-DhAzeRcm.js +49 -0
  61. package/dist/{process-output-CKRXyszb.js → process-output-D-OMW5sV.js} +4 -4
  62. package/dist/{quadrantDiagram-AXDQQJYC-Ccw0xMjN.js → quadrantDiagram-AXDQQJYC-Blrm7V7_.js} +1 -1
  63. package/dist/{railroadDiagram-O6MQD6OU-8vFLVTPu.js → railroadDiagram-O6MQD6OU-BVEF4OV_.js} +2 -2
  64. package/dist/{react-vega-BED0DjiB.js → react-vega-C4T9Rb_w.js} +12284 -12270
  65. package/dist/{requirementDiagram-BXWQKSXE-a_MxGvxe.js → requirementDiagram-BXWQKSXE-CWo-UAKV.js} +1 -1
  66. package/dist/{reveal-component-79ez8-D5.js → reveal-component-tAB5K3QE.js} +10 -10
  67. package/dist/{select-BButFi-d.js → select-B0LRDH0g.js} +4 -4
  68. package/dist/{sequenceDiagram-WJ2MYXX4-CXFMnxGh.js → sequenceDiagram-WJ2MYXX4-CHQvLnq4.js} +1 -1
  69. package/dist/{spec-BwarBpUJ.js → spec-C-LRKGtT.js} +4 -4
  70. package/dist/{stateDiagram-D77RDMKH-DQeA_CwI.js → stateDiagram-D77RDMKH-BOJZgbUJ.js} +2 -2
  71. package/dist/{stateDiagram-v2-MP3YSRHH-BxxQucm1.js → stateDiagram-v2-MP3YSRHH-C53XXdYH.js} +2 -2
  72. package/dist/style.css +1 -1
  73. package/dist/{swimlanes-42K2YHIH-Do6L-cYg.js → swimlanes-42K2YHIH-D2o1gOtw.js} +1 -1
  74. package/dist/{swimlanesDiagram-VR7AAH4N-DUzPvSVU.js → swimlanesDiagram-VR7AAH4N-BHFzN1Wk.js} +2 -2
  75. package/dist/{swiper-component-DKntyn8p.js → swiper-component-DfhRvAFi.js} +2 -2
  76. package/dist/{time-BieKwdYS.js → time-BaMbJs4z.js} +18 -18
  77. package/dist/{timeline-definition-24CTP7MA-D3c46WM5.js → timeline-definition-24CTP7MA-rv7QUa46.js} +1 -1
  78. package/dist/{toDate-vIXljnsr.js → toDate-CjPuEPou.js} +21 -20
  79. package/dist/{toast-B9UMEi8u.js → toast-CFWpihJU.js} +4 -4
  80. package/dist/{tooltip-BO1REbqs.js → tooltip-CKO1pHJo.js} +2 -2
  81. package/dist/{use-restore-focus-BEM7sFWl.js → use-restore-focus-DBCITLeF.js} +1 -1
  82. package/dist/{useAsyncData-Deh5kszT.js → useAsyncData-ChgNtdmE.js} +15 -14
  83. package/dist/{useDateFormatter-D8XruNGG.js → useDateFormatter-c_yJtkAe.js} +2 -2
  84. package/dist/{useDeepCompareMemoize-DXwzRINI.js → useDeepCompareMemoize-Bet7kSsI.js} +1 -1
  85. package/dist/{useIframeCapabilities-DBETvvPl.js → useIframeCapabilities-BkdeVdXC.js} +1 -1
  86. package/dist/{useLifecycle-j7D9uD1o.js → useLifecycle-VVmz_24o.js} +3 -3
  87. package/dist/{useTheme-CRnjkku0.js → useTheme-DympCsQg.js} +5 -5
  88. package/dist/{vega-component-DTG6zxaY.js → vega-component-DnfQeoyt.js} +147 -147
  89. package/dist/{vega-embed-container-BNHE18G9.js → vega-embed-container--LYZqguO.js} +2 -2
  90. package/dist/{vega-loader.browser-DNST6uVf.js → vega-loader.browser-B9Pz0EGP.js} +298 -507
  91. package/dist/{vennDiagram-4TSXK5OY-BSZD8JcZ.js → vennDiagram-4TSXK5OY-D36Zk7t-.js} +1 -1
  92. package/dist/{wardleyDiagram-VM6X3IG4-48cUWkfA.js → wardleyDiagram-VM6X3IG4-BLBolvyQ.js} +1 -1
  93. package/dist/{xychartDiagram-S5SC5T6Z-CswYgYeu.js → xychartDiagram-S5SC5T6Z-CfH57l0v.js} +2 -2
  94. package/package.json +4 -4
  95. package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
  96. package/src/__tests__/host-imports.test.ts +1 -0
  97. package/src/__tests__/mount.test.ts +8 -8
  98. package/src/components/app-config/ai-config.tsx +5 -1
  99. package/src/components/chat/__tests__/message-queue.test.tsx +17 -0
  100. package/src/components/chat/chat-panel.tsx +3 -7
  101. package/src/components/chat/chat-utils.ts +4 -8
  102. package/src/components/data-table/__tests__/context-menu-interaction.test.tsx +130 -0
  103. package/src/components/data-table/__tests__/date-popover.test.tsx +72 -0
  104. package/src/components/data-table/__tests__/export-actions.test.tsx +417 -6
  105. package/src/components/data-table/column-visibility-dropdown.tsx +8 -13
  106. package/src/components/data-table/columns.tsx +2 -1
  107. package/src/components/data-table/context-menu.tsx +36 -20
  108. package/src/components/data-table/date-popover.tsx +6 -4
  109. package/src/components/data-table/export-actions.tsx +263 -161
  110. package/src/components/data-table/row-viewer-panel/row-viewer.tsx +2 -2
  111. package/src/components/data-table/schemas.ts +14 -36
  112. package/src/components/editor/Output.tsx +5 -1
  113. package/src/components/editor/__tests__/dynamic-favicon.test.tsx +111 -18
  114. package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
  115. package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
  116. package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
  117. package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
  118. package/src/components/editor/ai/add-cell-with-ai.tsx +3 -3
  119. package/src/components/editor/cell/CreateCellButton.tsx +8 -10
  120. package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +54 -0
  121. package/src/components/editor/cell/code/cell-editor.tsx +1 -1
  122. package/src/components/editor/cell/code/language-toggle.tsx +8 -7
  123. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +277 -78
  124. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +196 -41
  125. package/src/components/editor/connections/components.tsx +1 -1
  126. package/src/components/editor/controls/__tests__/command-palette.test.tsx +53 -0
  127. package/src/components/editor/controls/command-palette.tsx +22 -10
  128. package/src/components/editor/dynamic-favicon.tsx +21 -40
  129. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +139 -36
  130. package/src/components/editor/errors/send-error-report-button.tsx +10 -7
  131. package/src/components/editor/file-tree/file-viewer.tsx +4 -2
  132. package/src/components/editor/output/MarimoTracebackOutput.tsx +28 -22
  133. package/src/components/editor/output/console/ConsoleOutput.tsx +8 -18
  134. package/src/components/pages/home-page.tsx +4 -4
  135. package/src/components/slides/slide-cell-view.tsx +1 -1
  136. package/src/components/tracing/tracing.tsx +2 -2
  137. package/src/core/attachments/__tests__/toast.test.tsx +58 -0
  138. package/src/core/attachments/state.ts +5 -8
  139. package/src/core/attachments/toast.ts +15 -0
  140. package/src/core/cells/__tests__/error-report.test.ts +53 -3
  141. package/src/core/cells/__tests__/notebook-outline.test.ts +113 -0
  142. package/src/core/cells/__tests__/session.test.ts +18 -3
  143. package/src/core/cells/cells.ts +2 -1
  144. package/src/core/cells/error-report.ts +14 -6
  145. package/src/core/cells/pending-delete-service.ts +2 -6
  146. package/src/core/cells/session.ts +19 -1
  147. package/src/core/config/pair.ts +5 -3
  148. package/src/core/datasets/__tests__/data-source-discovery.test.ts +4 -4
  149. package/src/core/datasets/data-source-discovery.ts +4 -6
  150. package/src/core/edit-app.tsx +2 -2
  151. package/src/core/host/AGENTS.md +11 -2
  152. package/src/core/host/__tests__/attachments.test.ts +56 -0
  153. package/src/core/host/__tests__/document.test.ts +126 -0
  154. package/src/core/host/__tests__/events.test.ts +49 -0
  155. package/src/core/host/__tests__/fixtures.ts +41 -0
  156. package/src/core/host/__tests__/notebook-client.test.ts +496 -0
  157. package/src/core/host/connection.ts +81 -0
  158. package/src/core/host/events.ts +68 -0
  159. package/src/core/host/notebook-client.ts +316 -0
  160. package/src/core/host/resources/attachments.ts +19 -0
  161. package/src/core/host/resources/document.ts +101 -0
  162. package/src/core/hotkeys/actions.ts +2 -4
  163. package/src/core/islands/__tests__/bootstrap.test.ts +0 -26
  164. package/src/core/islands/bootstrap.ts +0 -1
  165. package/src/core/network/connection.ts +4 -2
  166. package/src/core/run-app.tsx +2 -2
  167. package/src/core/runtime/__tests__/runtime.test.ts +5 -12
  168. package/src/core/runtime/runtime.ts +5 -5
  169. package/src/core/static/files.ts +2 -0
  170. package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
  171. package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
  172. package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +149 -0
  173. package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +168 -253
  174. package/src/core/websocket/connection-state.ts +70 -0
  175. package/src/core/websocket/state.ts +7 -0
  176. package/src/core/websocket/transports/__tests__/sse.test.ts +18 -4
  177. package/src/core/websocket/transports/create.ts +31 -0
  178. package/src/core/websocket/transports/sse.ts +14 -2
  179. package/src/core/websocket/transports/transport.ts +12 -23
  180. package/src/core/websocket/types.ts +9 -39
  181. package/src/core/websocket/useNotebookClient.ts +78 -0
  182. package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +53 -233
  183. package/src/hooks/__tests__/useAsyncData.test.tsx +148 -0
  184. package/src/hooks/useAsyncData.ts +3 -3
  185. package/src/hooks/useDataSourceDiscovery.ts +6 -4
  186. package/src/hooks/useElapsedTime.ts +3 -3
  187. package/src/hooks/useInputHistory.ts +4 -7
  188. package/src/hooks/useResizeObserver.ts +4 -7
  189. package/src/mount.tsx +3 -3
  190. package/src/plugins/core/registerReactComponent.tsx +1 -1
  191. package/src/plugins/impl/DropdownPlugin.tsx +2 -6
  192. package/src/plugins/impl/FormPlugin.tsx +1 -1
  193. package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
  194. package/src/plugins/impl/anywidget/__tests__/registry.test.ts +2 -0
  195. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +241 -2
  196. package/src/plugins/impl/anywidget/runtime.ts +21 -9
  197. package/src/plugins/impl/anywidget/widget-binding.ts +160 -82
  198. package/src/plugins/impl/code/any-language-editor.tsx +2 -2
  199. package/src/plugins/impl/data-editor/glide-data-editor.tsx +8 -5
  200. package/src/plugins/impl/data-editor/glide-utils.ts +3 -1
  201. package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +5 -3
  202. package/src/plugins/impl/panel/PanelPlugin.tsx +15 -16
  203. package/src/plugins/impl/panel/__tests__/utils.test.ts +39 -1
  204. package/src/plugins/impl/panel/utils.ts +17 -0
  205. package/src/plugins/impl/vega/make-selectable.ts +3 -6
  206. package/src/stories/__tests__/editor-story.test.tsx +33 -0
  207. package/src/stories/editor.stories.tsx +24 -17
  208. package/src/utils/__tests__/arrays.test.ts +35 -0
  209. package/src/utils/__tests__/numbers.test.ts +48 -1
  210. package/src/utils/__tests__/objects.test.ts +21 -0
  211. package/src/utils/arrays.ts +10 -0
  212. package/src/utils/numbers.ts +28 -17
  213. package/src/utils/objects.ts +3 -7
  214. package/src/utils/once.ts +19 -19
  215. package/dist/any-language-editor-DZ_YTVus.js +0 -76
  216. package/dist/mermaid-HWGCJPDP-ByCsZnYF.js +0 -2
  217. package/dist/precisionRound-DMIVQrt2.js +0 -97
  218. package/src/core/host/__tests__/protocol.test.ts +0 -214
  219. package/src/core/host/protocol.ts +0 -1943
  220. package/src/core/websocket/useWebSocket.tsx +0 -88
@@ -0,0 +1,68 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { z } from "zod";
4
+ import * as Protocol from "@marimo-team/client/protocol";
5
+
6
+ /**
7
+ * Notebook stream events, including native marimo notifications.
8
+ *
9
+ * Known events follow the generated protocol. Unknown events retain their name
10
+ * and cursor so clients can skip them without losing their place in the stream.
11
+ */
12
+
13
+ const envelope = z.object({
14
+ event: z.string().min(1),
15
+ data: z.unknown(),
16
+ id: z.string().min(1).optional(),
17
+ });
18
+
19
+ const knownNames = new Set<string>(
20
+ Protocol.Event.options.flatMap((schema) => schema.shape.event.options),
21
+ );
22
+
23
+ /**
24
+ * A native marimo message carried by a host stream.
25
+ *
26
+ * The protocol leaves extension events to their producer. The payload is
27
+ * passed through for the session handler to interpret.
28
+ */
29
+ const MarimoEvent = z.object({
30
+ event: z.literal("marimo"),
31
+ id: z.string().min(1).optional(),
32
+ data: z.object({ marimo: z.unknown() }),
33
+ });
34
+
35
+ /** A decoded event, or the name and cursor of an unrecognized event. */
36
+ export type NotebookEvent =
37
+ | Protocol.EventOutput
38
+ | z.output<typeof MarimoEvent>
39
+ | {
40
+ readonly event: "unknown";
41
+ readonly name: string;
42
+ readonly id?: string;
43
+ };
44
+
45
+ const sessionEnvelope = z.object({ data: z.unknown() });
46
+
47
+ /**
48
+ * Decode a host event or an in-process WASM `{ data: notification }` message.
49
+ *
50
+ * Throws if the envelope or a known event is malformed. Native notification
51
+ * payloads are passed through for the session handler to interpret.
52
+ */
53
+ export function parseNotebookEvent(value: unknown): NotebookEvent {
54
+ if (typeof value === "object" && value !== null && !("event" in value)) {
55
+ return {
56
+ event: "marimo",
57
+ data: { marimo: sessionEnvelope.parse(value).data },
58
+ };
59
+ }
60
+ const parsed = envelope.parse(value);
61
+ if (parsed.event === "marimo") {
62
+ return MarimoEvent.parse(parsed);
63
+ }
64
+ if (knownNames.has(parsed.event)) {
65
+ return Protocol.Event.parse(parsed);
66
+ }
67
+ return { event: "unknown", name: parsed.event, id: parsed.id };
68
+ }
@@ -0,0 +1,316 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { Logger } from "@/utils/Logger";
4
+ import type { Attachment, NotebookOutput } from "@marimo-team/client/protocol";
5
+ import { createReducer } from "@marimo-team/client/reducer";
6
+ import { type Atom, atom, type createStore } from "jotai/vanilla";
7
+ import {
8
+ type ConnectionStatus,
9
+ ConnectionState,
10
+ ConnectionClosedReason,
11
+ type CloseDecision,
12
+ type NotebookTransport,
13
+ TransportReadyState,
14
+ } from "./connection";
15
+ import { type NotebookEvent, parseNotebookEvent } from "./events";
16
+ import { attachments } from "./resources/attachments";
17
+ import { type Document, document } from "./resources/document";
18
+
19
+ /** Events that may arrive without a cursor after `ready`. */
20
+ const UNCURSORED = new Set<NotebookEvent["event"]>([
21
+ "reset",
22
+ "marimo",
23
+ "unknown",
24
+ ]);
25
+
26
+ /** A connection to a notebook on a marimo host. */
27
+ export interface NotebookClient {
28
+ /** Client connection status, independent of application session readiness. */
29
+ readonly connectionAtom: Atom<ConnectionStatus>;
30
+ /** Identity and capabilities, available while the stream is connected and ready. */
31
+ readonly notebookAtom: Atom<NotebookOutput | null>;
32
+ /**
33
+ * The most recently confirmed document.
34
+ *
35
+ * Null until a snapshot is complete, after reset or removal, and on
36
+ * connections without host document events. A temporary disconnection
37
+ * retains the last document. Partial updates are not exposed.
38
+ */
39
+ readonly documentAtom: Atom<Document | null>;
40
+ /** Live attachments, empty until the stream is ready or while disconnected. */
41
+ readonly attachmentsAtom: Atom<readonly Attachment[]>;
42
+ /** Attempt to restore a closed connection after checking host health. */
43
+ reconnect(): Promise<void>;
44
+ }
45
+
46
+ /** The transport and application callbacks needed to connect a notebook. */
47
+ export interface NotebookConnection {
48
+ /** Create a transport that reads the URL again on each connection attempt. */
49
+ createTransport: (url: () => string) => NotebookTransport;
50
+ /** The current stream URL, including authentication and subscription options. */
51
+ url: () => string;
52
+ /** Abort to close the transport and clear the client's state. */
53
+ signal: AbortSignal;
54
+ /** Wait until the host can accept the initial connection. */
55
+ waitUntilReady: () => Promise<void>;
56
+ /** Check whether the host is reachable before an explicit reconnect. */
57
+ checkHealth: () => Promise<boolean>;
58
+ /** Decode wire text without interpreting its event type. */
59
+ decodeJson: (message: string) => unknown;
60
+ /** Handle a native marimo notification. Its payload has not been validated. */
61
+ onNotification: (notification: unknown) => void;
62
+ /** Choose the connection status and retry policy after the transport closes. */
63
+ classifyClose: (event: CloseEvent) => CloseDecision;
64
+ }
65
+
66
+ /** Create a client without opening a connection. */
67
+ export function createNotebookClient(store: ReturnType<typeof createStore>) {
68
+ const connectionAtom = atom<ConnectionStatus>({
69
+ state: ConnectionState.NOT_STARTED,
70
+ });
71
+ // One atom holds the whole stream, which resource atoms derive from.
72
+ const reducer = createReducer({ document, attachments });
73
+ const streamAtom = atom(reducer.initial);
74
+ // A resumed stream is live, but presence waits for this connection's ready.
75
+ const readyAtom = atom(false);
76
+ const notebookAtom = atom((get) =>
77
+ get(readyAtom) && get(connectionAtom).state === ConnectionState.OPEN
78
+ ? get(streamAtom).notebook
79
+ : null,
80
+ );
81
+ const documentAtom = atom(
82
+ (get) => get(streamAtom).resources.document.current,
83
+ );
84
+ const attachmentsAtom = atom((get) =>
85
+ get(readyAtom) && get(connectionAtom).state === ConnectionState.OPEN
86
+ ? get(streamAtom).resources.attachments
87
+ : attachments.initial,
88
+ );
89
+ let reconnect: (() => Promise<void>) | undefined;
90
+ const client: NotebookClient = {
91
+ connectionAtom,
92
+ notebookAtom,
93
+ documentAtom,
94
+ attachmentsAtom,
95
+ reconnect: async () => reconnect?.(),
96
+ };
97
+
98
+ function connect({
99
+ createTransport,
100
+ url,
101
+ signal,
102
+ waitUntilReady,
103
+ checkHealth,
104
+ onNotification,
105
+ decodeJson,
106
+ classifyClose,
107
+ }: NotebookConnection) {
108
+ if (signal.aborted) {
109
+ return;
110
+ }
111
+ let shouldRetry = true;
112
+ let terminal = false;
113
+ let checkingHealth = false;
114
+ let streamKey: string | undefined;
115
+ // The id of the last live event. Null before `ready` and after `reset`.
116
+ let cursor: string | null = null;
117
+ const transport = createTransport(() => {
118
+ const next = new URL(url());
119
+ next.searchParams.delete("cursor");
120
+ if (signal.aborted) {
121
+ return next.toString();
122
+ }
123
+ // A cursor is useful with the state that produced it, which a change
124
+ // of session or notebook invalidates. Authentication may change freely.
125
+ const identity = new URL(next);
126
+ identity.searchParams.delete("access_token");
127
+ identity.searchParams.sort();
128
+ const key = identity.toString();
129
+ const current = store.get(streamAtom);
130
+ // A live stream keeps its state and resumes from the cursor. An
131
+ // incomplete snapshot starts over.
132
+ const state =
133
+ key !== streamKey || current.phase === "snapshot"
134
+ ? reducer.initial
135
+ : current;
136
+ if (state.phase === "snapshot") {
137
+ cursor = null;
138
+ }
139
+ streamKey = key;
140
+ store.set(readyAtom, false);
141
+ store.set(streamAtom, state);
142
+ if (cursor !== null) {
143
+ next.searchParams.set("cursor", cursor);
144
+ }
145
+ return next.toString();
146
+ });
147
+ const setStatus = (status: ConnectionStatus) => {
148
+ store.set(readyAtom, false);
149
+ store.set(connectionAtom, status);
150
+ };
151
+ const disconnected = {
152
+ state: ConnectionState.CLOSED,
153
+ code: ConnectionClosedReason.KERNEL_DISCONNECTED,
154
+ reason: "kernel not found",
155
+ } satisfies ConnectionStatus;
156
+
157
+ function retry(code?: number, reason?: string) {
158
+ if (!signal.aborted && !terminal && shouldRetry) {
159
+ shouldRetry = false;
160
+ transport.reconnect(code, reason);
161
+ }
162
+ }
163
+
164
+ function onOpen() {
165
+ if (signal.aborted || terminal) {
166
+ return;
167
+ }
168
+ shouldRetry = true;
169
+ setStatus({ state: ConnectionState.OPEN });
170
+ }
171
+
172
+ function onClose(event: CloseEvent) {
173
+ if (signal.aborted || terminal) {
174
+ return;
175
+ }
176
+ Logger.warn("Notebook connection closed", event.code, event.reason);
177
+ const decision = classifyClose(event);
178
+ setStatus(decision.status);
179
+ if (decision.kind === "terminal") {
180
+ terminal = true;
181
+ transport.close();
182
+ } else if (decision.kind === "retry") {
183
+ retry(event.code, event.reason);
184
+ } else {
185
+ terminal = true;
186
+ }
187
+ }
188
+
189
+ function onError(event: Event) {
190
+ if (signal.aborted || terminal) {
191
+ return;
192
+ }
193
+ Logger.warn("Notebook connection error", event);
194
+ setStatus(disconnected);
195
+ retry();
196
+ }
197
+
198
+ function onMessage(message: MessageEvent<string>) {
199
+ if (signal.aborted || terminal) {
200
+ return;
201
+ }
202
+ let event;
203
+ try {
204
+ event = parseNotebookEvent(decodeJson(message.data));
205
+ const state = store.get(streamAtom);
206
+ if (
207
+ state.phase === "live" &&
208
+ event.id === undefined &&
209
+ !UNCURSORED.has(event.event)
210
+ ) {
211
+ throw new Error("Live notebook event is missing its cursor");
212
+ }
213
+ if (event.event !== "marimo" && event.event !== "unknown") {
214
+ store.set(streamAtom, reducer.reduce(state, event));
215
+ }
216
+ if (event.event === "reset") {
217
+ cursor = null;
218
+ } else if (event.event === "ready" || state.phase === "live") {
219
+ cursor = event.id ?? cursor;
220
+ }
221
+ if (event.event === "ready" || event.event === "reset") {
222
+ store.set(readyAtom, event.event === "ready");
223
+ }
224
+ } catch (error) {
225
+ Logger.error("Failed to decode notebook message", error);
226
+ store.set(streamAtom, reducer.initial);
227
+ cursor = null;
228
+ terminal = true;
229
+ setStatus({
230
+ ...disconnected,
231
+ reason: "Failed to decode notebook message",
232
+ });
233
+ transport.close();
234
+ return;
235
+ }
236
+ if (event.event === "marimo") {
237
+ onNotification(event.data.marimo);
238
+ }
239
+ }
240
+
241
+ reconnect = async () => {
242
+ if (
243
+ signal.aborted ||
244
+ checkingHealth ||
245
+ transport.readyState === TransportReadyState.OPEN ||
246
+ transport.readyState === TransportReadyState.CONNECTING
247
+ ) {
248
+ return;
249
+ }
250
+ checkingHealth = true;
251
+ setStatus({ state: ConnectionState.CONNECTING });
252
+ try {
253
+ const healthy = await checkHealth();
254
+ if (signal.aborted) {
255
+ return;
256
+ }
257
+ if (!healthy) {
258
+ shouldRetry = false;
259
+ setStatus(disconnected);
260
+ return;
261
+ }
262
+ terminal = false;
263
+ shouldRetry = true;
264
+ transport.reconnect();
265
+ } catch (error) {
266
+ if (!signal.aborted) {
267
+ Logger.error("Notebook health check failed", error);
268
+ shouldRetry = false;
269
+ setStatus(disconnected);
270
+ }
271
+ } finally {
272
+ checkingHealth = false;
273
+ }
274
+ };
275
+
276
+ signal.addEventListener(
277
+ "abort",
278
+ () => {
279
+ transport.removeEventListener("open", onOpen);
280
+ transport.removeEventListener("close", onClose);
281
+ transport.removeEventListener("error", onError);
282
+ transport.removeEventListener("message", onMessage);
283
+ transport.close();
284
+ reconnect = undefined;
285
+ store.set(streamAtom, reducer.initial);
286
+ setStatus({ state: ConnectionState.NOT_STARTED });
287
+ },
288
+ { once: true },
289
+ );
290
+
291
+ transport.addEventListener("open", onOpen);
292
+ transport.addEventListener("close", onClose);
293
+ transport.addEventListener("error", onError);
294
+ transport.addEventListener("message", onMessage);
295
+
296
+ if (transport.readyState === TransportReadyState.CLOSED) {
297
+ setStatus({ state: ConnectionState.CONNECTING });
298
+ void waitUntilReady()
299
+ .then(() => {
300
+ if (!signal.aborted) {
301
+ transport.reconnect();
302
+ }
303
+ })
304
+ .catch((error) => {
305
+ if (!signal.aborted) {
306
+ Logger.error("Healthy connection never made", error);
307
+ terminal = true;
308
+ setStatus(disconnected);
309
+ transport.close();
310
+ }
311
+ });
312
+ }
313
+ }
314
+
315
+ return { client, connect };
316
+ }
@@ -0,0 +1,19 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { Attachment } from "@marimo-team/client/protocol";
4
+ import type { Resource } from "@marimo-team/client/reducer";
5
+
6
+ /** Attachment records retained with the stream's resume cursor. */
7
+ export const attachments: Resource<readonly Attachment[], "attachment"> = {
8
+ initial: [],
9
+ on: {
10
+ attachment: (state, { data: { attachment } }) =>
11
+ state.some((item) => item.id === attachment.id)
12
+ ? state.map((item) => (item.id === attachment.id ? attachment : item))
13
+ : [...state, attachment],
14
+ "attachment.removed": (state, { data }) =>
15
+ state.some((item) => item.id === data.id)
16
+ ? state.filter((item) => item.id !== data.id)
17
+ : state,
18
+ },
19
+ };
@@ -0,0 +1,101 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type * as Protocol from "@marimo-team/client/protocol";
4
+ import type { Resource } from "@marimo-team/client/reducer";
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
+ 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, "cell" | "document"> = {
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
+ };
@@ -2,6 +2,7 @@
2
2
  import { atom, useAtomValue, useSetAtom } from "jotai";
3
3
  import useEvent from "react-use-event-hook";
4
4
  import type { HotkeyAction } from "@/core/hotkeys/hotkeys";
5
+ import { Objects } from "@/utils/objects";
5
6
 
6
7
  /**
7
8
  * Map of registered keyboard shortcuts and their callbacks.
@@ -29,10 +30,7 @@ export function useSetRegisteredAction() {
29
30
  },
30
31
  ),
31
32
  unregisterAction: useEvent((shortcut: HotkeyAction) => {
32
- set((actions) => {
33
- const { [shortcut]: unused, ...rest } = actions;
34
- return rest;
35
- });
33
+ set((actions) => Objects.omit(actions, [shortcut]));
36
34
  }),
37
35
  };
38
36
  }
@@ -3,8 +3,6 @@
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";
8
6
  import { initializeIslands } from "../bootstrap";
9
7
  import type { IslandsPyodideBridge } from "../bridge";
10
8
  import { islandsHydratedAtom, islandsPendingInitialRunsAtom } from "../state";
@@ -50,30 +48,6 @@ describe("initializeIslands", () => {
50
48
  expect(store.get(islandsHydratedAtom)).toBe(false);
51
49
  store.set(islandsPendingInitialRunsAtom, new Set([1, 2]));
52
50
 
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
-
77
51
  consumeMessage?.(completedRun(1));
78
52
  expect(store.get(islandsHydratedAtom)).toBe(false);
79
53
 
@@ -209,7 +209,6 @@ function handleMessage(
209
209
  case "cache-info":
210
210
  case "kernel-startup-error":
211
211
  case "notebook-document-transaction":
212
- case "attachments":
213
212
  return;
214
213
 
215
214
  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(),
@@ -8,8 +8,9 @@ import { RuntimeManager } from "../runtime";
8
8
  import type { RuntimeConfig } from "../types";
9
9
 
10
10
  // Mock the session module
11
- vi.mock("@/core/kernel/session", () => ({
12
- getSessionId: () => "test-session-id" as SessionId,
11
+ vi.mock("@/core/kernel/session", async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import("@/core/kernel/session")>()),
13
+ getSessionId: () => "test-session-id",
13
14
  }));
14
15
 
15
16
  vi.mock("@/utils/Logger", () => ({ Logger: Mocks.quietLogger() }));
@@ -59,6 +60,7 @@ describe("RuntimeManager", () => {
59
60
  expect(url.protocol).toBe("wss:");
60
61
  expect(url.hostname).toBe("example.com");
61
62
  expect(url.pathname).toBe("/ws");
63
+ expect(url.searchParams.has("types")).toBe(false);
62
64
  expect(url.searchParams.get("session_id")).toBe(sessionId);
63
65
  });
64
66
 
@@ -219,15 +221,6 @@ describe("RuntimeManager", () => {
219
221
  });
220
222
 
221
223
  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
- });
230
-
231
224
  it("should never put the auth token in the URL, even cross-origin", () => {
232
225
  // SSE requests can send headers, so auth travels in the
233
226
  // Authorization header; a token in the URL would leak into
@@ -737,7 +730,7 @@ describe("RuntimeManager", () => {
737
730
  );
738
731
  });
739
732
 
740
- it("should throw when creating WebSocket URLs from blob URLs", () => {
733
+ it("warns when creating WebSocket URLs from blob URLs", () => {
741
734
  const runtime = new RuntimeManager({
742
735
  url: "blob:https://example.com/12345678-1234-1234-1234-123456789abc",
743
736
  lazy: true,
@@ -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 knownQueryParams = new Set(Object.values(KnownQueryParams));
13
+
12
14
  export class RuntimeManager {
13
15
  private initialHealthyCheck = new Deferred<void>();
14
16
  private config: RuntimeConfig;
@@ -71,10 +73,7 @@ export class RuntimeManager {
71
73
  }
72
74
 
73
75
  for (const [key, value] of currentParams.entries()) {
74
- if (
75
- restrictToKnownQueryParams &&
76
- !Object.values(KnownQueryParams).includes(key)
77
- ) {
76
+ if (restrictToKnownQueryParams && !knownQueryParams.has(key)) {
78
77
  continue;
79
78
  }
80
79
  baseUrl.searchParams.set(key, value);
@@ -154,7 +153,8 @@ export class RuntimeManager {
154
153
  * The WebSocket URL of the runtime.
155
154
  */
156
155
  public getWsURL(sessionId: SessionId): URL {
157
- return this.formatWsURL("/ws", this.getSessionSearchParams(sessionId));
156
+ const searchParams = this.getSessionSearchParams(sessionId);
157
+ return this.formatWsURL("/ws", searchParams);
158
158
  }
159
159
 
160
160
  /**
@@ -125,6 +125,8 @@ function withoutLeadingDot(path: string): string {
125
125
  /**
126
126
  * Resolve a URL to a blob URL if it's a virtual file, for use with dynamic import().
127
127
  * Unlike fetch, import() can't be patched, so we need to convert data URLs to blob URLs.
128
+ * The caller owns a returned URL that differs from the input and must revoke it
129
+ * once no module consumers or their cleanup callbacks need it.
128
130
  *
129
131
  * @returns The original URL if not a virtual file, or a blob URL if it is
130
132
  */