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

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 (87) hide show
  1. package/dist/{ConnectedDataExplorerComponent-CNSxdKyJ.js → ConnectedDataExplorerComponent-CNMliVzw.js} +1 -1
  2. package/dist/{ErrorBoundary-iHE9DnZC.js → ErrorBoundary-BiZWOrv4.js} +1 -1
  3. package/dist/{abnfDiagram-VCTEODGH-DQDjNXt3.js → abnfDiagram-VCTEODGH-D-Ez91gV.js} +2 -2
  4. package/dist/{any-language-editor-B9REGlD9.js → any-language-editor-DZ_YTVus.js} +1 -1
  5. package/dist/{architectureDiagram-5GKGNRK7-Nmls7Wxn.js → architectureDiagram-5GKGNRK7-CTpcNtO8.js} +1 -1
  6. package/dist/{chat-ui-BDpymeoe.js → chat-ui-0lKdOoQ3.js} +436 -437
  7. package/dist/{chunk-IMKFNOWR-CoIFEo-1.js → chunk-IMKFNOWR-DGxi-zeo.js} +1 -1
  8. package/dist/{chunk-SHT3W25Y-lHiGx3ZC.js → chunk-SHT3W25Y-CV9J3rAB.js} +1 -1
  9. package/dist/{chunk-SVP7TREG-BvsHIrY_.js → chunk-SVP7TREG-BL-9Fqgg.js} +1 -1
  10. package/dist/{chunk-TICWLB2K-BC8YIm_n.js → chunk-TICWLB2K-Cu_XMjDM.js} +1 -1
  11. package/dist/{classDiagram-ZZMXUADV-Bt98lkkv.js → classDiagram-ZZMXUADV-BXXV13Hq.js} +2 -2
  12. package/dist/{classDiagram-v2-VYDZK3BY-D9bfbcC3.js → classDiagram-v2-VYDZK3BY-B7FXJ1Cp.js} +2 -2
  13. package/dist/{code-visibility-BCpXa0Xh.js → code-visibility-CW5_8ZOI.js} +14 -14
  14. package/dist/{common-BrBUdtPy.js → common-mzWw4FdI.js} +5466 -5236
  15. package/dist/{cynefinDiagram-5FMLGOSQ-CgWra7wt.js → cynefinDiagram-5FMLGOSQ-CH96rwba.js} +1 -1
  16. package/dist/{dagre-GXQ25YYZ-CFZ8-35c.js → dagre-GXQ25YYZ-BD3PmpFY.js} +1 -1
  17. package/dist/{data-grid-overlay-editor-Ch2SgmvG.js → data-grid-overlay-editor-CJmehSdc.js} +1 -1
  18. package/dist/{diagram-S7CK7UJ4-lCNQrW7d.js → diagram-S7CK7UJ4-BsSJJldB.js} +1 -1
  19. package/dist/{diagram-UQ7AKVKN-CQPQ6pP_.js → diagram-UQ7AKVKN-muQtNZc4.js} +1 -1
  20. package/dist/{diagram-VX7I27RA-Cf3b0llz.js → diagram-VX7I27RA-CdhuZAqR.js} +1 -1
  21. package/dist/{diagram-Z3DM3KII-Bt4QLrpn.js → diagram-Z3DM3KII-BD8mXjOj.js} +1 -1
  22. package/dist/{ebnfDiagram-PWID7BFC-D3NAyPFY.js → ebnfDiagram-PWID7BFC-BIN1sweE.js} +2 -2
  23. package/dist/{erDiagram-RLTQ6QDP-Tlu09_Nm.js → erDiagram-RLTQ6QDP-Ctp5cfoN.js} +1 -1
  24. package/dist/{error-banner-Da600cwz.js → error-banner-D-WCIiMt.js} +10 -5
  25. package/dist/{cells-C8iCYvLU.js → error-entries-DSvFWT_M.js} +2262 -2206
  26. package/dist/{feedback-button-DxXNcC6q.js → feedback-button-BcT1FBud.js} +115 -116
  27. package/dist/{flowDiagram-HODETNUW-CAYbpJmV.js → flowDiagram-HODETNUW-3EfLrlFb.js} +2 -2
  28. package/dist/{formats-BGh2thRc.js → formats-CeOmU1MU.js} +1 -1
  29. package/dist/{glide-data-editor-BQ7uKbeo.js → glide-data-editor-XckatD0j.js} +3 -3
  30. package/dist/{globals-DSavh2ZB.js → globals-jRrLCkrP.js} +1 -1
  31. package/dist/{html-to-image-CZY1pIZH.js → html-to-image-B8s5SgGm.js} +23 -23
  32. package/dist/{infoDiagram-27XIBGKW-XrDxhr0x.js → infoDiagram-27XIBGKW-DipQoHxl.js} +1 -1
  33. package/dist/{ishikawaDiagram-5VMMS53U-CD1muzov.js → ishikawaDiagram-5VMMS53U-DgFMjSGz.js} +1 -1
  34. package/dist/{kanban-definition-UXKFOSKX-Dg9W142_.js → kanban-definition-UXKFOSKX-w1ACVjgl.js} +1 -1
  35. package/dist/main.js +164 -163
  36. package/dist/{mermaid-CiAXjLpA.js → mermaid-CHo49Yv-.js} +33 -33
  37. package/dist/{mindmap-definition-YA3MSWOX-DgCidgYJ.js → mindmap-definition-YA3MSWOX-CrT0KSfX.js} +1 -1
  38. package/dist/{number-overlay-editor-D6kYaqh5.js → number-overlay-editor-pZx8dUDV.js} +1 -1
  39. package/dist/{pegDiagram-XKGWAZYB-coOYYKB6.js → pegDiagram-XKGWAZYB-BqvywNJA.js} +2 -2
  40. package/dist/{pieDiagram-E7YTZNPT-BnXTc3bj.js → pieDiagram-E7YTZNPT-BfxJ_XCc.js} +1 -1
  41. package/dist/{process-output-e4jj9Ijt.js → process-output-CKRXyszb.js} +3 -3
  42. package/dist/{railroadDiagram-O6MQD6OU-B28-u83n.js → railroadDiagram-O6MQD6OU-8vFLVTPu.js} +2 -2
  43. package/dist/{requirementDiagram-BXWQKSXE-CdYCTMu0.js → requirementDiagram-BXWQKSXE-a_MxGvxe.js} +1 -1
  44. package/dist/{reveal-component-BTBfJ3ju.js → reveal-component-79ez8-D5.js} +4 -4
  45. package/dist/{sequenceDiagram-WJ2MYXX4-CM3aRAe_.js → sequenceDiagram-WJ2MYXX4-CXFMnxGh.js} +1 -1
  46. package/dist/{stateDiagram-D77RDMKH-DOZUMVj8.js → stateDiagram-D77RDMKH-DQeA_CwI.js} +2 -2
  47. package/dist/{stateDiagram-v2-MP3YSRHH-CISYxma6.js → stateDiagram-v2-MP3YSRHH-BxxQucm1.js} +2 -2
  48. package/dist/style.css +1 -1
  49. package/dist/{swimlanes-42K2YHIH-BkSppxp8.js → swimlanes-42K2YHIH-Do6L-cYg.js} +1 -1
  50. package/dist/{swimlanesDiagram-VR7AAH4N-c8G5pGxC.js → swimlanesDiagram-VR7AAH4N-DUzPvSVU.js} +2 -2
  51. package/dist/{timeline-definition-24CTP7MA-CaMQayqK.js → timeline-definition-24CTP7MA-D3c46WM5.js} +1 -1
  52. package/dist/{toDate-BNJf01u1.js → toDate-vIXljnsr.js} +39 -38
  53. package/dist/{vega-component-DG29TAB7.js → vega-component-DTG6zxaY.js} +3 -3
  54. package/dist/{vennDiagram-4TSXK5OY-CF8pfaK3.js → vennDiagram-4TSXK5OY-BSZD8JcZ.js} +1 -1
  55. package/dist/{wardleyDiagram-VM6X3IG4-dGPu39d-.js → wardleyDiagram-VM6X3IG4-48cUWkfA.js} +1 -1
  56. package/dist/{xychartDiagram-S5SC5T6Z-CP6-9q56.js → xychartDiagram-S5SC5T6Z-CswYgYeu.js} +1 -1
  57. package/package.json +1 -1
  58. package/src/components/data-table/__tests__/context-menu.test.ts +176 -0
  59. package/src/components/data-table/__tests__/date-filter-inputs.test.tsx +24 -1
  60. package/src/components/data-table/context-menu.tsx +125 -26
  61. package/src/components/data-table/date-filter-inputs.tsx +15 -42
  62. package/src/components/data-table/utils.ts +18 -0
  63. package/src/components/editor/Output.tsx +5 -2
  64. package/src/components/editor/chrome/panels/error-panel.tsx +1 -0
  65. package/src/components/editor/chrome/wrapper/__tests__/attachment-status.test.tsx +167 -0
  66. package/src/components/editor/chrome/wrapper/footer-item.tsx +32 -24
  67. package/src/components/editor/chrome/wrapper/footer-items/attachment-status.tsx +82 -0
  68. package/src/components/editor/chrome/wrapper/footer.tsx +2 -0
  69. package/src/components/editor/errors/__tests__/send-error-report-button.test.tsx +434 -0
  70. package/src/components/editor/errors/send-error-report-button.tsx +66 -0
  71. package/src/components/editor/output/MarimoErrorOutput.tsx +21 -5
  72. package/src/components/editor/output/MarimoTracebackOutput.tsx +9 -0
  73. package/src/components/editor/output/__tests__/ansi-reduce.test.ts +18 -0
  74. package/src/components/editor/output/ansi-reduce.ts +6 -0
  75. package/src/core/attachments/state.ts +21 -0
  76. package/src/core/cells/__tests__/cells.test.ts +52 -0
  77. package/src/core/cells/__tests__/error-report.test.ts +89 -0
  78. package/src/core/cells/__tests__/session.test.ts +34 -0
  79. package/src/core/cells/error-report.ts +55 -0
  80. package/src/core/cells/session.ts +9 -0
  81. package/src/core/islands/__tests__/bootstrap.test.ts +26 -0
  82. package/src/core/islands/bootstrap.ts +1 -0
  83. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +193 -0
  84. package/src/core/websocket/useMarimoKernelConnection.tsx +9 -0
  85. package/src/utils/__tests__/dates.test.ts +116 -0
  86. package/src/utils/dates.ts +94 -0
  87. package/dist/error-entries-CzoYkIQZ.js +0 -56
@@ -0,0 +1,21 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { components } from "@marimo-team/marimo-api";
4
+ import { atom } from "jotai";
5
+ import { isConnectedAtom } from "@/core/network/connection";
6
+
7
+ export type Attachment = components["schemas"]["Attachment"];
8
+
9
+ export const attachmentsAtom = atom<Attachment[]>([]);
10
+ export const agentAttachmentsAtom = atom((get) => {
11
+ if (!get(isConnectedAtom)) {
12
+ return [];
13
+ }
14
+ return get(attachmentsAtom).filter(
15
+ (attachment) => attachment.kind === "agent",
16
+ );
17
+ });
18
+
19
+ export function attachmentLabel(attachment: Attachment): string {
20
+ return attachment.name || "Agent";
21
+ }
@@ -789,6 +789,58 @@ describe("cell reducer", () => {
789
789
  `);
790
790
  });
791
791
 
792
+ it("keeps error evidence after an edit and a new queued run", () => {
793
+ const code = "raise ValueError('old failure')";
794
+ const output: OutputMessage = {
795
+ channel: "marimo-error",
796
+ mimetype: "application/vnd.marimo+error",
797
+ data: [
798
+ {
799
+ type: "exception",
800
+ msg: "old failure",
801
+ exception_type: "ValueError",
802
+ raising_cell: null,
803
+ traceback: "<pre>ValueError: old failure</pre>",
804
+ },
805
+ ],
806
+ code,
807
+ timestamp: 1,
808
+ };
809
+ actions.handleCellMessage({
810
+ cell_id: firstCellId,
811
+ output,
812
+ console: {
813
+ channel: "stderr",
814
+ mimetype: "application/vnd.marimo+traceback",
815
+ data: "<pre>ValueError: old failure</pre>",
816
+ code,
817
+ timestamp: 1,
818
+ },
819
+ status: "idle",
820
+ stale_inputs: null,
821
+ timestamp: 1,
822
+ });
823
+ actions.updateCellCode({
824
+ cellId: firstCellId,
825
+ code: "print('new code')",
826
+ formattingChange: false,
827
+ });
828
+ actions.prepareForRun({ cellId: firstCellId });
829
+ actions.handleCellMessage({
830
+ cell_id: firstCellId,
831
+ output: null,
832
+ console: null,
833
+ status: "queued",
834
+ stale_inputs: null,
835
+ timestamp: 2,
836
+ });
837
+ const runtime = state.cellRuntime[firstCellId];
838
+ expect(state.cellData[firstCellId].lastCodeRun).toBe("print('new code')");
839
+ expect(runtime.output).toEqual(output);
840
+ expect(runtime.consoleOutputs[0].code).toBe(code);
841
+ expect(runtime.consoleOutputs[0].data).toContain("old failure");
842
+ });
843
+
792
844
  it("can run cell and receive cell messages", () => {
793
845
  // HAPPY PATH
794
846
  /////////////////
@@ -0,0 +1,89 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ // @vitest-environment jsdom
3
+
4
+ import { describe, expect, it } from "vitest";
5
+ import { cellId } from "@/__tests__/branded";
6
+ import type { MarimoError } from "@/core/kernel/messages";
7
+ import { buildErrorReport } from "../error-report";
8
+
9
+ const CELL_ID = cellId("failed-cell");
10
+
11
+ describe("buildErrorReport", () => {
12
+ it("retains every message and complete traceback without display limits", () => {
13
+ const traceback = `Traceback\n${"long frame\n".repeat(10_000)}ValueError: bad <value>`;
14
+ const errors: MarimoError[] = [
15
+ {
16
+ type: "exception",
17
+ msg: "bad <value>",
18
+ exception_type: "ValueError",
19
+ raising_cell: null,
20
+ traceback: `<pre>${traceback.replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</pre>`,
21
+ },
22
+ { type: "syntax", msg: "another error" },
23
+ ];
24
+ expect(buildErrorReport(CELL_ID, { code: "failed()", errors })).toEqual({
25
+ type: "cell-output",
26
+ cell_id: CELL_ID,
27
+ code: "failed()",
28
+ output: {
29
+ type: "error",
30
+ message: "bad <value>\nanother error",
31
+ traceback,
32
+ },
33
+ });
34
+ });
35
+
36
+ it("leaves source unknown for a restored legacy exception", () => {
37
+ expect(
38
+ buildErrorReport(CELL_ID, {
39
+ errors: [
40
+ {
41
+ type: "exception",
42
+ exception_type: "exception",
43
+ msg: "legacy error",
44
+ raising_cell: null,
45
+ traceback: null,
46
+ },
47
+ ],
48
+ }),
49
+ ).toEqual({
50
+ type: "cell-output",
51
+ cell_id: CELL_ID,
52
+ output: { type: "error", message: "legacy error" },
53
+ });
54
+ });
55
+
56
+ it("preserves empty captured code", () => {
57
+ expect(
58
+ buildErrorReport(CELL_ID, {
59
+ code: "",
60
+ errors: [{ type: "syntax", msg: "invalid code" }],
61
+ }),
62
+ ).toEqual({
63
+ type: "cell-output",
64
+ cell_id: CELL_ID,
65
+ code: "",
66
+ output: { type: "error", message: "invalid code" },
67
+ });
68
+ });
69
+
70
+ it("keeps separate structured tracebacks in order", () => {
71
+ const errors: MarimoError[] = ["first", "second"].map((message) => ({
72
+ type: "exception",
73
+ exception_type: "ValueError",
74
+ raising_cell: null,
75
+ msg: message,
76
+ traceback: `<pre>Traceback\nValueError: ${message}</pre>`,
77
+ }));
78
+ expect(buildErrorReport(CELL_ID, { errors })).toEqual({
79
+ type: "cell-output",
80
+ cell_id: CELL_ID,
81
+ output: {
82
+ type: "error",
83
+ message: "first\nsecond",
84
+ traceback:
85
+ "Traceback\nValueError: first\n\nTraceback\nValueError: second",
86
+ },
87
+ });
88
+ });
89
+ });
@@ -183,6 +183,40 @@ describe("notebookStateFromSession", () => {
183
183
  });
184
184
  });
185
185
 
186
+ it("restores matching code from the session", () => {
187
+ const code = "raise ValueError('cached failure')";
188
+ const session = createSession([
189
+ createSessionCell(
190
+ "cell-1",
191
+ [
192
+ {
193
+ type: "error",
194
+ ename: "ValueError",
195
+ evalue: "cached failure",
196
+ traceback: ["Traceback\n", "ValueError: cached failure"],
197
+ code,
198
+ },
199
+ ],
200
+ [
201
+ {
202
+ type: "stream",
203
+ name: "stderr",
204
+ text: "<pre>ValueError: cached failure</pre>",
205
+ mimetype: "application/vnd.marimo+traceback",
206
+ code,
207
+ },
208
+ ],
209
+ ),
210
+ ]);
211
+ const state = notebookStateFromSession(session, null);
212
+ invariant(state, "state is null");
213
+ expect(state.cellRuntime[CELL_1].output).toMatchObject({
214
+ code,
215
+ data: [{ type: "unknown", msg: "cached failure" }],
216
+ });
217
+ expect(state.cellRuntime[CELL_1].consoleOutputs[0].code).toBe(code);
218
+ });
219
+
186
220
  it("handles data output in session cell", () => {
187
221
  const dataOutput = {
188
222
  type: "data" as const,
@@ -0,0 +1,55 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describeError } from "@/core/errors/error-entries";
4
+ import type { MarimoError } from "@/core/kernel/messages";
5
+ import { sanitizeHtml } from "@/plugins/core/sanitize-html";
6
+ import { parseHtmlContent } from "@/utils/dom";
7
+ import type { CellId } from "./ids";
8
+
9
+ export interface CellOutputHandoff<Output> {
10
+ type: "cell-output";
11
+ cell_id: CellId;
12
+ code?: string;
13
+ output: Output;
14
+ }
15
+
16
+ export interface ErrorHandoffOutput {
17
+ type: "error";
18
+ message: string;
19
+ traceback?: string;
20
+ }
21
+
22
+ export function buildErrorReport(
23
+ cellId: CellId,
24
+ evidence: { code?: string } & (
25
+ | { errors: MarimoError[] }
26
+ | { traceback: string }
27
+ ),
28
+ ): CellOutputHandoff<ErrorHandoffOutput> {
29
+ let message: string;
30
+ let traceback: string;
31
+ if ("errors" in evidence) {
32
+ message = evidence.errors.map(describeError).join("\n");
33
+ traceback = evidence.errors
34
+ .flatMap((error) =>
35
+ "traceback" in error && error.traceback
36
+ ? [parseHtmlContent(sanitizeHtml(error.traceback))]
37
+ : [],
38
+ )
39
+ .join("\n\n");
40
+ } else {
41
+ traceback = parseHtmlContent(sanitizeHtml(evidence.traceback));
42
+ message = traceback.split("\n").findLast((line) => line.length > 0) ?? "";
43
+ }
44
+
45
+ return {
46
+ type: "cell-output",
47
+ cell_id: cellId,
48
+ ...(evidence.code === undefined ? {} : { code: evidence.code }),
49
+ output: {
50
+ type: "error",
51
+ message,
52
+ ...(traceback ? { traceback } : {}),
53
+ },
54
+ };
55
+ }
@@ -223,6 +223,9 @@ function createCellRuntimeFromSession(
223
223
  timestamp: DEFAULT_TIMESTAMP,
224
224
  };
225
225
  }
226
+ if (runtimeState.output && primaryOutput.code !== undefined) {
227
+ runtimeState.output.code = primaryOutput.code;
228
+ }
226
229
  }
227
230
 
228
231
  const consoleOutputs = sessionCell.console || [];
@@ -238,6 +241,9 @@ function createCellRuntimeFromSession(
238
241
  data: consoleOutput.data,
239
242
  mimetype: consoleOutput.mimetype,
240
243
  timestamp: DEFAULT_TIMESTAMP,
244
+ ...(consoleOutput.code === undefined
245
+ ? {}
246
+ : { code: consoleOutput.code }),
241
247
  };
242
248
  }
243
249
  // Handle StreamOutput (type: "stream")
@@ -246,6 +252,9 @@ function createCellRuntimeFromSession(
246
252
  data: consoleOutput.text,
247
253
  mimetype: consoleOutput.mimetype ?? "text/plain",
248
254
  timestamp: DEFAULT_TIMESTAMP,
255
+ ...(consoleOutput.code === undefined
256
+ ? {}
257
+ : { code: consoleOutput.code }),
249
258
  };
250
259
  }),
251
260
  };
@@ -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":
@@ -43,6 +43,11 @@ vi.mock("@/core/runtime/config", async () => {
43
43
  import { MockNotebook } from "@/__mocks__/notebook";
44
44
  import { cellId } from "@/__tests__/branded";
45
45
  import { alertAtom, getPackageAlert } from "@/core/alerts/state";
46
+ import {
47
+ type Attachment,
48
+ attachmentsAtom,
49
+ agentAttachmentsAtom,
50
+ } from "@/core/attachments/state";
46
51
  import { notebookAtom } from "@/core/cells/cells";
47
52
  import { AppConfigSchema } from "@/core/config/config-schema";
48
53
  import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
@@ -184,6 +189,46 @@ describe("useMarimoKernelConnection.reconnect()", () => {
184
189
  });
185
190
 
186
191
  describe("useMarimoKernelConnection messages", () => {
192
+ it("replaces attachment snapshots, including the last detach", () => {
193
+ const store = createStore();
194
+ vi.mocked(useConnectionTransport).mockReturnValue(
195
+ makeTransport(WebSocket.OPEN),
196
+ );
197
+ vi.mocked(useRuntimeManager).mockReturnValue(
198
+ makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
199
+ );
200
+ renderConnectionHook(store);
201
+ const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
202
+ const agent: Attachment = {
203
+ id: "a1",
204
+ kind: "agent",
205
+ name: "Pi",
206
+ since: "2026-10-14T00:00:00Z",
207
+ notebook_id: "nb-test",
208
+ identity: "pi-thread",
209
+ };
210
+ const client: Attachment = {
211
+ id: "c1",
212
+ kind: "client",
213
+ name: null,
214
+ since: "2026-10-14T00:00:01Z",
215
+ notebook_id: "nb-test",
216
+ };
217
+ for (const attachments of [[agent, client], [client], []]) {
218
+ act(() =>
219
+ options.onMessage(
220
+ new MessageEvent("message", {
221
+ data: JSON.stringify({
222
+ op: "attachments",
223
+ data: { op: "attachments", attachments },
224
+ }),
225
+ }),
226
+ ),
227
+ );
228
+ expect(store.get(attachmentsAtom)).toEqual(attachments);
229
+ }
230
+ });
231
+
187
232
  it("records completion of the initial run", () => {
188
233
  const store = createStore();
189
234
  vi.mocked(useConnectionTransport).mockClear();
@@ -211,6 +256,101 @@ describe("useMarimoKernelConnection messages", () => {
211
256
  });
212
257
  });
213
258
 
259
+ describe("attachment snapshots across browser connections", () => {
260
+ const agent: Attachment = {
261
+ id: "a1",
262
+ kind: "agent",
263
+ name: "Pi",
264
+ since: "2026-10-14T00:00:00Z",
265
+ notebook_id: "nb-test",
266
+ identity: "pi-thread",
267
+ };
268
+
269
+ function connectedSession() {
270
+ const store = createStore();
271
+ store.set(connectionAtom, { state: ConnectionState.OPEN });
272
+ store.set(attachmentsAtom, [agent]);
273
+ const transport = makeTransport(WebSocket.OPEN);
274
+ vi.mocked(useConnectionTransport).mockReturnValue(transport);
275
+ vi.mocked(useRuntimeManager).mockReturnValue(
276
+ makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
277
+ );
278
+ renderConnectionHook(store);
279
+ const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
280
+ const receive = (data: NotificationPayload["data"]) =>
281
+ act(() => {
282
+ options.onMessage(
283
+ new MessageEvent("message", {
284
+ data: JSON.stringify({ op: data.op, data }),
285
+ }),
286
+ );
287
+ });
288
+ return { store, transport, options, receive };
289
+ }
290
+
291
+ it.each([
292
+ "MARIMO_NO_FILE_KEY",
293
+ "MARIMO_NO_SESSION_ID",
294
+ "MARIMO_NO_SESSION",
295
+ "MARIMO_SHUTDOWN",
296
+ "MARIMO_KERNEL_STARTUP_ERROR",
297
+ "MARIMO_UNAUTHORIZED",
298
+ "MARIMO_KIOSK_NOT_ALLOWED",
299
+ "MARIMO_TRANSPORT_EXHAUSTED",
300
+ ])("clears cached attachments on %s", (reason) => {
301
+ const { store, transport, options } = connectedSession();
302
+ act(() => options.onClose(new CloseEvent("close", { reason })));
303
+ expect(store.get(attachmentsAtom)).toEqual([]);
304
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
305
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.CLOSED);
306
+ expect(transport.reconnect).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it("clears the snapshot on transport error", () => {
310
+ const { store, options } = connectedSession();
311
+ act(() => options.onError(new Event("error")));
312
+ expect(store.get(attachmentsAtom)).toEqual([]);
313
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
314
+ });
315
+
316
+ it("waits for a fresh snapshot after a transient reconnect", async () => {
317
+ const { store, transport, options, receive } = connectedSession();
318
+ act(() => options.onClose(new CloseEvent("close")));
319
+ expect(store.get(attachmentsAtom)).toEqual([]);
320
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.CONNECTING);
321
+ expect(transport.reconnect).toHaveBeenCalledOnce();
322
+
323
+ await act(async () => options.onOpen(new Event("open")));
324
+ receive({ op: "reconnected" });
325
+ expect(store.get(connectionAtom).state).toBe(ConnectionState.OPEN);
326
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
327
+
328
+ const refreshed = { ...agent, id: "a2", name: "Codex" };
329
+ receive({ op: "attachments", attachments: [refreshed] });
330
+ expect(store.get(agentAttachmentsAtom)).toEqual([refreshed]);
331
+ receive({ op: "attachments", attachments: [] });
332
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
333
+ });
334
+
335
+ it("keeps a fresh snapshot hidden until the session is ready", async () => {
336
+ const { store, options, receive } = connectedSession();
337
+ act(() => options.onClose(new CloseEvent("close")));
338
+ await act(async () => options.onOpen(new Event("open")));
339
+ receive({ op: "attachments", attachments: [agent] });
340
+ expect(store.get(attachmentsAtom)).toEqual([agent]);
341
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
342
+ receive({ op: "reconnected" });
343
+ expect(store.get(agentAttachmentsAtom)).toEqual([agent]);
344
+ });
345
+
346
+ it("invalidates an old snapshot when a new transport opens", async () => {
347
+ const { store, options } = connectedSession();
348
+ await act(async () => options.onOpen(new Event("open")));
349
+ expect(store.get(attachmentsAtom)).toEqual([]);
350
+ expect(store.get(agentAttachmentsAtom)).toEqual([]);
351
+ });
352
+ });
353
+
214
354
  it.each(["kernel-ready", "reconnected"])(
215
355
  "waits for %s before marking the session connected",
216
356
  async (op) => {
@@ -261,6 +401,59 @@ it.each(["kernel-ready", "reconnected"])(
261
401
  },
262
402
  );
263
403
 
404
+ it.each([true, false])(
405
+ "keeps attachments only when kernel-ready resumes the session: %s",
406
+ (resumed) => {
407
+ const store = createStore();
408
+ const attachments: Attachment[] = [
409
+ {
410
+ id: "a1",
411
+ kind: "agent",
412
+ name: "Pi",
413
+ since: "2026-10-14T00:00:00Z",
414
+ notebook_id: "nb-test",
415
+ identity: "pi-thread",
416
+ },
417
+ ];
418
+ store.set(attachmentsAtom, attachments);
419
+ vi.mocked(useConnectionTransport).mockReturnValue(
420
+ makeTransport(WebSocket.OPEN),
421
+ );
422
+ vi.mocked(useRuntimeManager).mockReturnValue(
423
+ makeRuntimeManager() as unknown as ReturnType<typeof useRuntimeManager>,
424
+ );
425
+ renderConnectionHook(store);
426
+ const options = vi.mocked(useConnectionTransport).mock.calls.at(-1)![0];
427
+ act(() =>
428
+ options.onMessage(
429
+ new MessageEvent("message", {
430
+ data: JSON.stringify({
431
+ op: "kernel-ready",
432
+ data: {
433
+ op: "kernel-ready",
434
+ cell_ids: [],
435
+ codes: [],
436
+ names: [],
437
+ configs: [],
438
+ layout: null,
439
+ resumed,
440
+ ui_values: {},
441
+ last_executed_code: {},
442
+ last_execution_time: {},
443
+ app_config: { width: "normal" },
444
+ kiosk: false,
445
+ capabilities: { terminal: false },
446
+ auto_instantiated: true,
447
+ consumer_capabilities: { edit: true, interact: true },
448
+ },
449
+ }),
450
+ }),
451
+ ),
452
+ );
453
+ expect(store.get(attachmentsAtom)).toEqual(resumed ? attachments : []);
454
+ },
455
+ );
456
+
264
457
  describe("connection notice", () => {
265
458
  beforeEach(() => vi.useFakeTimers());
266
459
  afterEach(() => vi.useRealTimers());
@@ -5,6 +5,7 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
5
5
  import { useRef } from "react";
6
6
  import { useErrorBoundary } from "react-error-boundary";
7
7
  import { toast } from "@/components/ui/use-toast";
8
+ import { attachmentsAtom } from "@/core/attachments/state";
8
9
  import { getNotebook, useCellActions } from "@/core/cells/cells";
9
10
  import { applyTransactionChanges } from "@/core/cells/document-changes";
10
11
  import { AUTOCOMPLETER } from "@/core/codemirror/completion/Autocompleter";
@@ -236,6 +237,7 @@ export function useMarimoKernelConnection(opts: {
236
237
  } = useAlertActions();
237
238
  const setKioskMode = useSetAtom(kioskModeAtom);
238
239
  const setStableSessionId = useSetAtom(stableSessionIdAtom);
240
+ const setAttachments = useSetAtom(attachmentsAtom);
239
241
  const setCapabilities = useSetAtom(capabilitiesAtom);
240
242
  const runtimeManager = useRuntimeManager();
241
243
  const transportType = useAtomValue(connectionTransportTypeAtom);
@@ -252,6 +254,9 @@ export function useMarimoKernelConnection(opts: {
252
254
  case "reload":
253
255
  reloadSafe();
254
256
  return;
257
+ case "attachments":
258
+ setAttachments(msg.data.attachments);
259
+ return;
255
260
  case "startup-progress": {
256
261
  const { phase } = msg.data;
257
262
  updateStartupProgress(msg.data);
@@ -283,6 +288,7 @@ export function useMarimoKernelConnection(opts: {
283
288
  existingCells,
284
289
  });
285
290
  if (!msg.data.resumed) {
291
+ setAttachments([]);
286
292
  // A freshly started kernel may expose a different environment
287
293
  // (e.g. new env vars); re-run discovery instead of reusing stale
288
294
  // suggestions from the previous kernel instance.
@@ -561,6 +567,7 @@ export function useMarimoKernelConnection(opts: {
561
567
  */
562
568
  onOpen: async () => {
563
569
  updateStartupProgress(null);
570
+ setAttachments([]);
564
571
  // If we are open, we can reset our reconnecting flag.
565
572
  shouldTryReconnecting.current = true;
566
573
  },
@@ -603,6 +610,7 @@ export function useMarimoKernelConnection(opts: {
603
610
  */
604
611
  onClose: (e) => {
605
612
  Logger.warn("WebSocket closed", e.code, e.reason);
613
+ setAttachments([]);
606
614
  const decision = classifyCloseEvent(e);
607
615
  setConnection((previous) => {
608
616
  const status = decision.status;
@@ -641,6 +649,7 @@ export function useMarimoKernelConnection(opts: {
641
649
  */
642
650
  onError: (e) => {
643
651
  Logger.warn("WebSocket error", e);
652
+ setAttachments([]);
644
653
  setConnection({
645
654
  state: ConnectionState.CLOSED,
646
655
  code: WebSocketClosedReason.KERNEL_DISCONNECTED,