@marimo-team/islands 0.25.3-dev322 → 0.25.3-dev348

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 (125) hide show
  1. package/dist/{ConnectedDataExplorerComponent-DkXBUBf3.js → ConnectedDataExplorerComponent-xliyyXNO.js} +16 -16
  2. package/dist/{ErrorBoundary-CYa6lyKz.js → ErrorBoundary-CIimOpqs.js} +7 -7
  3. package/dist/{abnfDiagram-VCTEODGH-BPS-pTU2.js → abnfDiagram-VCTEODGH-Dl9Nrtbf.js} +2 -2
  4. package/dist/{any-language-editor-DIS7IdV1.js → any-language-editor-CBHhdCC0.js} +2 -2
  5. package/dist/{architectureDiagram-5GKGNRK7-DB2MHOb7.js → architectureDiagram-5GKGNRK7-DTg032fQ.js} +1 -1
  6. package/dist/{assertNever-HbUt8DyH.js → assertNever-ie77PjMn.js} +13 -13
  7. package/dist/{chat-ui-85k50jaj.js → chat-ui-BV0qyfru.js} +51 -51
  8. package/dist/{check-CApDh1-n.js → check-DTIv5ZZ3.js} +1 -1
  9. package/dist/{chunk-IMKFNOWR-CjFCedjx.js → chunk-IMKFNOWR-CsFB-2LW.js} +1 -1
  10. package/dist/{chunk-SHT3W25Y-Dptltg0_.js → chunk-SHT3W25Y-ByuJHeBL.js} +1 -1
  11. package/dist/{chunk-SVP7TREG-2jBN760m.js → chunk-SVP7TREG-9oBZhbrr.js} +1 -1
  12. package/dist/{chunk-TICWLB2K-CS8BE-b9.js → chunk-TICWLB2K-Bx4m0cJ1.js} +1 -1
  13. package/dist/{classDiagram-ZZMXUADV-HtQWp-Cm.js → classDiagram-ZZMXUADV-Cms37-C9.js} +2 -2
  14. package/dist/{classDiagram-v2-VYDZK3BY-DA9LETN-.js → classDiagram-v2-VYDZK3BY-qAom4Bi7.js} +2 -2
  15. package/dist/{code-visibility-CGcIJMGj.js → code-visibility-_-8-dvb4.js} +20 -20
  16. package/dist/{common-B-Ng3J3K.js → common-CNXuhphD.js} +620 -620
  17. package/dist/{config-B171m9zn.js → config-CvBnR945.js} +3 -3
  18. package/dist/{copy-icon-k2_Fx5Qv.js → copy-icon-DmtNidSa.js} +2 -2
  19. package/dist/{createReducer-BPPI11pP.js → createReducer-BxBCTvKn.js} +5 -5
  20. package/dist/{cynefinDiagram-5FMLGOSQ-eyocrg_E.js → cynefinDiagram-5FMLGOSQ-BjJZwMEz.js} +1 -1
  21. package/dist/{dagre-GXQ25YYZ-Dtm96Nvx.js → dagre-GXQ25YYZ-Bohu7_09.js} +1 -1
  22. package/dist/{data-grid-overlay-editor-CUD6F0Vj.js → data-grid-overlay-editor--4Gk_rwA.js} +1 -1
  23. package/dist/{diagram-S7CK7UJ4-C09TrdNO.js → diagram-S7CK7UJ4-Chl0n835.js} +1 -1
  24. package/dist/{diagram-UQ7AKVKN-vHUsSML6.js → diagram-UQ7AKVKN-DHx8nzy7.js} +1 -1
  25. package/dist/{diagram-VX7I27RA-Ch4mRgz8.js → diagram-VX7I27RA-CKdpv1U4.js} +1 -1
  26. package/dist/{diagram-Z3DM3KII-DyFQNdAL.js → diagram-Z3DM3KII-sY-sI--2.js} +1 -1
  27. package/dist/{dialog-C2Muh059.js → dialog-Cqu5Hp5U.js} +21 -21
  28. package/dist/{dropdown-menu-BGbNnuFq.js → dropdown-menu-DXEYk99S.js} +707 -707
  29. package/dist/{ebnfDiagram-PWID7BFC-N3SvlrCs.js → ebnfDiagram-PWID7BFC-CD0P2xeH.js} +2 -2
  30. package/dist/{erDiagram-RLTQ6QDP-RR2q2xtd.js → erDiagram-RLTQ6QDP-BaKQuYxC.js} +1 -1
  31. package/dist/{error-banner-3_4Lc93Y.js → error-banner-DUB_qUeL.js} +58 -58
  32. package/dist/{error-entries-BhNAZWU1.js → error-entries-CTb_cxXY.js} +310 -310
  33. package/dist/{feedback-button-DK6zUzAk.js → feedback-button-BaA4r2dl.js} +7 -7
  34. package/dist/{flowDiagram-HODETNUW-nxd7WhlW.js → flowDiagram-HODETNUW-BcCpHh7-.js} +2 -2
  35. package/dist/{formats-aUcw6MrS.js → formats-hF6fETr6.js} +1 -1
  36. package/dist/{glide-data-editor-O-94UN5R.js → glide-data-editor-CRn3KXyR.js} +3129 -3119
  37. package/dist/{glide-utils-D4UP8TGv.js → glide-utils-Bnd_k48H.js} +2 -2
  38. package/dist/{globals-BVjYePCn.js → globals-_FmD97Ui.js} +3 -3
  39. package/dist/{html-to-image-DOOtP-4-.js → html-to-image-Bbl0PeC7.js} +478 -478
  40. package/dist/{infoDiagram-27XIBGKW-CUYXQKGf.js → infoDiagram-27XIBGKW-DWltseIa.js} +1 -1
  41. package/dist/{ishikawaDiagram-5VMMS53U-v6gsCrlK.js → ishikawaDiagram-5VMMS53U-DHgepBPo.js} +1 -1
  42. package/dist/{kanban-definition-UXKFOSKX-z236-0Ok.js → kanban-definition-UXKFOSKX-B2_BIjBq.js} +1 -1
  43. package/dist/main.js +6712 -6590
  44. package/dist/{mermaid-C7lQ_2LJ.js → mermaid--2K0cmt9.js} +34 -34
  45. package/dist/{mindmap-definition-YA3MSWOX--pZF7OCY.js → mindmap-definition-YA3MSWOX-DNebycIP.js} +1 -1
  46. package/dist/{number-overlay-editor-jkzzCN4e.js → number-overlay-editor-Bcg25qrp.js} +1 -1
  47. package/dist/{pegDiagram-XKGWAZYB-Bgw0Zz_6.js → pegDiagram-XKGWAZYB-CH4U3II-.js} +2 -2
  48. package/dist/{pencil-RXFY-Ek8.js → pencil-MMnw1YQh.js} +1 -1
  49. package/dist/{pieDiagram-E7YTZNPT-B27tDYvK.js → pieDiagram-E7YTZNPT-pfcrdUCa.js} +1 -1
  50. package/dist/{process-output-CiX_Dl4L.js → process-output-Dw_sI4Xp.js} +4 -4
  51. package/dist/{railroadDiagram-O6MQD6OU-CBjPhGpj.js → railroadDiagram-O6MQD6OU-BZKpAbNG.js} +2 -2
  52. package/dist/{requirementDiagram-BXWQKSXE-DDDrLEw9.js → requirementDiagram-BXWQKSXE-DnyfIHe3.js} +1 -1
  53. package/dist/{reveal-component-CvPmNI5C.js → reveal-component-BVC491am.js} +18 -18
  54. package/dist/{select-Cv6lR39D.js → select-suaAxFuo.js} +286 -286
  55. package/dist/{sequenceDiagram-WJ2MYXX4-DQPaMygQ.js → sequenceDiagram-WJ2MYXX4-BeYuWX7N.js} +1 -1
  56. package/dist/{spec-BoCLWa-M.js → spec-BQNx2-D7.js} +4 -4
  57. package/dist/{stateDiagram-D77RDMKH-BDoDVVnq.js → stateDiagram-D77RDMKH-B8FxwAM2.js} +2 -2
  58. package/dist/{stateDiagram-v2-MP3YSRHH-KNko46uJ.js → stateDiagram-v2-MP3YSRHH-CzWLBqm1.js} +2 -2
  59. package/dist/{static-state-DgdOadIH.js → static-state-Al_hmf1n.js} +6 -6
  60. package/dist/style.css +1 -1
  61. package/dist/{swimlanes-42K2YHIH-boFjOuiI.js → swimlanes-42K2YHIH-CzKmGMts.js} +1 -1
  62. package/dist/{swimlanesDiagram-VR7AAH4N-BC4FLNwY.js → swimlanesDiagram-VR7AAH4N-DnWIUN_9.js} +2 -2
  63. package/dist/{timeline-definition-24CTP7MA-CLVPYA-v.js → timeline-definition-24CTP7MA-Czf2QN2R.js} +1 -1
  64. package/dist/{toast-CUuX7uXg.js → toast-CsJa1nGj.js} +66 -66
  65. package/dist/{tooltip-DBv8QUvS.js → tooltip-BVbJPkcl.js} +49 -49
  66. package/dist/{use-restore-focus-BxJh5Zfa.js → use-restore-focus-tPmARo1Q.js} +1035 -1022
  67. package/dist/{useDateFormatter-2k4mHRBq.js → useDateFormatter-B4ygYuUU.js} +2 -2
  68. package/dist/{useDeepCompareMemoize-Cb2GRQIS.js → useDeepCompareMemoize-Bcu0DkCS.js} +1 -1
  69. package/dist/{useLifecycle-B2fV3c8N.js → useLifecycle-MTdtnR3-.js} +1 -1
  70. package/dist/{vega-component-do3stf0K.js → vega-component-BhBbP4S7.js} +6 -6
  71. package/dist/{vennDiagram-4TSXK5OY-BLq7sNjb.js → vennDiagram-4TSXK5OY-BrHqWB1U.js} +1 -1
  72. package/dist/{wardleyDiagram-VM6X3IG4-oyJEWWtb.js → wardleyDiagram-VM6X3IG4-Cms1K_3v.js} +1 -1
  73. package/dist/{xychartDiagram-S5SC5T6Z-DMjWgQPm.js → xychartDiagram-S5SC5T6Z-_kUiODzS.js} +1 -1
  74. package/package.json +1 -1
  75. package/src/__mocks__/requests.ts +0 -2
  76. package/src/components/chat/__tests__/notebook-context.test.ts +199 -0
  77. package/src/components/chat/chat-panel.tsx +33 -15
  78. package/src/components/chat/notebook-context.ts +80 -0
  79. package/src/components/editor/cell/StopButton.tsx +3 -3
  80. package/src/components/editor/cell/cell-context-menu.tsx +18 -2
  81. package/src/components/editor/cell/code/__tests__/language-toggle.test.tsx +38 -1
  82. package/src/components/editor/cell/code/cell-editor.tsx +2 -1
  83. package/src/components/editor/cell/code/language-toggle.tsx +6 -4
  84. package/src/components/editor/chrome/wrapper/__tests__/document-sync-status.test.tsx +9 -6
  85. package/src/components/slides/slide-cell-view.tsx +2 -1
  86. package/src/components/ui/__tests__/fullscreen.test.tsx +56 -1
  87. package/src/components/ui/fullscreen.tsx +18 -2
  88. package/src/components/ui/slider.tsx +6 -2
  89. package/src/core/codemirror/copilot/getCodes.ts +1 -1
  90. package/src/core/edit-app.tsx +5 -4
  91. package/src/core/executions/__tests__/input.test.ts +1 -1
  92. package/src/core/executions/__tests__/interrupt.test.ts +200 -0
  93. package/src/core/executions/__tests__/run.test.ts +3 -3
  94. package/src/core/executions/index.ts +1 -0
  95. package/src/core/executions/interrupt.ts +49 -0
  96. package/src/core/islands/bridge.ts +0 -2
  97. package/src/core/network/requests-network.ts +0 -15
  98. package/src/core/network/requests-static.ts +0 -2
  99. package/src/core/network/requests-toasting.tsx +0 -2
  100. package/src/core/network/types.ts +0 -3
  101. package/src/core/notebook/document/__tests__/model.test.ts +4 -4
  102. package/src/core/notebook/document/__tests__/test-host.test.ts +6 -6
  103. package/src/core/notebook/document/__tests__/test-host.ts +42 -46
  104. package/src/core/notebook/document/__tests__/test-notebook.ts +5 -4
  105. package/src/core/notebook/document/sync.ts +12 -5
  106. package/src/core/notebook/replica/__tests__/fixtures.ts +8 -0
  107. package/src/core/notebook/replica/backend.ts +1 -1
  108. package/src/core/saving/__tests__/document.test.tsx +1 -1
  109. package/src/core/transports/__tests__/static.test.ts +1 -1
  110. package/src/core/transports/__tests__/wasm.test.ts +1 -1
  111. package/src/core/transports/static.ts +8 -2
  112. package/src/core/transports/wasm.ts +2 -4
  113. package/src/core/wasm/__tests__/bridge.test.ts +170 -26
  114. package/src/core/wasm/bridge.ts +64 -19
  115. package/src/core/wasm/worker/types.ts +0 -1
  116. package/src/plugins/impl/DataEditorPlugin.tsx +32 -21
  117. package/src/plugins/impl/SliderPlugin.tsx +35 -3
  118. package/src/plugins/impl/__test__/slider-labels.test.tsx +30 -0
  119. package/src/plugins/impl/__tests__/DataEditorPlugin.test.ts +103 -12
  120. package/src/plugins/impl/__tests__/SliderPlugin.test.tsx +40 -0
  121. package/src/plugins/impl/data-editor/__tests__/glide-data-editor.test.tsx +159 -4
  122. package/src/plugins/impl/data-editor/glide-data-editor.tsx +30 -10
  123. package/src/plugins/impl/data-editor/glide-portal.tsx +5 -3
  124. package/src/plugins/layout/AccordionPlugin.tsx +2 -1
  125. package/src/plugins/layout/__tests__/AccordionPlugin.test.tsx +21 -0
@@ -1,7 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { createHash } from "node:crypto";
4
- import type { Result } from "@marimo-team/client";
4
+ import type { ProblemOf, ResultOf } from "@marimo-team/client";
5
5
  import * as Protocol from "@marimo-team/client/protocol";
6
6
  import type { Document } from "../../replica/resources/document";
7
7
 
@@ -12,7 +12,7 @@ export interface EditRequest {
12
12
  }
13
13
 
14
14
  interface Outcome {
15
- readonly result: Result<Protocol.EditResponseOutput>;
15
+ readonly result: ResultOf<"notebook.edit">;
16
16
  /** Only a new commit produces a stream update; replaying a receipt does not. */
17
17
  readonly update?: Document;
18
18
  }
@@ -52,25 +52,8 @@ function record(
52
52
  };
53
53
  }
54
54
 
55
- function rejected(
56
- status: number,
57
- kind: string,
58
- detail: string,
59
- edit?: Protocol.Problem["edit"],
60
- ): Outcome {
61
- return {
62
- result: {
63
- ok: false,
64
- status,
65
- problem: {
66
- type: `tag:marimo.io,2026:host/${kind}`,
67
- title: kind === "conflict" ? "Conflict" : "Invalid request",
68
- detail,
69
- status,
70
- ...(edit === undefined ? {} : { edit }),
71
- },
72
- },
73
- };
55
+ function rejected(problem: ProblemOf<"notebook.edit">): Outcome {
56
+ return { result: { ok: false, status: problem.status, problem } };
74
57
  }
75
58
 
76
59
  /** An independent implementation of the document edit protocol, without client draft logic. */
@@ -80,7 +63,7 @@ export function createTestHost(initial: Document) {
80
63
  string,
81
64
  {
82
65
  fingerprint: string;
83
- result: Result<Protocol.EditResponseOutput>;
66
+ result: ResultOf<"notebook.edit">;
84
67
  expiresAt: number;
85
68
  }
86
69
  >();
@@ -92,9 +75,13 @@ export function createTestHost(initial: Document) {
92
75
  const version = current.revision.version + 1;
93
76
  const matches = (id: Protocol.CellId, etag: string) =>
94
77
  etag === "*" || current.cells.get(id)?.etag === etag;
95
- const conflict = (change: number, id?: Protocol.CellId) =>
96
- rejected(409, "conflict", `Edit conflict at change ${change}`, {
97
- change,
78
+ const conflict = (index: number, id?: Protocol.CellId) =>
79
+ rejected({
80
+ type: "urn:marimo:problem:precondition-failed",
81
+ title: "Precondition failed",
82
+ status: 412,
83
+ detail: `Edit conflict at change ${index}`,
84
+ index,
98
85
  cell: id === undefined ? null : (current.cells.get(id) ?? null),
99
86
  document: current.revision,
100
87
  });
@@ -108,19 +95,21 @@ export function createTestHost(initial: Document) {
108
95
  return conflict(index, id);
109
96
  }
110
97
  if (change.after !== undefined && change.before !== undefined) {
111
- return rejected(
112
- 400,
113
- "invalid-request",
114
- "A cell cannot name both before and after",
115
- );
98
+ return rejected({
99
+ type: "urn:marimo:problem:invalid-request",
100
+ title: "Invalid request",
101
+ status: 400,
102
+ detail: "A cell cannot name both before and after",
103
+ });
116
104
  }
117
105
  const anchor = change.after ?? change.before;
118
106
  if (anchor !== undefined && !order.includes(anchor)) {
119
- return rejected(
120
- 400,
121
- "invalid-request",
122
- "The placement anchor does not exist",
123
- );
107
+ return rejected({
108
+ type: "urn:marimo:problem:invalid-request",
109
+ title: "Invalid request",
110
+ status: 400,
111
+ detail: "The placement anchor does not exist",
112
+ });
124
113
  }
125
114
  const position =
126
115
  anchor === undefined
@@ -157,11 +146,12 @@ export function createTestHost(initial: Document) {
157
146
  new Set(change.order).size !== cells.size ||
158
147
  change.order.some((id) => !cells.has(id))
159
148
  ) {
160
- return rejected(
161
- 400,
162
- "invalid-request",
163
- "Order must name every cell exactly once",
164
- );
149
+ return rejected({
150
+ type: "urn:marimo:problem:invalid-request",
151
+ title: "Invalid request",
152
+ status: 400,
153
+ detail: "Order must name every cell exactly once",
154
+ });
165
155
  }
166
156
  order = [...change.order];
167
157
  break;
@@ -226,16 +216,22 @@ export function createTestHost(initial: Document) {
226
216
  if (receipt !== undefined && Date.now() < receipt.expiresAt) {
227
217
  return receipt.fingerprint === fingerprint
228
218
  ? { result: receipt.result }
229
- : rejected(
230
- 409,
231
- "conflict",
232
- "Idempotency key was used for another request",
233
- );
219
+ : rejected({
220
+ type: "urn:marimo:problem:conflict",
221
+ title: "Conflict",
222
+ status: 409,
223
+ detail: "Idempotency key was used for another request",
224
+ });
234
225
  }
235
226
  const outcome =
236
227
  request.notebookId === current.revision.notebook_id
237
228
  ? apply(request.changes)
238
- : rejected(404, "not-found", "Notebook does not exist");
229
+ : rejected({
230
+ type: "urn:marimo:problem:not-found",
231
+ title: "Not found",
232
+ status: 404,
233
+ detail: "Notebook does not exist",
234
+ });
239
235
  receipts.set(request.idempotencyKey, {
240
236
  fingerprint,
241
237
  result: outcome.result,
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { Result } from "@marimo-team/client";
3
+ import type { ResultOf } from "@marimo-team/client";
4
4
  import type * as Protocol from "@marimo-team/client/protocol";
5
5
  import { atom, type createStore } from "jotai/vanilla";
6
6
  import { vi } from "vitest";
@@ -41,8 +41,8 @@ export function createTestNotebook(
41
41
  "The test host answers edits that carry a key and a signal",
42
42
  );
43
43
  signal.throwIfAborted();
44
- const reply = new Deferred<Result<Protocol.EditResponseOutput>>();
45
- let outcome: Result<Protocol.EditResponseOutput> | undefined;
44
+ const reply = new Deferred<ResultOf<"notebook.edit">>();
45
+ let outcome: ResultOf<"notebook.edit"> | undefined;
46
46
  const abort = () => reply.reject(signal.reason);
47
47
  const finish = () => signal.removeEventListener("abort", abort);
48
48
  signal.addEventListener("abort", abort, { once: true });
@@ -112,8 +112,9 @@ export function createTestNotebook(
112
112
  kind: "put_cell",
113
113
  base_etag: cell.etag,
114
114
  cell: {
115
- ...cell,
115
+ id,
116
116
  code: cell.code,
117
+ name: cell.name,
117
118
  ...patch,
118
119
  config: { ...cell.config, ...patch.config },
119
120
  },
@@ -1,6 +1,10 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { idempotencyKey, type Result } from "@marimo-team/client";
3
+ import {
4
+ type ProblemOf,
5
+ idempotencyKey,
6
+ type ResultOf,
7
+ } from "@marimo-team/client";
4
8
  import type * as Protocol from "@marimo-team/client/protocol";
5
9
  import { type Atom, atom, type createStore } from "jotai/vanilla";
6
10
  import type { Document, NotebookBackend } from "../replica";
@@ -18,7 +22,7 @@ export type DocumentSyncStatus =
18
22
  | {
19
23
  readonly status: "error";
20
24
  readonly error: Error;
21
- readonly problem?: Protocol.Problem;
25
+ readonly problem?: ProblemOf<"notebook.edit">;
22
26
  readonly retryable: boolean;
23
27
  };
24
28
 
@@ -30,7 +34,7 @@ interface Submission {
30
34
  readonly startedAt: number;
31
35
  readonly controller: AbortController;
32
36
  response?: Protocol.Document;
33
- problem?: Protocol.Problem;
37
+ problem?: ProblemOf<"notebook.edit">;
34
38
  }
35
39
 
36
40
  // The marimo host retains receipts for five minutes. Stop well before that,
@@ -167,7 +171,7 @@ export function createDocumentSync({
167
171
  () => controller.abort(new Error("The edit request timed out")),
168
172
  REQUEST_TIMEOUT_MS,
169
173
  );
170
- let result: Result<Protocol.EditResponseOutput> | undefined;
174
+ let result: ResultOf<"notebook.edit"> | undefined;
171
175
  try {
172
176
  signal.throwIfAborted();
173
177
  request.controller.signal.throwIfAborted();
@@ -336,7 +340,10 @@ export function createDocumentSync({
336
340
  }
337
341
  if (active?.problem) {
338
342
  const current = store.get(source);
339
- const rejectedAt = active.problem.edit?.document.version;
343
+ const rejectedAt =
344
+ active.problem.type === "urn:marimo:problem:precondition-failed"
345
+ ? active.problem.document.version
346
+ : undefined;
340
347
  if (
341
348
  !current ||
342
349
  current.revision.notebook_id !== active.submission.notebookId ||
@@ -30,6 +30,11 @@ export type FakeInput = (
30
30
  ...args: Parameters<Commands["input"]>
31
31
  ) => ReturnType<Commands["input"]>;
32
32
 
33
+ export type FakeInterrupt = (
34
+ notebookId: Protocol.NotebookId,
35
+ ...args: Parameters<Commands["interrupt"]>
36
+ ) => ReturnType<Commands["interrupt"]>;
37
+
33
38
  /** A backend over `transport` whose commands go to the given fakes. */
34
39
  export function fakeBackend({
35
40
  transport = async () => undefined,
@@ -37,6 +42,7 @@ export function fakeBackend({
37
42
  save = vi.fn<FakeSave>(),
38
43
  run = vi.fn<FakeRun>(),
39
44
  input = vi.fn<FakeInput>(),
45
+ interrupt = vi.fn<FakeInterrupt>(),
40
46
  checkHealth,
41
47
  }: {
42
48
  transport?: NotebookTransport;
@@ -44,6 +50,7 @@ export function fakeBackend({
44
50
  save?: FakeSave;
45
51
  run?: FakeRun;
46
52
  input?: FakeInput;
53
+ interrupt?: FakeInterrupt;
47
54
  checkHealth?: () => Promise<boolean>;
48
55
  } = {}): NotebookBackend {
49
56
  return {
@@ -53,6 +60,7 @@ export function fakeBackend({
53
60
  save: (...args) => save(notebookId, ...args),
54
61
  run: (...args) => run(notebookId, ...args),
55
62
  input: (...args) => input(notebookId, ...args),
63
+ interrupt: (...args) => interrupt(notebookId, ...args),
56
64
  }),
57
65
  checkHealth,
58
66
  };
@@ -15,7 +15,7 @@ export interface NotebookBackend {
15
15
  */
16
16
  notebook(
17
17
  id: NotebookId,
18
- ): Pick<NotebookClient, "edit" | "save" | "run" | "input">;
18
+ ): Pick<NotebookClient, "edit" | "save" | "run" | "input" | "interrupt">;
19
19
  /** Whether the host is reachable before an explicit reconnect. Absent means always. */
20
20
  checkHealth?(): Promise<boolean>;
21
21
  }
@@ -102,7 +102,7 @@ describe("useNotebookDocumentSave", () => {
102
102
  ok: false,
103
103
  status: 409,
104
104
  problem: {
105
- type: "about:blank",
105
+ type: "urn:marimo:problem:conflict",
106
106
  title: "Conflict",
107
107
  status: 409,
108
108
  detail: "storage full",
@@ -199,7 +199,7 @@ describe("static notebook backend", () => {
199
199
  ok: false,
200
200
  status: 501,
201
201
  problem: {
202
- type: "tag:marimo.io,2026:host/unsupported-operation",
202
+ type: "urn:marimo:problem:unsupported-operation",
203
203
  title: "Unsupported operation",
204
204
  status: 501,
205
205
  detail: "Static notebooks do not support mutations",
@@ -118,7 +118,7 @@ describe("WASM notebook backend", () => {
118
118
  body: operation === "edit" ? { changes: [] } : {},
119
119
  });
120
120
  const problem = {
121
- type: "tag:marimo.io,2026:host/forbidden",
121
+ type: "urn:marimo:problem:forbidden",
122
122
  title: "Forbidden",
123
123
  status: 403,
124
124
  };
@@ -2,6 +2,7 @@
2
2
 
3
3
  /** A backend that serves a document snapshot without a host. */
4
4
 
5
+ import type { Result } from "@marimo-team/client";
5
6
  import type * as Protocol from "@marimo-team/client/protocol";
6
7
  import type { NotebookBackend } from "../notebook/replica";
7
8
 
@@ -150,13 +151,17 @@ export function createStaticNotebookBackend(
150
151
  }
151
152
  }),
152
153
  notebook: () => {
153
- const unsupported = async (options?: { signal?: AbortSignal }) => {
154
+ const unsupported = async (options?: {
155
+ signal?: AbortSignal;
156
+ }): Promise<
157
+ Result<never, Protocol.UnsupportedOperationProblemOutput>
158
+ > => {
154
159
  options?.signal?.throwIfAborted();
155
160
  return {
156
161
  ok: false as const,
157
162
  status: 501,
158
163
  problem: {
159
- type: "tag:marimo.io,2026:host/unsupported-operation",
164
+ type: "urn:marimo:problem:unsupported-operation",
160
165
  title: "Unsupported operation",
161
166
  status: 501,
162
167
  detail: "Static notebooks do not support mutations",
@@ -168,6 +173,7 @@ export function createStaticNotebookBackend(
168
173
  save: unsupported,
169
174
  run: unsupported,
170
175
  input: (_inputId, _reply, options) => unsupported(options),
176
+ interrupt: unsupported,
171
177
  };
172
178
  },
173
179
  };
@@ -38,9 +38,7 @@ function abortable<T>(
38
38
  });
39
39
  }
40
40
 
41
- export function createWasmNotebookBackend(
42
- bridge: WasmNotebookBridge,
43
- ): NotebookBackend {
41
+ export function createWasmNotebookBackend(bridge: WasmNotebookBridge) {
44
42
  return {
45
43
  transport: async ({ cursor, signal }, { open, message }) => {
46
44
  if (signal.aborted) {
@@ -93,5 +91,5 @@ export function createWasmNotebookBackend(
93
91
  },
94
92
  notebook: (notebookId) =>
95
93
  NotebookClient.unstable_over((call) => bridge.call(call), { notebookId }),
96
- };
94
+ } satisfies NotebookBackend;
97
95
  }
@@ -16,6 +16,7 @@ const {
16
16
  rpcListeners,
17
17
  mockNotebookSubscribe,
18
18
  mockNotebookUnsubscribe,
19
+ workerState,
19
20
  } = vi.hoisted(() => ({
20
21
  mockBridge: vi.fn(),
21
22
  mockNotebookReadFile: vi.fn(),
@@ -36,10 +37,11 @@ const {
36
37
  >,
37
38
  mockNotebookSubscribe: vi.fn(),
38
39
  mockNotebookUnsubscribe: vi.fn(),
40
+ workerState: { interruptBuffer: undefined as Uint8Array | undefined },
39
41
  }));
40
42
 
41
43
  // Mock browser globals before any imports
42
- vi.stubGlobal("crossOriginIsolated", false);
44
+ vi.stubGlobal("crossOriginIsolated", true);
43
45
  vi.stubGlobal(
44
46
  "Worker",
45
47
  // vitest 4 requires a `function` (not an arrow) for mocks called with `new`
@@ -74,6 +76,9 @@ vi.mock("@/core/wasm/rpc", () => ({
74
76
  notebookUnsubscribe: mockNotebookUnsubscribe,
75
77
  notebookCall: mockNotebookCall,
76
78
  setNotebookLayout: mockSetNotebookLayout,
79
+ setInterruptBuffer: (buffer: Uint8Array) => {
80
+ workerState.interruptBuffer = buffer;
81
+ },
77
82
  },
78
83
  },
79
84
  addMessageListener: (
@@ -102,7 +107,6 @@ vi.mock("@/core/wasm/store", () => ({
102
107
  // Import after all mocks are set up
103
108
  import { store } from "@/core/state/jotai";
104
109
  import { initialModeAtom } from "@/core/mode";
105
- import type { NotebookClient } from "@marimo-team/client";
106
110
  import type { NotebookReplica } from "@/core/notebook/replica";
107
111
  import { activeNotebookReplicaAtom } from "@/core/session/active-notebook";
108
112
  import { fallbackFileStore, notebookFileStore } from "../store";
@@ -262,7 +266,7 @@ it("cancels a pending notebook subscription before its RPC response", async () =
262
266
  }
263
267
  });
264
268
 
265
- it("raises the interrupt buffer for a running run before the worker takes the call", async () => {
269
+ it("raises the interrupt buffer for a cancelled running run before the worker takes the call", async () => {
266
270
  vi.clearAllMocks();
267
271
  store.set(initialModeAtom, "read");
268
272
  rpcListeners.initialized();
@@ -284,35 +288,117 @@ it("raises the interrupt buffer for a running run before the worker takes the ca
284
288
  latest: new Map(),
285
289
  }),
286
290
  }) as unknown as NotebookReplica;
287
- const interrupt = vi
288
- .spyOn(PyodideBridge.INSTANCE, "sendInterrupt")
289
- .mockResolvedValue(null);
291
+ const buffer = workerState.interruptBuffer!;
292
+ buffer[0] = 0;
290
293
  mockNotebookCall.mockResolvedValue({ status: 202, body: run });
294
+ mockNotebookCall.mockImplementationOnce(async () => {
295
+ expect(buffer[0]).toBe(2);
296
+ return { status: 202, body: run };
297
+ });
291
298
  try {
292
299
  const backend = await PyodideBridge.INSTANCE.createNotebookBackend();
293
- // The backend narrows the client to editing until the editor runs through the host.
294
- const notebook = backend.notebook("n1") as unknown as NotebookClient;
300
+ const notebook = backend.notebook("n1");
295
301
 
296
302
  store.set(activeNotebookReplicaAtom, replicaWith("running"));
297
- expect((await notebook.interrupt("run-1")).ok).toBe(true);
298
- expect(interrupt).toHaveBeenCalledOnce();
303
+ expect((await notebook.cancel({ runId: "run-1" })).ok).toBe(true);
304
+ expect(buffer[0]).toBe(2);
299
305
  expect(mockNotebookCall).toHaveBeenCalledOnce();
300
- expect(interrupt.mock.invocationCallOrder[0]).toBeLessThan(
301
- mockNotebookCall.mock.invocationCallOrder[0],
302
- );
303
306
 
307
+ buffer[0] = 0;
304
308
  store.set(activeNotebookReplicaAtom, replicaWith("queued"));
305
- expect((await notebook.interrupt("run-1")).ok).toBe(true);
306
- expect(interrupt).toHaveBeenCalledOnce();
309
+ expect((await notebook.cancel({ runId: "run-1" })).ok).toBe(true);
310
+ expect(buffer[0]).toBe(0);
307
311
  expect(mockNotebookCall).toHaveBeenCalledTimes(2);
308
312
  } finally {
309
- interrupt.mockRestore();
313
+ buffer[0] = 0;
310
314
  store.set(activeNotebookReplicaAtom, null);
311
315
  store.set(initialModeAtom, undefined);
312
316
  }
313
317
  });
314
318
 
315
- it("raises the interrupt buffer once a run interrupted while queued starts", async () => {
319
+ it.each(["running", "completed", null])(
320
+ "signals runtime work before the RPC with recorded run status %s",
321
+ async (status) => {
322
+ vi.clearAllMocks();
323
+ store.set(initialModeAtom, "read");
324
+ rpcListeners.initialized();
325
+ const run = {
326
+ id: "run-1",
327
+ notebook_id: "n1",
328
+ runtime_id: "r1",
329
+ cells: [],
330
+ status: "running",
331
+ outcome: null,
332
+ started_at: "2026-10-01T09:20:11Z",
333
+ completed_at: null,
334
+ };
335
+ const replicaWith = (status: string) =>
336
+ ({
337
+ executionsAtom: atom({
338
+ runs: new Map([["run-1", { ...run, status }]]),
339
+ executions: new Map(),
340
+ latest: new Map(),
341
+ }),
342
+ }) as unknown as NotebookReplica;
343
+ const buffer = workerState.interruptBuffer!;
344
+ buffer[0] = 0;
345
+ const accepted = {
346
+ status: 202,
347
+ body: {
348
+ id: "r1",
349
+ notebook_id: "n1",
350
+ status: "running",
351
+ message: null,
352
+ marimo_version: null,
353
+ started_at: "2026-10-01T09:20:11Z",
354
+ },
355
+ };
356
+ mockNotebookCall.mockImplementation(async () => {
357
+ expect(buffer[0]).toBe(2);
358
+ return accepted;
359
+ });
360
+ try {
361
+ const backend = await PyodideBridge.INSTANCE.createNotebookBackend();
362
+ const notebook = backend.notebook("n1");
363
+
364
+ store.set(
365
+ activeNotebookReplicaAtom,
366
+ status === null ? null : replicaWith(status),
367
+ );
368
+ expect((await notebook.interrupt()).ok).toBe(true);
369
+ expect(buffer[0]).toBe(2);
370
+
371
+ // Nothing holds the worker, so it takes the call and ends queued runs.
372
+ buffer[0] = 0;
373
+ store.set(activeNotebookReplicaAtom, replicaWith("queued"));
374
+ expect((await notebook.interrupt()).ok).toBe(true);
375
+ expect(buffer[0]).toBe(2);
376
+ expect(mockNotebookCall).toHaveBeenCalledTimes(2);
377
+ buffer[0] = 0;
378
+
379
+ // The worker took the queued run before the interrupt reached it.
380
+ const deliver = (status: string) =>
381
+ rpcListeners.notebookEvent({
382
+ id: "sub",
383
+ event: JSON.stringify({
384
+ event: "run",
385
+ data: { run: { ...run, status } },
386
+ }),
387
+ });
388
+ deliver("running");
389
+ expect(buffer[0]).toBe(2);
390
+ buffer[0] = 0;
391
+ deliver("completed");
392
+ expect(buffer[0]).toBe(0);
393
+ } finally {
394
+ buffer[0] = 0;
395
+ store.set(activeNotebookReplicaAtom, null);
396
+ store.set(initialModeAtom, undefined);
397
+ }
398
+ },
399
+ );
400
+
401
+ it("raises the interrupt buffer once a run cancelled while queued starts", async () => {
316
402
  vi.clearAllMocks();
317
403
  store.set(initialModeAtom, "read");
318
404
  rpcListeners.initialized();
@@ -333,9 +419,8 @@ it("raises the interrupt buffer once a run interrupted while queued starts", asy
333
419
  latest: new Map(),
334
420
  }),
335
421
  } as unknown as NotebookReplica);
336
- const interrupt = vi
337
- .spyOn(PyodideBridge.INSTANCE, "sendInterrupt")
338
- .mockResolvedValue(null);
422
+ const buffer = workerState.interruptBuffer!;
423
+ buffer[0] = 0;
339
424
  mockNotebookCall.mockResolvedValue({ status: 202, body: run });
340
425
  const deliver = (status: string) =>
341
426
  rpcListeners.notebookEvent({
@@ -347,18 +432,19 @@ it("raises the interrupt buffer once a run interrupted while queued starts", asy
347
432
  });
348
433
  try {
349
434
  const backend = await PyodideBridge.INSTANCE.createNotebookBackend();
350
- const notebook = backend.notebook("n1") as unknown as NotebookClient;
351
- expect((await notebook.interrupt("run-1")).ok).toBe(true);
352
- expect(interrupt).not.toHaveBeenCalled();
435
+ const notebook = backend.notebook("n1");
436
+ expect((await notebook.cancel({ runId: "run-1" })).ok).toBe(true);
437
+ expect(buffer[0]).toBe(0);
353
438
 
354
439
  // The worker took the run before the cancellation reached it.
355
440
  deliver("running");
356
- expect(interrupt).toHaveBeenCalledOnce();
441
+ expect(buffer[0]).toBe(2);
442
+ buffer[0] = 0;
357
443
  deliver("running");
358
444
  deliver("completed");
359
- expect(interrupt).toHaveBeenCalledOnce();
445
+ expect(buffer[0]).toBe(0);
360
446
  } finally {
361
- interrupt.mockRestore();
447
+ buffer[0] = 0;
362
448
  store.set(activeNotebookReplicaAtom, null);
363
449
  store.set(initialModeAtom, undefined);
364
450
  }
@@ -388,3 +474,61 @@ describe("getWasmWorkerName", () => {
388
474
  expect(getWasmWorkerName()).toBe("marimo");
389
475
  });
390
476
  });
477
+
478
+ it("signals an interrupt once across pending, completed, and lost-reply retries", async () => {
479
+ vi.clearAllMocks();
480
+ store.set(initialModeAtom, "read");
481
+ rpcListeners.initialized();
482
+ const buffer = workerState.interruptBuffer!;
483
+ buffer[0] = 0;
484
+ const clock = vi.spyOn(performance, "now").mockReturnValue(0);
485
+ const pending = new Deferred<unknown>();
486
+ const accepted = {
487
+ status: 202,
488
+ body: {
489
+ id: "r1",
490
+ notebook_id: "n1",
491
+ status: "running",
492
+ message: null,
493
+ marimo_version: null,
494
+ started_at: "2026-10-01T09:20:11Z",
495
+ },
496
+ };
497
+ mockNotebookCall.mockImplementation(() => pending.promise);
498
+ try {
499
+ const backend = await PyodideBridge.INSTANCE.createNotebookBackend();
500
+ const notebook = backend.notebook("n1");
501
+ const options = { idempotencyKey: "retry-interrupt" };
502
+ const first = notebook.interrupt(options);
503
+ expect(buffer[0]).toBe(2);
504
+ buffer[0] = 0;
505
+ const retry = notebook.interrupt(options);
506
+ expect(buffer[0]).toBe(0);
507
+ pending.resolve(accepted);
508
+ expect(await retry).toEqual(await first);
509
+ await notebook.interrupt(options);
510
+ expect(buffer[0]).toBe(0);
511
+ expect(mockNotebookCall).toHaveBeenCalledTimes(3);
512
+
513
+ mockNotebookCall.mockRejectedValueOnce(new Error("Lost reply"));
514
+ await expect(
515
+ notebook.interrupt({ idempotencyKey: "lost-interrupt" }),
516
+ ).rejects.toThrow("Lost reply");
517
+ expect(buffer[0]).toBe(2);
518
+ buffer[0] = 0;
519
+ await notebook.interrupt({ idempotencyKey: "lost-interrupt" });
520
+ expect(buffer[0]).toBe(0);
521
+
522
+ // A new user action still signals; settled receipts expire with the worker's.
523
+ await notebook.interrupt();
524
+ expect(buffer[0]).toBe(2);
525
+ buffer[0] = 0;
526
+ clock.mockReturnValue(300_001);
527
+ await notebook.interrupt(options);
528
+ expect(buffer[0]).toBe(2);
529
+ } finally {
530
+ buffer[0] = 0;
531
+ clock.mockRestore();
532
+ store.set(initialModeAtom, undefined);
533
+ }
534
+ });