@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.
- package/dist/assets/{CellStatus-BP2mavAq.js → CellStatus-DVOHpdeJ.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-t8fH_TNc.js → ConnectedDataExplorerComponent-TprnxZeJ.js} +1 -1
- package/dist/assets/{ErrorBoundary-Gl5nCA8C.js → ErrorBoundary-CM13wrd8.js} +2 -2
- package/dist/assets/JsonOutput-Cx1iyibB.js +11 -0
- package/dist/assets/{LazyAnyLanguageCodeMirror-CKcCbG2G.js → LazyAnyLanguageCodeMirror-B4fCknq1.js} +2 -2
- package/dist/assets/MarimoErrorOutput-s_5GOX8k.js +10 -0
- package/dist/assets/{RenderHTML-BbOqmHy3.js → RenderHTML-DX6i99Op.js} +1 -1
- package/dist/assets/{RunButton-CK3LhnP5.js → RunButton-BKoh0zYG.js} +1 -1
- package/dist/assets/{add-cell-with-ai-8UBC0xwR.js → add-cell-with-ai-BpD_790A.js} +11 -11
- package/dist/assets/{add-connection-dialog-DXQnd6eB.js → add-connection-dialog-B1BXCjex.js} +2 -2
- package/dist/assets/{agent-panel-D7kMZLG-.js → agent-panel-CDFUc1uu.js} +5 -5
- package/dist/assets/ai-model-dropdown-i1vfGeVi.js +13 -0
- package/dist/assets/{any-language-editor-BLCAD3yx.js → any-language-editor-BdTWdMCI.js} +1 -1
- package/dist/assets/{app-config-button-BKEG4x9u.js → app-config-button-AdV-QDD7.js} +1 -1
- package/dist/assets/{blob-D5nfNHOo.js → blob-BG8yoQmx.js} +1 -1
- package/dist/assets/{cell-editor-ADVfycpN.js → cell-editor-BKJ06H5F.js} +9 -9
- package/dist/assets/cell-link-8Auigi8P.js +1 -0
- package/dist/assets/{cells-BRkCaSsh.js → cells-C6YGYGdi.js} +56 -56
- package/dist/assets/{chat-display-Dnagy4Nv.js → chat-display-DhgI2MS9.js} +1 -1
- package/dist/assets/{chat-panel-DQ6HkWEF.js → chat-panel-Cau71Ygr.js} +2 -2
- package/dist/assets/{chat-ui-Df8Vcj6n.js → chat-ui-C5ql1Up-.js} +1 -1
- package/dist/assets/code-visibility-BJImdpDi.js +1 -0
- package/dist/assets/coerce-DWjFeXOY.js +1 -0
- package/dist/assets/{column-preview-Cm2D6vyX.js → column-preview-BZ_iNhtJ.js} +1 -1
- package/dist/assets/{command-palette-DTSQ7gOC.js → command-palette-DDC3kBet.js} +1 -1
- package/dist/assets/{common-Cu6CDbuw.js → common-Sl3uthjg.js} +1 -1
- package/dist/assets/{components-CGw16R_N.js → components-3cou6Mka.js} +1 -1
- package/dist/assets/{components-CP_UoFtT.js → components-CskjK6xZ.js} +1 -1
- package/dist/assets/{components-CXfO-F47.js → components-YBmtV0p6.js} +1 -1
- package/dist/assets/{config-VPSq73n8.js → config-CA1V-wjh.js} +1 -1
- package/dist/assets/config-DRIpOKqh.js +1 -0
- package/dist/assets/config-Dy28fDwn.js +1 -0
- package/dist/assets/{datasource-Cq6x_5ML.js → datasource-DfbNb83e.js} +1 -1
- package/dist/assets/{dates-BbFQ2oE4.js → dates-CfmdTL6B.js} +1 -1
- package/dist/assets/{dependency-graph-panel--Kgtktcw.js → dependency-graph-panel-L5cBE5sl.js} +1 -1
- package/dist/assets/{documentation-panel-BHmbeznw.js → documentation-panel-D7S8JIc2.js} +1 -1
- package/dist/assets/{download-DCJXy66H.js → download-C03FOElq.js} +1 -1
- package/dist/assets/edit-page-CLF_VUTW.js +9 -0
- package/dist/assets/{error-banner-Cd69vvyX.js → error-banner-COZ7SfYV.js} +1 -1
- package/dist/assets/{error-entries-Dk3VZ-OK.js → error-entries-CLnPDv7b.js} +1 -1
- package/dist/assets/{error-panel-BPRYmC5J.js → error-panel-D2xrYLue.js} +1 -1
- package/dist/assets/{errors-DCrsa1nH.js → errors-CNsgAw6m.js} +1 -1
- package/dist/assets/{feedback-button-Bik2-e33.js → feedback-button-DzPunIqU.js} +2 -2
- package/dist/assets/feedback-button-Kla7i5RT.js +1 -0
- package/dist/assets/{file-explorer-panel-BEEK5lcl.js → file-explorer-panel-Uk4_QIJQ.js} +1 -1
- package/dist/assets/{file-name-input-Cdc-TVqO.js → file-name-input-B4fRaQkI.js} +1 -1
- package/dist/assets/{floating-outline-XD9FAV-f.js → floating-outline-B5MaM1JS.js} +1 -1
- package/dist/assets/{gallery-page-Dm6mGwBi.js → gallery-page-BefvdsM7.js} +1 -1
- package/dist/assets/{glide-data-editor-J9CAb70d.js → glide-data-editor-C3eaYlXg.js} +1 -1
- package/dist/assets/{home-page-DMHnsRxm.js → home-page-hmvKlc1w.js} +1 -1
- package/dist/assets/{hooks-93IwHd2u.js → hooks-DzML9nez.js} +1 -1
- package/dist/assets/index-6b4mf8Ai.css +2 -0
- package/dist/assets/index-CMx5iSyu.js +30 -0
- package/dist/assets/{layout-DmcNEu3P.js → layout-Ck1RoL1B.js} +5 -5
- package/dist/assets/{logs-panel-ABM16htT.js → logs-panel-BRytaWaO.js} +1 -1
- package/dist/assets/{markdown-renderer-BG3sp2rs.js → markdown-renderer-DX0Xrrfy.js} +1 -1
- package/dist/assets/{mermaid-a3xYymTy.js → mermaid-C4EiVqcd.js} +1 -1
- package/dist/assets/{name-cell-input-BgEBmqyL.js → name-cell-input-DlpTvPLa.js} +1 -1
- package/dist/assets/{outline-panel-DCXQ3_F6.js → outline-panel-B2WIpz-1.js} +1 -1
- package/dist/assets/{packages-panel-BMGOmZkR.js → packages-panel-CI5ff9b-.js} +1 -1
- package/dist/assets/{panel-accordion-state-C1sdPp0m.js → panel-accordion-state-BqSH5ttk.js} +1 -1
- package/dist/assets/panels-B_jlikjE.js +107 -0
- package/dist/assets/{process-output-D0KGq04R.js → process-output-f3C3PD0Z.js} +1 -1
- package/dist/assets/{readonly-code-display-Do50PHI8.js → readonly-code-display-MnMEoYW5.js} +1 -1
- package/dist/assets/{readonly-python-code-DCSMIsTX.js → readonly-python-code-ggs4hAzC.js} +1 -1
- package/dist/assets/{renderShortcut-BkVJQ_Zh.js → renderShortcut-C1JwYcqW.js} +1 -1
- package/dist/assets/{reveal-component-ltaepAqw.js → reveal-component-PiV1eQk9.js} +1 -1
- package/dist/assets/run-page-CkP22x9D.js +1 -0
- package/dist/assets/{runtime-details-state-CSPobEdR.js → runtime-details-state-CoL-p0MC.js} +1 -1
- package/dist/assets/{schemas-Dv42wLFq.js → schemas-ChRqMOTL.js} +1 -1
- package/dist/assets/{scratchpad-panel-Dwd6W5PE.js → scratchpad-panel-D2rWg4k8.js} +1 -1
- package/dist/assets/{secrets-panel-Bctbtkrp.js → secrets-panel-D7KN4tVs.js} +1 -1
- package/dist/assets/session-panel-CnANhcTo.js +1 -0
- package/dist/assets/{snippets-panel-Bh1oTw4m.js → snippets-panel-ClGHtEKQ.js} +1 -1
- package/dist/assets/state-CbjBtkdd.js +3 -0
- package/dist/assets/{state-XsPO8hNi.js → state-DtGB7xeJ.js} +1 -1
- package/dist/assets/{terminal-Ck_vK-34.js → terminal-wnSHYT8N.js} +1 -1
- package/dist/assets/{tracing-Di6pUmX8.js → tracing-Dz2rYOFL.js} +1 -1
- package/dist/assets/{tracing-panel-PeRXHD8_.js → tracing-panel-DqQY9V91.js} +2 -2
- package/dist/assets/{tree-actions-GM5E_bUs.js → tree-actions-DnUokmva.js} +1 -1
- package/dist/assets/{useAddCell-GR7eEQrT.js → useAddCell-IN24thff.js} +1 -1
- package/dist/assets/{useBoolean-BDD6_xQQ.js → useBoolean-d0Tty94N.js} +1 -1
- package/dist/assets/{useCellActionButton-B-9eG5z_.js → useCellActionButton-DmaFZ4oF.js} +1 -1
- package/dist/assets/{useDeleteCell-2_bMClHd.js → useDeleteCell-fQlM46Y2.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-k3C3M18S.js → useDependencyPanelTab-ImXYINm_.js} +1 -1
- package/dist/assets/{useInstallPackage-iX1eufdl.js → useInstallPackage-BW06eter.js} +1 -1
- package/dist/assets/{useNotebookActions-NHN6A-cN.js → useNotebookActions-DHt3s-ia.js} +1 -1
- package/dist/assets/{useRunCells-B2vUhREg.js → useRunCells-DSJPXE-j.js} +1 -1
- package/dist/assets/{useSplitCell-DEb03htp.js → useSplitCell-DQyMF5gd.js} +1 -1
- package/dist/assets/{useTheme-COhSHkCj.js → useTheme-n8Dj1zH7.js} +1 -1
- package/dist/assets/{vega-component-CnadTgoE.js → vega-component-IouMe13t.js} +1 -1
- package/dist/index.html +34 -35
- package/package.json +1 -1
- package/src/__tests__/fixtures/{pair-preview.ts → pair-config.ts} +3 -2
- package/src/__tests__/mount.test.ts +28 -8
- package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
- package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
- package/src/components/data-table/__tests__/export-actions.test.tsx +329 -6
- package/src/components/data-table/context-menu.tsx +125 -26
- package/src/components/data-table/date-filter-inputs.tsx +15 -42
- package/src/components/data-table/export-actions.tsx +192 -116
- package/src/components/data-table/schemas.ts +14 -36
- package/src/components/data-table/utils.ts +18 -0
- package/src/components/editor/Output.tsx +9 -2
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +80 -252
- package/src/components/editor/actions/__tests__/pair-with-agent-modal.test.tsx +186 -34
- package/src/components/editor/actions/pair-with-agent-commands.ts +42 -51
- package/src/components/editor/actions/pair-with-agent-modal.tsx +96 -36
- package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
- package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +372 -0
- package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
- package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +237 -0
- package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
- package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +548 -0
- package/src/components/editor/errors/send-error-report-button.tsx +69 -0
- package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
- package/src/components/editor/output/MarimoTracebackOutput.tsx +35 -20
- package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
- package/src/components/editor/output/ansi-reduce.ts +6 -0
- package/src/core/attachments/__tests__/toast.test.tsx +58 -0
- package/src/core/attachments/state.ts +21 -0
- package/src/core/attachments/toast.ts +15 -0
- package/src/core/cells/__tests__/cells.test.ts +52 -0
- package/src/core/cells/__tests__/error-report.test.ts +139 -0
- package/src/core/cells/__tests__/session.test.ts +50 -1
- package/src/core/cells/error-report.ts +63 -0
- package/src/core/cells/session.ts +28 -1
- package/src/core/config/pair.ts +5 -3
- package/src/core/edit-app.tsx +2 -2
- package/src/core/host/AGENTS.md +5 -0
- package/src/core/host/__tests__/document.test.ts +124 -0
- package/src/core/host/__tests__/events.test.ts +46 -0
- package/src/core/host/__tests__/fixtures.ts +39 -0
- package/src/core/host/__tests__/notebook-client.test.ts +303 -0
- package/src/core/host/__tests__/stream.test.ts +211 -0
- package/src/core/host/connection.ts +81 -0
- package/src/core/host/events.ts +60 -0
- package/src/core/host/notebook-client.ts +272 -0
- package/src/core/host/resources/document.ts +101 -0
- package/src/core/host/resources/resource.ts +59 -0
- package/src/core/host/stream.ts +213 -0
- package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
- package/src/core/islands/bootstrap.ts +1 -0
- package/src/core/network/connection.ts +4 -2
- package/src/core/run-app.tsx +2 -2
- package/src/core/runtime/__tests__/runtime.test.ts +55 -11
- package/src/core/runtime/runtime.ts +14 -1
- package/src/core/runtime/types.ts +2 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.test.ts → connection-state.test.ts} +15 -17
- package/src/core/websocket/__tests__/{useWebSocket.test.ts → createConnectionTransport.test.ts} +1 -1
- package/src/core/websocket/__tests__/useNotebookClient.hook.test.tsx +140 -0
- package/src/core/websocket/__tests__/{useMarimoKernelConnection.hook.test.tsx → useNotebookSession.hook.test.tsx} +352 -65
- package/src/core/websocket/connection-state.ts +70 -0
- package/src/core/websocket/transports/__tests__/sse.test.ts +1 -4
- package/src/core/websocket/transports/create.ts +31 -0
- package/src/core/websocket/transports/transport.ts +12 -23
- package/src/core/websocket/types.ts +9 -39
- package/src/core/websocket/useNotebookClient.ts +80 -0
- package/src/core/websocket/{useMarimoKernelConnection.tsx → useNotebookSession.tsx} +67 -225
- package/src/mount.tsx +7 -3
- package/src/plugins/impl/__tests__/DataTablePlugin.test.tsx +84 -0
- package/src/utils/__tests__/dates.test.ts +116 -0
- package/src/utils/dates.ts +94 -0
- package/dist/assets/JsonOutput-D6HIIsCb.js +0 -11
- package/dist/assets/MarimoErrorOutput-D60XkCny.js +0 -6
- package/dist/assets/ai-model-dropdown-B0z6-zhp.js +0 -5
- package/dist/assets/api-BeC_VSGv.js +0 -1
- package/dist/assets/cell-link-LSth_8hT.js +0 -1
- package/dist/assets/code-visibility-B-_GvwR5.js +0 -1
- package/dist/assets/coerce-CpE6kUdX.js +0 -1
- package/dist/assets/config-BOKzA3tF.js +0 -1
- package/dist/assets/config-DX8Y2WbJ.js +0 -1
- package/dist/assets/edit-page-ChcSgpke.js +0 -9
- package/dist/assets/feedback-button-B2gKC5cn.js +0 -1
- package/dist/assets/index-Bcdzex-3.css +0 -2
- package/dist/assets/index-s-M1OXjs.js +0 -29
- package/dist/assets/pair-CaQyK6En.js +0 -1
- package/dist/assets/panels-CxVG8-v2.js +0 -4
- package/dist/assets/run-page-DF3c5cDk.js +0 -1
- package/dist/assets/session-panel-VdDhEBa-.js +0 -1
- package/dist/assets/state-_Qai_QyX.js +0 -3
- package/src/core/websocket/useWebSocket.tsx +0 -88
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { NotebookEvent } from "../events";
|
|
5
|
+
import type { Resource } from "../resources/resource";
|
|
6
|
+
import { createStream, type Stream, type StreamState } from "../stream";
|
|
7
|
+
import { fixtureEvents } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
interface CellIds {
|
|
10
|
+
readonly ids: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A resource whose events are complete on their own. */
|
|
14
|
+
const cells: Resource<CellIds> = {
|
|
15
|
+
initial: { ids: [] },
|
|
16
|
+
on: {
|
|
17
|
+
cell: (state, event) =>
|
|
18
|
+
state.ids.includes(event.data.cell.id)
|
|
19
|
+
? state
|
|
20
|
+
: { ids: [...state.ids, event.data.cell.id] },
|
|
21
|
+
"cell.removed": (state, event) =>
|
|
22
|
+
state.ids.includes(event.data.id)
|
|
23
|
+
? { ids: state.ids.filter((id) => id !== event.data.id) }
|
|
24
|
+
: state,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface Revisions {
|
|
29
|
+
/** Cells changed since the last revision. */
|
|
30
|
+
readonly pending: readonly string[];
|
|
31
|
+
readonly published: readonly (readonly string[])[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publish(state: Revisions): Revisions {
|
|
35
|
+
return { pending: [], published: [...state.published, state.pending] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A resource assembled from groups of events, like a document from its cells. */
|
|
39
|
+
const revisions: Resource<Revisions> = {
|
|
40
|
+
initial: { pending: [], published: [] },
|
|
41
|
+
on: {
|
|
42
|
+
cell: (state, event) => ({
|
|
43
|
+
...state,
|
|
44
|
+
pending: [...state.pending, event.data.cell.id],
|
|
45
|
+
}),
|
|
46
|
+
"cell.removed": (state, event) => ({
|
|
47
|
+
...state,
|
|
48
|
+
pending: [...state.pending, event.data.id],
|
|
49
|
+
}),
|
|
50
|
+
// A live revision completes a group. A snapshot's revision waits for ready.
|
|
51
|
+
document: (state, _event, { phase }) =>
|
|
52
|
+
phase === "live" ? publish(state) : state,
|
|
53
|
+
ready: publish,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface Resources {
|
|
58
|
+
cells: CellIds;
|
|
59
|
+
revisions: Revisions;
|
|
60
|
+
}
|
|
61
|
+
type State = StreamState<Resources>;
|
|
62
|
+
|
|
63
|
+
function replay(
|
|
64
|
+
stream: Stream<Resources>,
|
|
65
|
+
events: readonly NotebookEvent[],
|
|
66
|
+
from: State = stream.initial,
|
|
67
|
+
): State {
|
|
68
|
+
return events.reduce((state, event) => stream.reduce(state, event), from);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snapshot = fixtureEvents("notebook-events.sse");
|
|
72
|
+
const ready = snapshot.findIndex((event) => event.event === "ready");
|
|
73
|
+
const cellEvent = snapshot.find((event) => event.event === "cell");
|
|
74
|
+
if (cellEvent?.event !== "cell") {
|
|
75
|
+
throw new Error("Fixture is missing a cell event");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("notebook stream", () => {
|
|
79
|
+
it("reads the snapshot, goes live at ready, and rebuilds after reset", () => {
|
|
80
|
+
const stream = createStream({ cells, revisions });
|
|
81
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
82
|
+
expect(partial).toMatchObject({
|
|
83
|
+
phase: "snapshot",
|
|
84
|
+
notebookId: "n1",
|
|
85
|
+
cursor: null,
|
|
86
|
+
});
|
|
87
|
+
expect(partial.resources.cells.ids).toEqual(["c1", "c3", "k7f2"]);
|
|
88
|
+
expect(partial.resources.revisions).toEqual({
|
|
89
|
+
pending: ["c1", "c3", "k7f2"],
|
|
90
|
+
published: [],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
94
|
+
expect(live).toMatchObject({ phase: "live", cursor: "97" });
|
|
95
|
+
expect(live.resources.cells).toBe(partial.resources.cells);
|
|
96
|
+
expect(live.resources.revisions).toEqual({
|
|
97
|
+
pending: [],
|
|
98
|
+
published: [["c1", "c3", "k7f2"]],
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const resetEvents = fixtureEvents("events-reset.sse");
|
|
102
|
+
const afterReset = stream.reduce(live, resetEvents[0]);
|
|
103
|
+
expect(afterReset).toEqual({ ...stream.initial, notebookId: "n1" });
|
|
104
|
+
expect(replay(stream, resetEvents, live).phase).toBe("live");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("keeps resource state by reference when an event does not apply", () => {
|
|
108
|
+
const stream = createStream({ cells, revisions });
|
|
109
|
+
const state = replay(stream, snapshot);
|
|
110
|
+
const runtime = snapshot.find((event) => event.event === "runtime");
|
|
111
|
+
const unhandled = stream.reduce(state, { ...runtime!, id: "100" });
|
|
112
|
+
expect(unhandled.resources).toBe(state.resources);
|
|
113
|
+
expect(unhandled.cursor).toBe("100");
|
|
114
|
+
expect(
|
|
115
|
+
stream.reduce(state, { event: "unknown", name: "x", id: "101" })
|
|
116
|
+
.resources,
|
|
117
|
+
).toBe(state.resources);
|
|
118
|
+
expect(
|
|
119
|
+
stream.reduce(state, { event: "marimo", data: { marimo: {} } }),
|
|
120
|
+
).toBe(state);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("clears resources when its notebook is removed and ignores other notebooks", () => {
|
|
124
|
+
const stream = createStream({ cells, revisions });
|
|
125
|
+
const state = replay(stream, snapshot);
|
|
126
|
+
const time = "2026-10-01T09:30:00Z";
|
|
127
|
+
expect(
|
|
128
|
+
stream.reduce(state, {
|
|
129
|
+
event: "notebook.removed",
|
|
130
|
+
id: "100",
|
|
131
|
+
data: { id: "n2", time },
|
|
132
|
+
}).resources,
|
|
133
|
+
).toBe(state.resources);
|
|
134
|
+
const removed = stream.reduce(state, {
|
|
135
|
+
event: "notebook.removed",
|
|
136
|
+
id: "100",
|
|
137
|
+
data: { id: "n1", time },
|
|
138
|
+
});
|
|
139
|
+
expect(removed).toMatchObject({ phase: "live", notebookId: "n1" });
|
|
140
|
+
expect(removed.resources).toBe(stream.initial.resources);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects objects from another notebook", () => {
|
|
144
|
+
const stream = createStream({ cells, revisions });
|
|
145
|
+
const state = replay(stream, snapshot.slice(0, ready));
|
|
146
|
+
const notebook = snapshot[0];
|
|
147
|
+
if (notebook.event !== "notebook") {
|
|
148
|
+
throw new Error("Fixture does not start with its notebook");
|
|
149
|
+
}
|
|
150
|
+
expect(() =>
|
|
151
|
+
stream.reduce(state, {
|
|
152
|
+
...notebook,
|
|
153
|
+
data: { notebook: { ...notebook.data.notebook, id: "n2" } },
|
|
154
|
+
}),
|
|
155
|
+
).toThrow("changed identity");
|
|
156
|
+
expect(() =>
|
|
157
|
+
stream.reduce(state, {
|
|
158
|
+
...cellEvent,
|
|
159
|
+
data: { cell: { ...cellEvent.data.cell, notebook_id: "n2" } },
|
|
160
|
+
}),
|
|
161
|
+
).toThrow("changed identity");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("records each live event's cursor and resumes in place", () => {
|
|
165
|
+
const stream = createStream({ cells, revisions });
|
|
166
|
+
const partial = replay(stream, snapshot.slice(0, ready));
|
|
167
|
+
expect(stream.resume(partial)).toEqual({
|
|
168
|
+
...stream.initial,
|
|
169
|
+
notebookId: "n1",
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const live = replay(stream, snapshot.slice(ready), partial);
|
|
173
|
+
const [cell, , removed, published, saved] = fixtureEvents("edits.sse");
|
|
174
|
+
const editing = replay(stream, [cell], live);
|
|
175
|
+
expect(editing.cursor).toBe("110");
|
|
176
|
+
expect(editing.resources.revisions.pending).toEqual(["c2"]);
|
|
177
|
+
expect(stream.resume(editing)).toBe(editing);
|
|
178
|
+
|
|
179
|
+
const edited = replay(stream, [removed, published], editing);
|
|
180
|
+
expect(edited.cursor).toBe("113");
|
|
181
|
+
expect(edited.resources.revisions).toEqual({
|
|
182
|
+
pending: [],
|
|
183
|
+
published: [
|
|
184
|
+
["c1", "c3", "k7f2"],
|
|
185
|
+
["c2", "k7f2"],
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
expect(stream.reduce(edited, saved).cursor).toBe("114");
|
|
189
|
+
expect(
|
|
190
|
+
stream.reduce(edited, { event: "ready", id: "116", data: {} }).cursor,
|
|
191
|
+
).toBe("116");
|
|
192
|
+
expect(stream.reduce(edited, { event: "reset", data: {} }).cursor).toBe(
|
|
193
|
+
null,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("requires a cursor on live events other than reset and notifications", () => {
|
|
198
|
+
const stream = createStream({ cells, revisions });
|
|
199
|
+
const state = replay(stream, snapshot);
|
|
200
|
+
expect(() => stream.reduce(state, cellEvent)).toThrow("cursor");
|
|
201
|
+
expect(
|
|
202
|
+
stream.reduce(state, { ...cellEvent, id: "100" }).resources.cells,
|
|
203
|
+
).toBe(state.resources.cells);
|
|
204
|
+
expect(stream.reduce(state, { event: "reset", data: {} }).phase).toBe(
|
|
205
|
+
"snapshot",
|
|
206
|
+
);
|
|
207
|
+
expect(
|
|
208
|
+
stream.reduce(state, { event: "marimo", data: { marimo: null } }),
|
|
209
|
+
).toBe(state);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
export const ConnectionState = {
|
|
4
|
+
NOT_STARTED: "NOT_STARTED",
|
|
5
|
+
CONNECTING: "CONNECTING",
|
|
6
|
+
OPEN: "OPEN",
|
|
7
|
+
CLOSING: "CLOSING",
|
|
8
|
+
CLOSED: "CLOSED",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type ConnectionState =
|
|
12
|
+
(typeof ConnectionState)[keyof typeof ConnectionState];
|
|
13
|
+
|
|
14
|
+
export const ConnectionClosedReason = {
|
|
15
|
+
KERNEL_DISCONNECTED: "KERNEL_DISCONNECTED",
|
|
16
|
+
KERNEL_STARTUP_ERROR: "KERNEL_STARTUP_ERROR",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export type ConnectionClosedReason =
|
|
20
|
+
(typeof ConnectionClosedReason)[keyof typeof ConnectionClosedReason];
|
|
21
|
+
|
|
22
|
+
export type ConnectionStatus =
|
|
23
|
+
| {
|
|
24
|
+
state: typeof ConnectionState.CLOSED;
|
|
25
|
+
code: ConnectionClosedReason;
|
|
26
|
+
reason: string;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
state: typeof ConnectionState.CONNECTING;
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
state:
|
|
33
|
+
| typeof ConnectionState.OPEN
|
|
34
|
+
| typeof ConnectionState.CLOSING
|
|
35
|
+
| typeof ConnectionState.NOT_STARTED;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** The recovery policy for a closed connection. */
|
|
39
|
+
export interface CloseDecision {
|
|
40
|
+
kind: "terminal" | "gave-up" | "retry";
|
|
41
|
+
status: ConnectionStatus;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ConnectionEvent = "open" | "close" | "message" | "error";
|
|
45
|
+
|
|
46
|
+
export interface IConnectionTransportMap {
|
|
47
|
+
open: WebSocketEventMap["open"];
|
|
48
|
+
message: WebSocketEventMap["message"];
|
|
49
|
+
close: WebSocketEventMap["close"];
|
|
50
|
+
error: WebSocketEventMap["error"];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ConnectionTransportCallback<T extends ConnectionEvent> = (
|
|
54
|
+
data: IConnectionTransportMap[T],
|
|
55
|
+
) => void;
|
|
56
|
+
|
|
57
|
+
/** Transport readiness, using the WebSocket readyState values. */
|
|
58
|
+
export const TransportReadyState = {
|
|
59
|
+
CONNECTING: 0,
|
|
60
|
+
OPEN: 1,
|
|
61
|
+
CLOSING: 2,
|
|
62
|
+
CLOSED: 3,
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export type TransportReadyState =
|
|
66
|
+
(typeof TransportReadyState)[keyof typeof TransportReadyState];
|
|
67
|
+
|
|
68
|
+
/** A reconnectable source of notebook messages. */
|
|
69
|
+
export interface NotebookTransport {
|
|
70
|
+
reconnect(code?: number | undefined, reason?: string | undefined): void;
|
|
71
|
+
close(): void;
|
|
72
|
+
addEventListener<T extends ConnectionEvent>(
|
|
73
|
+
event: T,
|
|
74
|
+
callback: ConnectionTransportCallback<T>,
|
|
75
|
+
): void;
|
|
76
|
+
removeEventListener<T extends ConnectionEvent>(
|
|
77
|
+
event: T,
|
|
78
|
+
callback: ConnectionTransportCallback<T>,
|
|
79
|
+
): void;
|
|
80
|
+
readyState: TransportReadyState;
|
|
81
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import * as Protocol from "./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
|
+
/** Event kinds requested by the client; removals and controls are implicit. */
|
|
14
|
+
export const NOTEBOOK_EVENT_TYPES = [
|
|
15
|
+
"notebook",
|
|
16
|
+
"document",
|
|
17
|
+
"cell",
|
|
18
|
+
"marimo",
|
|
19
|
+
] as const satisfies readonly (Protocol.ObjectType | "marimo")[];
|
|
20
|
+
|
|
21
|
+
const envelope = z.object({
|
|
22
|
+
event: z.string().min(1),
|
|
23
|
+
data: z.unknown(),
|
|
24
|
+
id: z.string().min(1).optional(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const knownNames = new Set<string>(
|
|
28
|
+
Protocol.Event.options.flatMap((schema) => schema.shape.event.options),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
/** A decoded event, or the name and cursor of an unrecognized event. */
|
|
32
|
+
export type NotebookEvent =
|
|
33
|
+
| Protocol.EventOutput
|
|
34
|
+
| {
|
|
35
|
+
readonly event: "unknown";
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly id?: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const sessionEnvelope = z.object({ data: Protocol.MarimoNotification });
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decode a host event or a legacy `{ data: notification }` session message.
|
|
44
|
+
*
|
|
45
|
+
* Throws if the envelope or a known event is malformed. Native notification
|
|
46
|
+
* payloads are passed through for the session handler to interpret.
|
|
47
|
+
*/
|
|
48
|
+
export function parseNotebookEvent(value: unknown): NotebookEvent {
|
|
49
|
+
if (typeof value === "object" && value !== null && !("event" in value)) {
|
|
50
|
+
return {
|
|
51
|
+
event: "marimo",
|
|
52
|
+
data: { marimo: sessionEnvelope.parse(value).data },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const parsed = envelope.parse(value);
|
|
56
|
+
if (knownNames.has(parsed.event)) {
|
|
57
|
+
return Protocol.Event.parse(parsed);
|
|
58
|
+
}
|
|
59
|
+
return { event: "unknown", name: parsed.event, id: parsed.id };
|
|
60
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { Logger } from "@/utils/Logger";
|
|
4
|
+
import { type Atom, atom, type createStore } from "jotai/vanilla";
|
|
5
|
+
import {
|
|
6
|
+
type ConnectionStatus,
|
|
7
|
+
ConnectionState,
|
|
8
|
+
ConnectionClosedReason,
|
|
9
|
+
type CloseDecision,
|
|
10
|
+
type NotebookTransport,
|
|
11
|
+
TransportReadyState,
|
|
12
|
+
} from "./connection";
|
|
13
|
+
import { parseNotebookEvent } from "./events";
|
|
14
|
+
import { type Document, document } from "./resources/document";
|
|
15
|
+
import { createStream } from "./stream";
|
|
16
|
+
|
|
17
|
+
/** A connection to a notebook on a marimo host. */
|
|
18
|
+
export interface NotebookClient {
|
|
19
|
+
/** Client connection status, independent of application session readiness. */
|
|
20
|
+
readonly connectionAtom: Atom<ConnectionStatus>;
|
|
21
|
+
/**
|
|
22
|
+
* The most recently confirmed document.
|
|
23
|
+
*
|
|
24
|
+
* Null until a snapshot is complete, after reset or removal, and on
|
|
25
|
+
* connections without host document events. A temporary disconnection
|
|
26
|
+
* retains the last document. Partial updates are not exposed.
|
|
27
|
+
*/
|
|
28
|
+
readonly documentAtom: Atom<Document | null>;
|
|
29
|
+
/** Attempt to restore a closed connection after checking host health. */
|
|
30
|
+
reconnect(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The transport and application callbacks needed to connect a notebook. */
|
|
34
|
+
export interface NotebookConnection {
|
|
35
|
+
/** Create a transport that reads the URL again on each connection attempt. */
|
|
36
|
+
createTransport: (url: () => string) => NotebookTransport;
|
|
37
|
+
/** The current stream URL, including authentication and subscription options. */
|
|
38
|
+
url: () => string;
|
|
39
|
+
/** Host events require an intact stream; legacy notifications are independent. */
|
|
40
|
+
messageFormat: "host" | "session";
|
|
41
|
+
/** Abort to close the transport and clear the client's state. */
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
/** Wait until the host can accept the initial connection. */
|
|
44
|
+
waitUntilReady: () => Promise<void>;
|
|
45
|
+
/** Check whether the host is reachable before an explicit reconnect. */
|
|
46
|
+
checkHealth: () => Promise<boolean>;
|
|
47
|
+
/** Decode wire text without interpreting its event type. */
|
|
48
|
+
decodeJson: (message: string) => unknown;
|
|
49
|
+
/** Handle a native marimo notification. Its payload has not been validated. */
|
|
50
|
+
onNotification: (notification: unknown) => void;
|
|
51
|
+
/** Report a malformed message without coupling the client to editor UI. */
|
|
52
|
+
onMessageError: (error: unknown) => void;
|
|
53
|
+
/** Choose the connection status and retry policy after the transport closes. */
|
|
54
|
+
classifyClose: (event: CloseEvent) => CloseDecision;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Create a client without opening a connection. */
|
|
58
|
+
export function createNotebookClient(store: ReturnType<typeof createStore>) {
|
|
59
|
+
const connectionAtom = atom<ConnectionStatus>({
|
|
60
|
+
state: ConnectionState.NOT_STARTED,
|
|
61
|
+
});
|
|
62
|
+
// One atom holds the whole stream, which resource atoms derive from.
|
|
63
|
+
const stream = createStream({ document });
|
|
64
|
+
const streamAtom = atom(stream.initial);
|
|
65
|
+
const documentAtom = atom(
|
|
66
|
+
(get) => get(streamAtom).resources.document.current,
|
|
67
|
+
);
|
|
68
|
+
let reconnect: (() => Promise<void>) | undefined;
|
|
69
|
+
const client: NotebookClient = {
|
|
70
|
+
connectionAtom,
|
|
71
|
+
documentAtom,
|
|
72
|
+
reconnect: async () => reconnect?.(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function connect({
|
|
76
|
+
createTransport,
|
|
77
|
+
url,
|
|
78
|
+
messageFormat,
|
|
79
|
+
signal,
|
|
80
|
+
waitUntilReady,
|
|
81
|
+
checkHealth,
|
|
82
|
+
onNotification,
|
|
83
|
+
onMessageError,
|
|
84
|
+
decodeJson,
|
|
85
|
+
classifyClose,
|
|
86
|
+
}: NotebookConnection) {
|
|
87
|
+
if (signal.aborted) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
let shouldRetry = true;
|
|
91
|
+
let terminal = false;
|
|
92
|
+
let checkingHealth = false;
|
|
93
|
+
let streamKey: string | undefined;
|
|
94
|
+
const transport = createTransport(() => {
|
|
95
|
+
if (messageFormat === "session") {
|
|
96
|
+
return url();
|
|
97
|
+
}
|
|
98
|
+
const next = new URL(url());
|
|
99
|
+
next.searchParams.delete("cursor");
|
|
100
|
+
if (signal.aborted) {
|
|
101
|
+
return next.toString();
|
|
102
|
+
}
|
|
103
|
+
// A cursor is useful with the state that produced it, which a change
|
|
104
|
+
// of session or notebook invalidates. Authentication may change freely.
|
|
105
|
+
const identity = new URL(next);
|
|
106
|
+
identity.searchParams.delete("access_token");
|
|
107
|
+
identity.searchParams.sort();
|
|
108
|
+
const key = identity.toString();
|
|
109
|
+
const state =
|
|
110
|
+
key === streamKey
|
|
111
|
+
? stream.resume(store.get(streamAtom))
|
|
112
|
+
: stream.initial;
|
|
113
|
+
streamKey = key;
|
|
114
|
+
store.set(streamAtom, state);
|
|
115
|
+
if (state.cursor !== null) {
|
|
116
|
+
next.searchParams.set("cursor", state.cursor);
|
|
117
|
+
}
|
|
118
|
+
return next.toString();
|
|
119
|
+
});
|
|
120
|
+
const setStatus = (status: ConnectionStatus) =>
|
|
121
|
+
store.set(connectionAtom, status);
|
|
122
|
+
const disconnected = {
|
|
123
|
+
state: ConnectionState.CLOSED,
|
|
124
|
+
code: ConnectionClosedReason.KERNEL_DISCONNECTED,
|
|
125
|
+
reason: "kernel not found",
|
|
126
|
+
} satisfies ConnectionStatus;
|
|
127
|
+
|
|
128
|
+
function retry(code?: number, reason?: string) {
|
|
129
|
+
if (!signal.aborted && !terminal && shouldRetry) {
|
|
130
|
+
shouldRetry = false;
|
|
131
|
+
transport.reconnect(code, reason);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function onOpen() {
|
|
136
|
+
if (signal.aborted || terminal) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
shouldRetry = true;
|
|
140
|
+
setStatus({ state: ConnectionState.OPEN });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function onClose(event: CloseEvent) {
|
|
144
|
+
if (signal.aborted || terminal) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
Logger.warn("Notebook connection closed", event.code, event.reason);
|
|
148
|
+
const decision = classifyClose(event);
|
|
149
|
+
setStatus(decision.status);
|
|
150
|
+
if (decision.kind === "terminal") {
|
|
151
|
+
terminal = true;
|
|
152
|
+
transport.close();
|
|
153
|
+
} else if (decision.kind === "retry") {
|
|
154
|
+
retry(event.code, event.reason);
|
|
155
|
+
} else {
|
|
156
|
+
terminal = true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function onError(event: Event) {
|
|
161
|
+
if (signal.aborted || terminal) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
Logger.warn("Notebook connection error", event);
|
|
165
|
+
setStatus(disconnected);
|
|
166
|
+
retry();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onMessage(message: MessageEvent<string>) {
|
|
170
|
+
if (signal.aborted || terminal) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
let event;
|
|
174
|
+
try {
|
|
175
|
+
event = parseNotebookEvent(decodeJson(message.data));
|
|
176
|
+
store.set(streamAtom, stream.reduce(store.get(streamAtom), event));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
Logger.error("Failed to decode notebook message", error);
|
|
179
|
+
if (messageFormat === "session") {
|
|
180
|
+
onMessageError(error);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
store.set(streamAtom, stream.initial);
|
|
184
|
+
terminal = true;
|
|
185
|
+
setStatus({
|
|
186
|
+
...disconnected,
|
|
187
|
+
reason: "Failed to decode notebook message",
|
|
188
|
+
});
|
|
189
|
+
transport.close();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (event.event === "marimo") {
|
|
193
|
+
onNotification(event.data.marimo);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
reconnect = async () => {
|
|
198
|
+
if (
|
|
199
|
+
signal.aborted ||
|
|
200
|
+
checkingHealth ||
|
|
201
|
+
transport.readyState === TransportReadyState.OPEN ||
|
|
202
|
+
transport.readyState === TransportReadyState.CONNECTING
|
|
203
|
+
) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
checkingHealth = true;
|
|
207
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
208
|
+
try {
|
|
209
|
+
const healthy = await checkHealth();
|
|
210
|
+
if (signal.aborted) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (!healthy) {
|
|
214
|
+
shouldRetry = false;
|
|
215
|
+
setStatus(disconnected);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
terminal = false;
|
|
219
|
+
shouldRetry = true;
|
|
220
|
+
transport.reconnect();
|
|
221
|
+
} catch (error) {
|
|
222
|
+
if (!signal.aborted) {
|
|
223
|
+
Logger.error("Notebook health check failed", error);
|
|
224
|
+
shouldRetry = false;
|
|
225
|
+
setStatus(disconnected);
|
|
226
|
+
}
|
|
227
|
+
} finally {
|
|
228
|
+
checkingHealth = false;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
signal.addEventListener(
|
|
233
|
+
"abort",
|
|
234
|
+
() => {
|
|
235
|
+
transport.removeEventListener("open", onOpen);
|
|
236
|
+
transport.removeEventListener("close", onClose);
|
|
237
|
+
transport.removeEventListener("error", onError);
|
|
238
|
+
transport.removeEventListener("message", onMessage);
|
|
239
|
+
transport.close();
|
|
240
|
+
reconnect = undefined;
|
|
241
|
+
store.set(streamAtom, stream.initial);
|
|
242
|
+
setStatus({ state: ConnectionState.NOT_STARTED });
|
|
243
|
+
},
|
|
244
|
+
{ once: true },
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
transport.addEventListener("open", onOpen);
|
|
248
|
+
transport.addEventListener("close", onClose);
|
|
249
|
+
transport.addEventListener("error", onError);
|
|
250
|
+
transport.addEventListener("message", onMessage);
|
|
251
|
+
|
|
252
|
+
if (transport.readyState === TransportReadyState.CLOSED) {
|
|
253
|
+
setStatus({ state: ConnectionState.CONNECTING });
|
|
254
|
+
void waitUntilReady()
|
|
255
|
+
.then(() => {
|
|
256
|
+
if (!signal.aborted) {
|
|
257
|
+
transport.reconnect();
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
.catch((error) => {
|
|
261
|
+
if (!signal.aborted) {
|
|
262
|
+
Logger.error("Healthy connection never made", error);
|
|
263
|
+
terminal = true;
|
|
264
|
+
setStatus(disconnected);
|
|
265
|
+
transport.close();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return { client, connect };
|
|
272
|
+
}
|