@marimo-team/frontend 0.24.1-dev57 → 0.24.1-dev59

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 (42) hide show
  1. package/dist/assets/add-cell-with-ai-DVGZNNUv.js +76 -0
  2. package/dist/assets/{agent-panel-D7mo8YLQ.js → agent-panel-BTY3oeHD.js} +3 -3
  3. package/dist/assets/cell-editor-D3YsOG7a.js +21 -0
  4. package/dist/assets/{chat-display-Cgg0xLir.js → chat-display-NTNQQyoE.js} +1 -1
  5. package/dist/assets/chat-panel-Bow0QKrA.js +4 -0
  6. package/dist/assets/{chat-ui-DYLpA8l4.js → chat-ui-Cg69zAqt.js} +4 -4
  7. package/dist/assets/{command-palette-DiT0E_K7.js → command-palette-QFmIvDcT.js} +1 -1
  8. package/dist/assets/{edit-page-DAC4cNKe.js → edit-page-ds4edlkF.js} +6 -6
  9. package/dist/assets/{index-DTQ4RS9q.js → index-B36dhEV6.js} +3 -3
  10. package/dist/assets/index-ecw9ngZu.css +2 -0
  11. package/dist/assets/{layout-D3zwsTvR.js → layout-iNJ-1JI2.js} +2 -2
  12. package/dist/assets/{panels-ChFSBlsU.js → panels-BbkuRtEi.js} +1 -1
  13. package/dist/assets/{reveal-component-BFRmOcQr.js → reveal-component-C4pF9OE7.js} +1 -1
  14. package/dist/assets/{run-page-MZCCEXct.js → run-page-Bo57bjbp.js} +1 -1
  15. package/dist/assets/{scratchpad-panel-BqYT2vj3.js → scratchpad-panel-vBIq9BfA.js} +1 -1
  16. package/dist/assets/{skeleton-gmxAkHfa.js → skeleton-B5iAa5n_.js} +1 -1
  17. package/dist/assets/{useNotebookActions-BoFJmccb.js → useNotebookActions-DGuDG0Px.js} +1 -1
  18. package/dist/index.html +2 -2
  19. package/package.json +1 -1
  20. package/src/components/editor/ai/__tests__/completion-utils.test.ts +0 -178
  21. package/src/components/editor/ai/__tests__/staged-cell-submission.test.ts +106 -0
  22. package/src/components/editor/ai/add-cell-with-ai.tsx +83 -62
  23. package/src/components/editor/ai/ai-completion-editor.tsx +71 -37
  24. package/src/components/editor/ai/completion-handlers.tsx +4 -0
  25. package/src/components/editor/ai/completion-utils.ts +0 -92
  26. package/src/components/editor/ai/staged-cell-submission.ts +46 -0
  27. package/src/components/editor/cell/StagedAICell.tsx +4 -2
  28. package/src/components/editor/cell/__tests__/StagedAICell.test.tsx +64 -0
  29. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +77 -0
  30. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +8 -2
  31. package/src/core/ai/__tests__/staged-cells.test.ts +442 -125
  32. package/src/core/ai/__tests__/stream-completion-text.test.ts +74 -0
  33. package/src/core/ai/completion-output.ts +56 -0
  34. package/src/core/ai/staged-cells.ts +245 -164
  35. package/src/core/ai/stream-completion-text.ts +23 -6
  36. package/src/css/globals.css +1 -0
  37. package/dist/assets/add-cell-with-ai-0dqk0f7O.js +0 -77
  38. package/dist/assets/cell-editor-e7_o4mEh.js +0 -24
  39. package/dist/assets/chat-panel-BaG-5LiN.js +0 -4
  40. package/dist/assets/index-BrmH4-pI.css +0 -2
  41. package/src/core/ai/__tests__/strip-wrapping-backticks.test.ts +0 -133
  42. package/src/core/ai/strip-wrapping-backticks.ts +0 -88
@@ -0,0 +1,74 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { streamCompletionText } from "../stream-completion-text";
5
+
6
+ function streamResponse(...chunks: object[]): Response {
7
+ const body = [
8
+ ...chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`),
9
+ "data: [DONE]\n\n",
10
+ ].join("");
11
+ return new Response(body, {
12
+ headers: { "Content-Type": "text/event-stream" },
13
+ });
14
+ }
15
+
16
+ describe("streamCompletionText", () => {
17
+ it("returns validated cell code", async () => {
18
+ const response = streamResponse(
19
+ { type: "start" },
20
+ {
21
+ type: "data-cell-completion",
22
+ data: { code: "print('```')" },
23
+ },
24
+ { type: "finish", finishReason: "stop" },
25
+ );
26
+
27
+ await expect(streamCompletionText(response)).resolves.toBe("print('```')");
28
+ });
29
+
30
+ it("rejects a missing structured completion", async () => {
31
+ const response = streamResponse(
32
+ { type: "start" },
33
+ { type: "finish", finishReason: "stop" },
34
+ );
35
+
36
+ await expect(streamCompletionText(response)).rejects.toThrow(
37
+ "AI completion returned no cell code",
38
+ );
39
+ });
40
+
41
+ it("rejects malformed completion data", async () => {
42
+ const response = streamResponse({
43
+ type: "data-cell-completion",
44
+ data: { code: 42 },
45
+ });
46
+
47
+ await expect(streamCompletionText(response)).rejects.toThrow();
48
+ });
49
+
50
+ it("rejects a partial completion without successful final validation", async () => {
51
+ const response = streamResponse({
52
+ type: "data-cell-completion",
53
+ data: { code: "partial" },
54
+ });
55
+
56
+ await expect(streamCompletionText(response)).rejects.toThrow(
57
+ "AI completion ended before final validation",
58
+ );
59
+ });
60
+
61
+ it("rejects a completion with an error finish reason", async () => {
62
+ const response = streamResponse(
63
+ {
64
+ type: "data-cell-completion",
65
+ data: { code: "partial" },
66
+ },
67
+ { type: "finish", finishReason: "error" },
68
+ );
69
+
70
+ await expect(streamCompletionText(response)).rejects.toThrow(
71
+ "AI completion ended before final validation",
72
+ );
73
+ });
74
+ });
@@ -0,0 +1,56 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { DataUIPart, UIMessage, UIMessageChunk } from "ai";
4
+ import { z } from "zod";
5
+
6
+ // Mirrored from marimo/_server/ai/completion_output.py; SSE data parts are not
7
+ // represented in OpenAPI.
8
+ const CELL_COMPLETION_DATA_PART = "cell-completion" as const;
9
+ const NOTEBOOK_CELLS_COMPLETION_DATA_PART =
10
+ "notebook-cells-completion" as const;
11
+
12
+ export const CELL_COMPLETION_DATA_TYPE =
13
+ `data-${CELL_COMPLETION_DATA_PART}` as const;
14
+ export const NOTEBOOK_CELLS_COMPLETION_DATA_TYPE =
15
+ `data-${NOTEBOOK_CELLS_COMPLETION_DATA_PART}` as const;
16
+
17
+ export const cellCompletionSchema = z.object({
18
+ code: z.string(),
19
+ });
20
+
21
+ export const generatedCellSchema = z.object({
22
+ language: z.enum(["python", "sql", "markdown"]),
23
+ code: z.string().min(1),
24
+ });
25
+
26
+ export const notebookCellsCompletionSchema = z.object({
27
+ cells: z.array(generatedCellSchema).min(1),
28
+ });
29
+
30
+ export type CellCompletion = z.infer<typeof cellCompletionSchema>;
31
+ export type GeneratedCell = z.infer<typeof generatedCellSchema>;
32
+ export type NotebookCellsCompletion = z.infer<
33
+ typeof notebookCellsCompletionSchema
34
+ >;
35
+
36
+ // Keep parsing data parts explicitly at the SSE boundary. AI SDK 7.0.37 types
37
+ // schema keys without `data-`, but its runtime lookup uses the full wire type.
38
+ const completionDataSchemas = {
39
+ [CELL_COMPLETION_DATA_PART]: cellCompletionSchema,
40
+ [NOTEBOOK_CELLS_COMPLETION_DATA_PART]: notebookCellsCompletionSchema,
41
+ };
42
+
43
+ export type CompletionDataParts = {
44
+ [Name in keyof typeof completionDataSchemas]: z.infer<
45
+ (typeof completionDataSchemas)[Name]
46
+ >;
47
+ };
48
+
49
+ export type CompletionUIMessage = UIMessage<unknown, CompletionDataParts>;
50
+ export type CompletionDataPart = DataUIPart<CompletionDataParts>;
51
+
52
+ type DataChunk = Extract<UIMessageChunk, { type: `data-${string}` }>;
53
+
54
+ export function isDataChunk(chunk: UIMessageChunk): chunk is DataChunk {
55
+ return chunk.type.startsWith("data-");
56
+ }
@@ -1,34 +1,32 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import type { UIMessageChunk } from "ai";
4
- import { useAtomValue } from "jotai";
3
+ import { atom, useAtomValue } from "jotai";
5
4
  import { selectAtom } from "jotai/utils";
6
5
  import { useMemo, useRef } from "react";
7
- import {
8
- type AiCompletion,
9
- codeToCells,
10
- } from "@/components/editor/ai/completion-utils";
11
6
  import { useDeleteCellCallback } from "@/components/editor/cell/useDeleteCell";
12
7
  import { CellId } from "@/core/cells/ids";
13
- import { logNever } from "@/utils/assertNever";
14
8
  import { createReducerAndAtoms } from "@/utils/createReducer";
15
9
  import { Logger } from "@/utils/Logger";
10
+ import {
11
+ type CompletionDataPart,
12
+ NOTEBOOK_CELLS_COMPLETION_DATA_TYPE,
13
+ type GeneratedCell,
14
+ notebookCellsCompletionSchema,
15
+ } from "./completion-output";
16
16
  import { maybeAddMarimoImport } from "../cells/add-missing-import";
17
17
  import {
18
18
  type CreateNewCellAction,
19
19
  getCellEditorView,
20
20
  useCellActions,
21
21
  } from "../cells/cells";
22
- import type { LanguageAdapterType } from "../codemirror/language/types";
23
22
  import { updateEditorCodeFromPython } from "../codemirror/language/utils";
24
23
  import type { JotaiStore } from "../state/jotai";
25
24
  import type { EditType } from "./tools/edit-notebook-tool";
26
25
 
27
26
  /**
28
- * Cells that are staged for AI completion
29
- * They function similarly to cells in the notebook, but they can be accepted or rejected by the user.
30
- * We track edited, new and deleted cells.
31
- * And we only track one set of staged cells at a time.
27
+ * Pending AI edits shared by completion, chat, and agent workflows.
28
+ * Generation ownership keeps one workflow from accepting or discarding another's
29
+ * staged cells.
32
30
  */
33
31
 
34
32
  export type Edit =
@@ -38,6 +36,25 @@ export type Edit =
38
36
 
39
37
  export type StagedAICells = Map<CellId, Edit>;
40
38
 
39
+ type StagedGeneration =
40
+ | {
41
+ id: symbol;
42
+ status: "in_progress";
43
+ cellIds: readonly CellId[];
44
+ }
45
+ | { id: symbol; status: "complete" };
46
+
47
+ interface OwnedStagedGeneration {
48
+ id: symbol;
49
+ status: "in_progress" | "complete";
50
+ reconciler: StagedCellReconciler;
51
+ }
52
+
53
+ const stagedGenerationAtom = atom<StagedGeneration | null>(null);
54
+ export const stagedGenerationInProgressAtom = atom(
55
+ (get) => get(stagedGenerationAtom)?.status === "in_progress",
56
+ );
57
+
41
58
  const initialState = (): StagedAICells => {
42
59
  return new Map();
43
60
  };
@@ -60,16 +77,9 @@ const {
60
77
  newState.delete(cellId);
61
78
  return newState;
62
79
  },
63
- clearStagedCells: () => {
64
- return initialState();
65
- },
66
80
  });
67
81
 
68
- export {
69
- useStagedAICellsActions,
70
- createActions as createStagedAICellsActions,
71
- reducer as stagedAICellsReducer,
72
- };
82
+ export { useStagedAICellsActions };
73
83
 
74
84
  export function useStagedAICell(cellId: CellId): Edit | undefined {
75
85
  const stagedCellAtom = useMemo(
@@ -83,20 +93,14 @@ export function useStagedAICell(cellId: CellId): Edit | undefined {
83
93
  interface UpdateStagedCellAction {
84
94
  cellId: CellId;
85
95
  code: string;
86
- language?: LanguageAdapterType;
87
96
  }
88
97
 
89
- /**
90
- * Helper functions to create and delete staged cells.
91
- */
98
+ /** Manage staged cells without assuming which AI workflow owns them. */
92
99
  export function useStagedCells(store: JotaiStore) {
93
- const { addStagedCell, removeStagedCell, clearStagedCells } =
94
- useStagedAICellsActions();
100
+ const { addStagedCell, removeStagedCell } = useStagedAICellsActions();
95
101
  const { createNewCell, updateCellCode } = useCellActions();
96
102
  const deleteCellCallback = useDeleteCellCallback();
97
103
 
98
- const cellCreationStream = useRef<CellCreationStream | null>(null);
99
-
100
104
  const createStagedCell = (code: string): CellId => {
101
105
  const newCellId = CellId.create();
102
106
  addStagedCell({ cellId: newCellId, edit: { type: "add_cell" } });
@@ -118,8 +122,6 @@ export function useStagedCells(store: JotaiStore) {
118
122
  return;
119
123
  }
120
124
 
121
- // Update the editor code if the cell is mounted
122
- // Else, update the cell code in the notebook
123
125
  const editorView = getCellEditorView(cellId);
124
126
  if (editorView) {
125
127
  updateEditorCodeFromPython(editorView, code);
@@ -128,190 +130,269 @@ export function useStagedCells(store: JotaiStore) {
128
130
  }
129
131
  };
130
132
 
131
- // Delete a staged cell and the corresponding cell in the notebook.
132
133
  const deleteStagedCell = (cellId: CellId) => {
133
134
  removeStagedCell(cellId);
134
135
  deleteCellCallback({ cellId });
135
136
  };
136
137
 
137
- // Delete all staged cells and the corresponding cells in the notebook.
138
- const deleteAllStagedCells = () => {
138
+ return {
139
+ createStagedCell,
140
+ updateStagedCell,
141
+ addStagedCell,
142
+ removeStagedCell,
143
+ deleteStagedCell,
144
+ };
145
+ }
146
+
147
+ /** Owns the lifecycle of cells created by one structured completion flow. */
148
+ export function useStagedCellGeneration(store: JotaiStore) {
149
+ const stagedCellActions = useStagedCells(store);
150
+ const { createNewCell } = useCellActions();
151
+ const ownedGeneration = useRef<OwnedStagedGeneration | null>(null);
152
+ // Ownership lives in a ref, so subscribe to its backing atoms to rerender
153
+ // consumers when completion or per-cell acceptance changes.
154
+ useAtomValue(stagedGenerationAtom, { store });
155
+ useAtomValue(stagedAICellsAtom, { store });
156
+
157
+ const discardStagedCellIds = (cellIds: readonly CellId[]) => {
139
158
  const stagedAICells = store.get(stagedAICellsAtom);
140
- for (const cellId of stagedAICells.keys()) {
141
- deleteCellCallback({ cellId });
159
+ for (const cellId of cellIds.toReversed()) {
160
+ if (stagedAICells.has(cellId)) {
161
+ stagedCellActions.deleteStagedCell(cellId);
162
+ }
142
163
  }
143
- clearStagedCells();
144
164
  };
145
165
 
146
- const onStream = (chunk: UIMessageChunk) => {
147
- switch (chunk.type) {
148
- case "text-start":
149
- // Create stream
150
- cellCreationStream.current = new CellCreationStream(
151
- createStagedCell,
152
- updateStagedCell,
153
- addStagedCell,
154
- createNewCell,
155
- );
156
- break;
157
- case "text-delta":
158
- if (!cellCreationStream.current) {
159
- Logger.error("Cell creation stream not found");
160
- return;
161
- }
162
- cellCreationStream.current.stream(chunk);
163
- break;
164
- case "text-end":
165
- case "finish":
166
- if (!cellCreationStream.current) {
167
- Logger.error("Cell creation stream not found");
168
- return;
169
- }
170
- cellCreationStream.current.stop();
171
- break;
172
- case "abort":
173
- case "error":
174
- case "tool-input-error":
175
- case "tool-output-error":
176
- Logger.error("Error", chunk.type, { chunk });
177
- break;
178
- case "tool-approval-request":
179
- Logger.log("Tool approval request", { chunk });
180
- break;
181
- case "tool-output-denied":
182
- Logger.error("Tool output denied", { chunk });
183
- break;
184
- // These logs are not useful for debugging
185
- case "start":
186
- case "start-step":
187
- case "finish-step":
188
- case "data-reasoning-signature":
189
- break;
190
- case "message-metadata":
191
- case "tool-input-available":
192
- case "tool-output-available":
193
- case "tool-approval-response":
194
- case "reasoning-start":
195
- case "reasoning-delta":
196
- case "reasoning-end":
197
- case "file":
198
- case "reasoning-file":
199
- case "source-document":
200
- case "source-url":
201
- case "tool-input-start":
202
- case "tool-input-delta":
203
- case "custom":
204
- Logger.debug(chunk.type, { chunk });
205
- break;
206
- default:
207
- if (isDataChunk(chunk)) {
208
- Logger.debug("Data chunk", { chunk });
209
- break;
210
- }
211
- logNever(chunk);
166
+ const beginStagedCellGeneration = () => {
167
+ const activeGeneration = store.get(stagedGenerationAtom);
168
+ if (activeGeneration?.status === "in_progress") {
169
+ discardStagedCellIds(activeGeneration.cellIds);
170
+ }
171
+ ownedGeneration.current?.reconciler.discard(store.get(stagedAICellsAtom));
172
+ const generationId = Symbol("staged-cell-generation");
173
+ ownedGeneration.current = {
174
+ id: generationId,
175
+ status: "in_progress",
176
+ reconciler: new StagedCellReconciler({
177
+ ...stagedCellActions,
178
+ createNewCell,
179
+ }),
180
+ };
181
+ store.set(stagedGenerationAtom, {
182
+ id: generationId,
183
+ status: "in_progress",
184
+ cellIds: [],
185
+ });
186
+ };
187
+
188
+ const finishStagedCellGeneration = (successful: boolean) => {
189
+ const generation = ownedGeneration.current;
190
+ if (generation?.status !== "in_progress") {
191
+ return;
192
+ }
193
+
194
+ const stagedGeneration = store.get(stagedGenerationAtom);
195
+ const ownsActiveGeneration =
196
+ stagedGeneration?.id === generation.id &&
197
+ stagedGeneration.status === "in_progress";
198
+ if (!ownsActiveGeneration || !successful) {
199
+ generation.reconciler.discard(store.get(stagedAICellsAtom));
200
+ if (ownsActiveGeneration) {
201
+ store.set(stagedGenerationAtom, null);
202
+ }
203
+ ownedGeneration.current = null;
204
+ return;
205
+ }
206
+
207
+ generation.status = "complete";
208
+ store.set(stagedGenerationAtom, {
209
+ id: generation.id,
210
+ status: "complete",
211
+ });
212
+ };
213
+
214
+ const hasOwnedStagedCells = () => {
215
+ const generation = ownedGeneration.current;
216
+ const stagedGeneration = store.get(stagedGenerationAtom);
217
+ return (
218
+ generation !== null &&
219
+ generation.status === "complete" &&
220
+ stagedGeneration?.status === "complete" &&
221
+ stagedGeneration.id === generation.id &&
222
+ generation.reconciler.hasStagedCells(store.get(stagedAICellsAtom))
223
+ );
224
+ };
225
+
226
+ const acceptOwnedStagedCells = () => {
227
+ if (!hasOwnedStagedCells()) {
228
+ return false;
229
+ }
230
+
231
+ ownedGeneration.current?.reconciler.accept();
232
+ store.set(stagedGenerationAtom, null);
233
+ ownedGeneration.current = null;
234
+ return true;
235
+ };
236
+
237
+ const discardOwnedStagedCells = () => {
238
+ const generation = ownedGeneration.current;
239
+ const stagedGeneration = store.get(stagedGenerationAtom);
240
+ if (generation === null || stagedGeneration?.id !== generation.id) {
241
+ return false;
212
242
  }
243
+
244
+ generation.reconciler.discard(store.get(stagedAICellsAtom));
245
+ store.set(stagedGenerationAtom, null);
246
+ ownedGeneration.current = null;
247
+ return true;
248
+ };
249
+
250
+ const onData = (part: CompletionDataPart) => {
251
+ if (part.type !== NOTEBOOK_CELLS_COMPLETION_DATA_TYPE) {
252
+ return;
253
+ }
254
+ const generation = ownedGeneration.current;
255
+ const stagedGeneration = store.get(stagedGenerationAtom);
256
+ if (
257
+ generation?.status !== "in_progress" ||
258
+ stagedGeneration?.id !== generation.id ||
259
+ stagedGeneration.status !== "in_progress"
260
+ ) {
261
+ return;
262
+ }
263
+ const completion = notebookCellsCompletionSchema.parse(part.data);
264
+ generation.reconciler.reconcile(completion.cells);
265
+ store.set(stagedGenerationAtom, {
266
+ ...stagedGeneration,
267
+ cellIds: generation.reconciler.stagedCellIds(),
268
+ });
213
269
  };
214
270
 
215
271
  return {
216
- createStagedCell,
217
- updateStagedCell,
218
- addStagedCell,
219
- removeStagedCell,
220
- clearStagedCells,
221
- deleteStagedCell,
222
- deleteAllStagedCells,
223
- onStream,
272
+ beginStagedCellGeneration,
273
+ finishStagedCellGeneration,
274
+ hasOwnedStagedCells,
275
+ acceptOwnedStagedCells,
276
+ discardOwnedStagedCells,
277
+ onData,
224
278
  };
225
279
  }
226
280
 
227
281
  export { stagedAICellsAtom };
228
282
  export const visibleForTesting = {
283
+ stagedGenerationAtom,
229
284
  createActions,
230
285
  reducer,
231
286
  initialState,
232
- useStagedAICellsActions,
233
287
  };
234
288
 
235
- type TextDeltaChunk = Extract<UIMessageChunk, { type: "text-delta" }>;
236
-
237
289
  interface CreatedCell {
238
290
  cellId: CellId;
239
- cell: AiCompletion;
291
+ code: string;
240
292
  }
241
293
 
242
- class CellCreationStream {
294
+ interface StagedCellReconcilerActions {
295
+ createStagedCell: (code: string) => CellId;
296
+ updateStagedCell: (opts: UpdateStagedCellAction) => void;
297
+ deleteStagedCell: (cellId: CellId) => void;
298
+ removeStagedCell: (cellId: CellId) => void;
299
+ addStagedCell: (payload: { cellId: CellId; edit: Edit }) => void;
300
+ createNewCell: (opts: CreateNewCellAction) => void;
301
+ }
302
+
303
+ class StagedCellReconciler {
243
304
  private createdCells: CreatedCell[] = [];
244
- private buffer = "";
245
-
246
- private onCreateCell: (code: string) => CellId;
247
- private onUpdateCell: (opts: UpdateStagedCellAction) => void;
248
- private addStagedCell: (payload: { cellId: CellId; edit: Edit }) => void;
249
- private createNewCell: (opts: CreateNewCellAction) => void;
250
- private hasMarimoImport = false;
251
-
252
- constructor(
253
- onCreateCell: (code: string) => CellId,
254
- onUpdateCell: (opts: UpdateStagedCellAction) => void,
255
- addStagedCell: (payload: { cellId: CellId; edit: Edit }) => void,
256
- createNewCell: (opts: CreateNewCellAction) => void,
257
- ) {
258
- this.onCreateCell = onCreateCell;
259
- this.onUpdateCell = onUpdateCell;
260
- this.addStagedCell = addStagedCell;
261
- this.createNewCell = createNewCell;
262
- }
305
+ private readonly actions: StagedCellReconcilerActions;
306
+ private marimoImportCellId: CellId | null = null;
263
307
 
264
- stream(chunk: TextDeltaChunk) {
265
- const delta = chunk.delta;
266
- this.buffer += delta;
267
- const completionCells = codeToCells(this.buffer);
308
+ constructor(actions: StagedCellReconcilerActions) {
309
+ this.actions = actions;
310
+ }
268
311
 
269
- // As incoming chunks are appended to the buffer,
270
- // we parse the buffer into cells and determine which parts correspond to which cell.
271
- // For each parsed cell, we either update an existing staged cell or create a new one.
312
+ reconcile(completionCells: GeneratedCell[]) {
272
313
  for (const [idx, cell] of completionCells.entries()) {
273
314
  if (idx < this.createdCells.length) {
274
- this.addMarimoImport(cell.language);
275
315
  const existingCell = this.createdCells[idx];
276
- this.createdCells[idx] = { ...existingCell, cell };
277
- this.onUpdateCell({
316
+ const codeChanged = existingCell.code !== cell.code;
317
+ this.createdCells[idx] = { ...existingCell, code: cell.code };
318
+ if (!codeChanged) {
319
+ continue;
320
+ }
321
+ this.actions.updateStagedCell({
278
322
  cellId: existingCell.cellId,
279
323
  code: cell.code,
280
- language: cell.language,
281
324
  });
282
325
  } else {
283
- const newCellId = this.onCreateCell(cell.code);
284
- this.createdCells.push({ cellId: newCellId, cell });
326
+ const newCellId = this.actions.createStagedCell(cell.code);
327
+ this.createdCells.push({ cellId: newCellId, code: cell.code });
285
328
  }
286
329
  }
330
+
331
+ const removedCells = this.createdCells.splice(completionCells.length);
332
+ for (const { cellId } of removedCells.toReversed()) {
333
+ this.actions.deleteStagedCell(cellId);
334
+ }
335
+
336
+ this.syncMarimoImport(completionCells);
287
337
  }
288
338
 
289
- /** Add a marimo import if the cell is SQL or Markdown and we haven't added it yet. */
290
- private addMarimoImport(language: LanguageAdapterType) {
291
- if (this.hasMarimoImport || language === "python") {
339
+ discard(stagedCells: StagedAICells) {
340
+ for (const { cellId } of this.createdCells.toReversed()) {
341
+ if (stagedCells.has(cellId)) {
342
+ this.actions.deleteStagedCell(cellId);
343
+ }
344
+ }
345
+ this.createdCells = [];
346
+
347
+ if (this.marimoImportCellId && stagedCells.has(this.marimoImportCellId)) {
348
+ this.actions.deleteStagedCell(this.marimoImportCellId);
349
+ }
350
+ this.marimoImportCellId = null;
351
+ }
352
+
353
+ accept() {
354
+ for (const cellId of this.stagedCellIds()) {
355
+ this.actions.removeStagedCell(cellId);
356
+ }
357
+ }
358
+
359
+ hasStagedCells(stagedCells: StagedAICells) {
360
+ return this.stagedCellIds().some((cellId) => stagedCells.has(cellId));
361
+ }
362
+
363
+ stagedCellIds() {
364
+ const cellIds = this.createdCells.map(({ cellId }) => cellId);
365
+ if (this.marimoImportCellId) {
366
+ cellIds.push(this.marimoImportCellId);
367
+ }
368
+ return cellIds;
369
+ }
370
+
371
+ /** Keep the generated marimo import consistent with the latest snapshot. */
372
+ private syncMarimoImport(completionCells: GeneratedCell[]) {
373
+ const requiresMarimo = completionCells.some(
374
+ (cell) => cell.language !== "python",
375
+ );
376
+ if (!requiresMarimo) {
377
+ if (this.marimoImportCellId) {
378
+ this.actions.deleteStagedCell(this.marimoImportCellId);
379
+ this.marimoImportCellId = null;
380
+ }
381
+ return;
382
+ }
383
+ if (this.marimoImportCellId) {
292
384
  return;
293
385
  }
294
386
 
295
387
  const cellId = maybeAddMarimoImport({
296
388
  autoInstantiate: false,
297
- createNewCell: this.createNewCell,
389
+ createNewCell: this.actions.createNewCell,
298
390
  fromCellId: this.createdCells[0]?.cellId,
299
391
  before: true,
300
392
  });
301
393
  if (cellId) {
302
- this.addStagedCell({ cellId, edit: { type: "add_cell" } });
394
+ this.actions.addStagedCell({ cellId, edit: { type: "add_cell" } });
395
+ this.marimoImportCellId = cellId;
303
396
  }
304
- this.hasMarimoImport = true;
305
397
  }
306
-
307
- stop() {
308
- // Clear all state
309
- this.buffer = "";
310
- }
311
- }
312
-
313
- type DataChunk = Extract<UIMessageChunk, { type: `data-${string}` }>;
314
-
315
- function isDataChunk(chunk: UIMessageChunk): chunk is DataChunk {
316
- return chunk.type.startsWith("data-");
317
398
  }