@marimo-team/frontend 0.24.1-dev58 → 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 (41) 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-W5CUNEV9.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/dist/assets/add-cell-with-ai-0dqk0f7O.js +0 -77
  37. package/dist/assets/cell-editor-e7_o4mEh.js +0 -24
  38. package/dist/assets/chat-panel-BaG-5LiN.js +0 -4
  39. package/dist/assets/index-DIUt_9oR.css +0 -2
  40. package/src/core/ai/__tests__/strip-wrapping-backticks.test.ts +0 -133
  41. package/src/core/ai/strip-wrapping-backticks.ts +0 -88
@@ -6,20 +6,23 @@ import { createElement, type ReactNode } from "react";
6
6
  import { beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { cellId } from "@/__tests__/branded";
8
8
  import { CellId } from "@/core/cells/ids";
9
+ import { getCellEditorView } from "../../cells/cells";
9
10
  import { updateEditorCodeFromPython } from "../../codemirror/language/utils";
10
11
  import {
11
- type StagedAICells,
12
12
  stagedAICellsAtom,
13
+ stagedGenerationInProgressAtom,
13
14
  useStagedAICell,
15
+ useStagedCellGeneration,
14
16
  useStagedCells,
15
17
  visibleForTesting,
16
18
  } from "../staged-cells";
17
19
 
18
- const { createActions, reducer, initialState } = visibleForTesting;
20
+ const { stagedGenerationAtom, createActions, reducer, initialState } =
21
+ visibleForTesting;
19
22
 
20
23
  // Mock the dependencies
21
24
  const mockCreateNewCell = vi.fn();
22
- const mockUpdateCellEditor = vi.fn();
25
+ const mockUpdateCellCode = vi.fn();
23
26
  const mockDeleteCellCallback = vi.fn();
24
27
 
25
28
  // Mock cell handle with editor view
@@ -31,21 +34,29 @@ const mockCellHandle = {
31
34
  },
32
35
  };
33
36
 
34
- vi.mock("../../cells/cells", () => ({
35
- useCellActions: () => ({
36
- createNewCell: mockCreateNewCell,
37
- updateCellEditor: mockUpdateCellEditor,
38
- }),
39
- cellHandleAtom: vi.fn(() => ({
40
- read: vi.fn(() => mockCellHandle),
41
- })),
42
- getCellEditorView: vi.fn(() => mockCellHandle.current.editorViewOrNull),
43
- }));
37
+ vi.mock("../../cells/cells", async () => {
38
+ const { atom } = await import("jotai");
39
+ return {
40
+ notebookAtom: atom({ cellData: {}, cellIds: { inOrderIds: [] } }),
41
+ useCellActions: () => ({
42
+ createNewCell: mockCreateNewCell,
43
+ updateCellCode: mockUpdateCellCode,
44
+ }),
45
+ cellHandleAtom: vi.fn(() => ({
46
+ read: vi.fn(() => mockCellHandle),
47
+ })),
48
+ getCellEditorView: vi.fn(() => mockCellHandle.current.editorViewOrNull),
49
+ };
50
+ });
44
51
 
45
52
  vi.mock("@/components/editor/cell/useDeleteCell", () => ({
46
53
  useDeleteCellCallback: () => mockDeleteCellCallback,
47
54
  }));
48
55
 
56
+ vi.mock("@/core/network/requests", () => ({
57
+ getRequestClient: () => ({ sendRun: vi.fn() }),
58
+ }));
59
+
49
60
  vi.mock("../../codemirror/language/utils", () => ({
50
61
  updateEditorCodeFromPython: vi.fn(),
51
62
  }));
@@ -69,9 +80,11 @@ describe("staged-cells", () => {
69
80
 
70
81
  // Reset mocks
71
82
  vi.clearAllMocks();
83
+ vi.mocked(CellId.create).mockReset();
72
84
 
73
85
  // Reset the atom state
74
86
  store.set(stagedAICellsAtom, new Map());
87
+ store.set(stagedGenerationAtom, null);
75
88
  });
76
89
 
77
90
  describe("reducer and actions", () => {
@@ -160,20 +173,6 @@ describe("staged-cells", () => {
160
173
 
161
174
  expect(newState).toBe(state);
162
175
  });
163
-
164
- it("should clear all cells", () => {
165
- const state = new Map([
166
- [cellId1, { type: "add_cell" as const }],
167
- [cellId2, { type: "add_cell" as const }],
168
- ]);
169
- const newState = reducer(state, {
170
- type: "clearStagedCells",
171
- payload: undefined,
172
- });
173
-
174
- expect(newState).toEqual(new Map());
175
- });
176
-
177
176
  it("should not mutate original state when adding", () => {
178
177
  const state = new Map([[cellId1, { type: "add_cell" as const }]]);
179
178
  const originalSize = state.size;
@@ -211,7 +210,6 @@ describe("staged-cells", () => {
211
210
 
212
211
  expect(typeof actions.addStagedCell).toBe("function");
213
212
  expect(typeof actions.removeStagedCell).toBe("function");
214
- expect(typeof actions.clearStagedCells).toBe("function");
215
213
  });
216
214
 
217
215
  it("should initialize atom with empty map", () => {
@@ -282,34 +280,6 @@ describe("staged-cells", () => {
282
280
  });
283
281
  });
284
282
 
285
- it("should delete all staged cells when none exist", () => {
286
- const { result } = renderHook(() => useStagedCells(store));
287
-
288
- // Should not throw when no cells exist
289
- expect(() => result.current.deleteAllStagedCells()).not.toThrow();
290
- expect(mockDeleteCellCallback).not.toHaveBeenCalled();
291
- });
292
-
293
- it("should delete all staged cells when cells exist", () => {
294
- // First set the atom state before rendering the hook
295
- const initialState: StagedAICells = new Map([
296
- [cellId1, { type: "add_cell" }],
297
- [cellId2, { type: "add_cell" }],
298
- ]);
299
- store.set(stagedAICellsAtom, initialState);
300
-
301
- const { result } = renderHook(() => useStagedCells(store));
302
- result.current.deleteAllStagedCells();
303
-
304
- expect(mockDeleteCellCallback).toHaveBeenCalledTimes(2);
305
- expect(mockDeleteCellCallback).toHaveBeenCalledWith({ cellId: cellId1 });
306
- expect(mockDeleteCellCallback).toHaveBeenCalledWith({ cellId: cellId2 });
307
-
308
- // Verify cells were cleared from the atom
309
- const state = store.get(stagedAICellsAtom);
310
- expect(state).toEqual(new Map());
311
- });
312
-
313
283
  it("should add staged cell with edit info", () => {
314
284
  const { result } = renderHook(() => useStagedCells(store));
315
285
 
@@ -349,27 +319,6 @@ describe("staged-cells", () => {
349
319
  expect(state.has(cellId2)).toBe(true);
350
320
  });
351
321
 
352
- it("should clear all staged cells", () => {
353
- const { result } = renderHook(() => useStagedCells(store));
354
-
355
- // First add some cells
356
- result.current.addStagedCell({
357
- cellId: cellId1,
358
- edit: { type: "add_cell" },
359
- });
360
- result.current.addStagedCell({
361
- cellId: cellId2,
362
- edit: { type: "add_cell" },
363
- });
364
-
365
- // Then clear all
366
- result.current.clearStagedCells();
367
-
368
- // Check that no cells remain
369
- const state = store.get(stagedAICellsAtom);
370
- expect(state).toEqual(new Map());
371
- });
372
-
373
322
  it("should handle multiple operations correctly", () => {
374
323
  const { result } = renderHook(() => useStagedCells(store));
375
324
 
@@ -434,92 +383,460 @@ describe("staged-cells", () => {
434
383
  });
435
384
  });
436
385
 
437
- describe("onStream", () => {
386
+ describe("staged cell generation", () => {
438
387
  let store: ReturnType<typeof getDefaultStore>;
388
+ const renderGenerationHook = () =>
389
+ renderHook(() => useStagedCellGeneration(store));
390
+
439
391
  beforeEach(() => {
440
392
  store = getDefaultStore();
393
+ vi.clearAllMocks();
394
+ vi.mocked(CellId.create).mockReset();
395
+ store.set(stagedAICellsAtom, new Map());
396
+ store.set(stagedGenerationAtom, null);
441
397
  });
442
398
 
443
- it("should create a cell creation stream", () => {
444
- const { result } = renderHook(() => useStagedCells(store));
445
- result.current.onStream({ type: "text-start", id: "test-id" });
399
+ it("should begin generation", () => {
400
+ const { result } = renderGenerationHook();
401
+ result.current.beginStagedCellGeneration();
446
402
 
447
403
  // No cell or cell update should have been called
448
404
  expect(mockCreateNewCell).not.toHaveBeenCalled();
449
- expect(mockUpdateCellEditor).not.toHaveBeenCalled();
405
+ expect(updateEditorCodeFromPython).not.toHaveBeenCalled();
406
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(true);
407
+ });
408
+
409
+ it("should mark generation complete after a successful finish", () => {
410
+ const { result } = renderGenerationHook();
411
+ result.current.beginStagedCellGeneration();
412
+ result.current.finishStagedCellGeneration(true);
413
+
414
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(false);
450
415
  });
451
416
 
452
- it("should not create cells when text-delta is received and no stream has been created", () => {
453
- const { result } = renderHook(() => useStagedCells(store));
454
- result.current.onStream({
455
- type: "text-delta",
456
- id: "test-id",
457
- delta: "test-delta",
417
+ it("should not couple unrelated staged-cell removal to generation state", () => {
418
+ const { result: stagedCells } = renderHook(() => useStagedCells(store));
419
+ const { result } = renderGenerationHook();
420
+ result.current.beginStagedCellGeneration();
421
+
422
+ const chatCellId = cellId("chat-edited-cell");
423
+ stagedCells.current.addStagedCell({
424
+ cellId: chatCellId,
425
+ edit: { type: "update_cell", previousCode: "before" },
458
426
  });
427
+ stagedCells.current.removeStagedCell(chatCellId);
459
428
 
460
- // No cell or cell update should have been called
461
- expect(mockCreateNewCell).not.toHaveBeenCalled();
462
- expect(mockUpdateCellEditor).not.toHaveBeenCalled();
429
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(true);
430
+ });
431
+
432
+ it("should only accept cells owned by the completed generation", () => {
433
+ const firstGeneration = renderGenerationHook();
434
+ firstGeneration.result.current.beginStagedCellGeneration();
435
+
436
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("superseded-cell"));
437
+ firstGeneration.result.current.onData({
438
+ type: "data-notebook-cells-completion",
439
+ data: {
440
+ cells: [{ language: "python", code: "old" }],
441
+ },
442
+ });
443
+ firstGeneration.result.current.finishStagedCellGeneration(true);
444
+
445
+ const activeGeneration = renderGenerationHook();
446
+ activeGeneration.result.current.beginStagedCellGeneration();
447
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("active-cell"));
448
+ activeGeneration.result.current.onData({
449
+ type: "data-notebook-cells-completion",
450
+ data: {
451
+ cells: [{ language: "python", code: "active" }],
452
+ },
453
+ });
454
+ activeGeneration.result.current.finishStagedCellGeneration(true);
455
+
456
+ expect(firstGeneration.result.current.hasOwnedStagedCells()).toBe(false);
457
+ expect(firstGeneration.result.current.acceptOwnedStagedCells()).toBe(false);
458
+ expect(store.get(stagedAICellsAtom)).toEqual(
459
+ new Map([
460
+ [cellId("superseded-cell"), { type: "add_cell" }],
461
+ [cellId("active-cell"), { type: "add_cell" }],
462
+ ]),
463
+ );
464
+
465
+ expect(activeGeneration.result.current.hasOwnedStagedCells()).toBe(true);
466
+ expect(activeGeneration.result.current.acceptOwnedStagedCells()).toBe(true);
467
+ expect(store.get(stagedAICellsAtom)).toEqual(
468
+ new Map([[cellId("superseded-cell"), { type: "add_cell" }]]),
469
+ );
470
+ });
471
+
472
+ it("should replace only the previous generation owned by this hook", () => {
473
+ const { result: stagedCells } = renderHook(() => useStagedCells(store));
474
+ const { result } = renderGenerationHook();
475
+ stagedCells.current.addStagedCell({
476
+ cellId: cellId("chat-edited-cell"),
477
+ edit: { type: "update_cell", previousCode: "before" },
478
+ });
479
+ result.current.beginStagedCellGeneration();
480
+
481
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("generated-cell"));
482
+ result.current.onData({
483
+ type: "data-notebook-cells-completion",
484
+ data: {
485
+ cells: [{ language: "python", code: "generated" }],
486
+ },
487
+ });
488
+ result.current.finishStagedCellGeneration(true);
489
+ mockDeleteCellCallback.mockClear();
490
+
491
+ result.current.beginStagedCellGeneration();
492
+
493
+ expect(mockDeleteCellCallback).toHaveBeenCalledExactlyOnceWith({
494
+ cellId: cellId("generated-cell"),
495
+ });
496
+ expect(store.get(stagedAICellsAtom)).toEqual(
497
+ new Map([
498
+ [
499
+ cellId("chat-edited-cell"),
500
+ { type: "update_cell", previousCode: "before" },
501
+ ],
502
+ ]),
503
+ );
504
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(true);
505
+ });
506
+
507
+ it("should leave staged cells from another workflow untouched when accepting", () => {
508
+ const { result: stagedCells } = renderHook(() => useStagedCells(store));
509
+ const { result } = renderGenerationHook();
510
+ result.current.beginStagedCellGeneration();
511
+
512
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("generated-cell"));
513
+ result.current.onData({
514
+ type: "data-notebook-cells-completion",
515
+ data: {
516
+ cells: [{ language: "python", code: "generated" }],
517
+ },
518
+ });
519
+ result.current.finishStagedCellGeneration(true);
520
+ stagedCells.current.addStagedCell({
521
+ cellId: cellId("chat-edited-cell"),
522
+ edit: { type: "update_cell", previousCode: "before" },
523
+ });
524
+
525
+ expect(result.current.acceptOwnedStagedCells()).toBe(true);
526
+ expect(store.get(stagedAICellsAtom)).toEqual(
527
+ new Map([
528
+ [
529
+ cellId("chat-edited-cell"),
530
+ { type: "update_cell", previousCode: "before" },
531
+ ],
532
+ ]),
533
+ );
534
+ });
535
+
536
+ it("should only discard the generation's remaining staged cells", () => {
537
+ const { result: stagedCells } = renderHook(() => useStagedCells(store));
538
+ const { result } = renderGenerationHook();
539
+ result.current.beginStagedCellGeneration();
540
+
541
+ vi.mocked(CellId.create)
542
+ .mockReturnValueOnce(cellId("accepted-cell"))
543
+ .mockReturnValueOnce(cellId("pending-cell"));
544
+ result.current.onData({
545
+ type: "data-notebook-cells-completion",
546
+ data: {
547
+ cells: [
548
+ { language: "python", code: "accepted" },
549
+ { language: "python", code: "pending" },
550
+ ],
551
+ },
552
+ });
553
+ result.current.finishStagedCellGeneration(true);
554
+ stagedCells.current.removeStagedCell(cellId("accepted-cell"));
555
+ stagedCells.current.addStagedCell({
556
+ cellId: cellId("chat-edited-cell"),
557
+ edit: { type: "update_cell", previousCode: "before" },
558
+ });
559
+
560
+ expect(result.current.discardOwnedStagedCells()).toBe(true);
561
+ expect(mockDeleteCellCallback).not.toHaveBeenCalledWith({
562
+ cellId: cellId("accepted-cell"),
563
+ });
564
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({
565
+ cellId: cellId("pending-cell"),
566
+ });
567
+ expect(mockDeleteCellCallback).not.toHaveBeenCalledWith({
568
+ cellId: cellId("chat-edited-cell"),
569
+ });
570
+ expect(store.get(stagedAICellsAtom)).toEqual(
571
+ new Map([
572
+ [
573
+ cellId("chat-edited-cell"),
574
+ { type: "update_cell", previousCode: "before" },
575
+ ],
576
+ ]),
577
+ );
578
+ });
579
+
580
+ it.each([
581
+ { name: "no cells", cells: [] },
582
+ {
583
+ name: "an empty cell",
584
+ cells: [{ language: "python" as const, code: "" }],
585
+ },
586
+ ])("should reject $name", ({ cells }) => {
587
+ const { result } = renderGenerationHook();
588
+ result.current.beginStagedCellGeneration();
589
+
590
+ expect(() =>
591
+ result.current.onData({
592
+ type: "data-notebook-cells-completion",
593
+ data: { cells },
594
+ }),
595
+ ).toThrow();
463
596
  });
464
597
 
465
- it("should create cells when text-delta is received and a stream has been created", () => {
466
- const { result } = renderHook(() => useStagedCells(store));
467
- result.current.onStream({ type: "text-start", id: "test-id" });
598
+ it("should create cells from a validated completion", () => {
599
+ const { result } = renderGenerationHook();
600
+ result.current.beginStagedCellGeneration();
468
601
 
469
602
  // Mock CellId.create to return a predictable ID
470
603
  const mockCellId = cellId("mock-cell-id");
471
604
  vi.mocked(CellId.create).mockReturnValue(mockCellId);
472
605
 
473
- result.current.onStream({
474
- type: "text-delta",
475
- id: "test-id",
476
- delta: "some code",
606
+ result.current.onData({
607
+ type: "data-notebook-cells-completion",
608
+ data: {
609
+ cells: [{ language: "python", code: "print('```')" }],
610
+ },
477
611
  });
478
612
 
479
613
  expect(mockCreateNewCell).toHaveBeenCalledWith({
480
614
  cellId: "__end__",
481
- code: "some code",
615
+ code: "print('```')",
482
616
  before: false,
483
617
  newCellId: "mock-cell-id",
484
618
  });
485
619
  });
486
620
 
487
- it("should handle delta chunks", () => {
488
- const { result } = renderHook(() => useStagedCells(store));
489
- result.current.onStream({ type: "text-start", id: "test-id" });
621
+ it("should create multiple cells", () => {
622
+ const { result } = renderGenerationHook();
623
+ result.current.beginStagedCellGeneration();
624
+
625
+ vi.mocked(CellId.create)
626
+ .mockReturnValueOnce(cellId("first-cell"))
627
+ .mockReturnValueOnce(cellId("second-cell"));
628
+
629
+ result.current.onData({
630
+ type: "data-notebook-cells-completion",
631
+ data: {
632
+ cells: [
633
+ { language: "python", code: "value = 1" },
634
+ { language: "python", code: "value" },
635
+ ],
636
+ },
637
+ });
490
638
 
491
- const mockCellId = cellId("mock-cell-id");
492
- vi.mocked(CellId.create).mockReturnValue(mockCellId);
639
+ expect(mockCreateNewCell).toHaveBeenCalledWith(
640
+ expect.objectContaining({
641
+ code: "value = 1",
642
+ newCellId: "first-cell",
643
+ }),
644
+ );
645
+ expect(mockCreateNewCell).toHaveBeenCalledWith(
646
+ expect.objectContaining({
647
+ code: "value",
648
+ newCellId: "second-cell",
649
+ }),
650
+ );
651
+ });
652
+
653
+ it("should apply cumulative snapshots without recreating cells", () => {
654
+ const { result } = renderGenerationHook();
655
+ result.current.beginStagedCellGeneration();
493
656
 
494
- result.current.onStream({
495
- type: "text-delta",
496
- id: "test-id",
497
- delta: "``",
657
+ vi.mocked(CellId.create)
658
+ .mockReturnValueOnce(cellId("first-cell"))
659
+ .mockReturnValueOnce(cellId("second-cell"));
660
+
661
+ result.current.onData({
662
+ type: "data-notebook-cells-completion",
663
+ data: {
664
+ cells: [{ language: "python", code: "value" }],
665
+ },
666
+ });
667
+ result.current.onData({
668
+ type: "data-notebook-cells-completion",
669
+ data: {
670
+ cells: [
671
+ { language: "python", code: "value = 1" },
672
+ { language: "python", code: "value" },
673
+ ],
674
+ },
675
+ });
676
+ result.current.onData({
677
+ type: "data-notebook-cells-completion",
678
+ data: {
679
+ cells: [
680
+ { language: "python", code: "value = 1" },
681
+ { language: "python", code: "value + 1" },
682
+ ],
683
+ },
498
684
  });
499
685
 
500
- expect(mockCreateNewCell).toHaveBeenCalledWith({
501
- cellId: "__end__",
502
- code: "``",
503
- before: false,
504
- newCellId: "mock-cell-id",
686
+ expect(mockCreateNewCell).toHaveBeenCalledTimes(2);
687
+ expect(store.get(stagedAICellsAtom).has(cellId("first-cell"))).toBe(true);
688
+ expect(store.get(stagedAICellsAtom).has(cellId("second-cell"))).toBe(true);
689
+ expect(updateEditorCodeFromPython).toHaveBeenCalledTimes(2);
690
+ });
691
+
692
+ it("should update notebook state when the cell editor is not mounted", () => {
693
+ const { result } = renderGenerationHook();
694
+ result.current.beginStagedCellGeneration();
695
+
696
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("unmounted-cell"));
697
+ result.current.onData({
698
+ type: "data-notebook-cells-completion",
699
+ data: {
700
+ cells: [{ language: "python", code: "value" }],
701
+ },
505
702
  });
506
703
 
507
- result.current.onStream({
508
- type: "text-delta",
509
- id: "test-id",
510
- delta: "```python\nsome code",
704
+ vi.mocked(getCellEditorView).mockReturnValueOnce(null);
705
+ result.current.onData({
706
+ type: "data-notebook-cells-completion",
707
+ data: {
708
+ cells: [{ language: "python", code: "value = 1" }],
709
+ },
511
710
  });
512
711
 
513
- // Now the cell is recognized and only some code is seen
514
- expect(vi.mocked(updateEditorCodeFromPython)).toHaveBeenCalledWith(
515
- mockCellHandle.current.editorViewOrNull,
516
- "some code",
712
+ expect(mockUpdateCellCode).toHaveBeenCalledWith({
713
+ cellId: cellId("unmounted-cell"),
714
+ code: "value = 1",
715
+ formattingChange: false,
716
+ });
717
+ });
718
+
719
+ it("should remove trailing cells when a retry produces a shorter snapshot", () => {
720
+ const { result } = renderGenerationHook();
721
+ result.current.beginStagedCellGeneration();
722
+
723
+ vi.mocked(CellId.create)
724
+ .mockReturnValueOnce(cellId("first-cell"))
725
+ .mockReturnValueOnce(cellId("second-cell"));
726
+
727
+ result.current.onData({
728
+ type: "data-notebook-cells-completion",
729
+ data: {
730
+ cells: [
731
+ { language: "python", code: "value = 1" },
732
+ { language: "python", code: "value" },
733
+ ],
734
+ },
735
+ });
736
+ result.current.onData({
737
+ type: "data-notebook-cells-completion",
738
+ data: {
739
+ cells: [{ language: "python", code: "replacement = 2" }],
740
+ },
741
+ });
742
+
743
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({
744
+ cellId: cellId("second-cell"),
745
+ });
746
+ expect(store.get(stagedAICellsAtom).has(cellId("first-cell"))).toBe(true);
747
+ expect(store.get(stagedAICellsAtom).has(cellId("second-cell"))).toBe(false);
748
+ });
749
+
750
+ it("should remove a provisional marimo import when a retry no longer needs it", () => {
751
+ const { result } = renderGenerationHook();
752
+ result.current.beginStagedCellGeneration();
753
+
754
+ vi.mocked(CellId.create)
755
+ .mockReturnValueOnce(cellId("generated-cell"))
756
+ .mockReturnValueOnce(cellId("marimo-import"));
757
+
758
+ result.current.onData({
759
+ type: "data-notebook-cells-completion",
760
+ data: {
761
+ cells: [{ language: "sql", code: "result = mo.sql('SELECT 1')" }],
762
+ },
763
+ });
764
+ result.current.onData({
765
+ type: "data-notebook-cells-completion",
766
+ data: {
767
+ cells: [{ language: "python", code: "result = 1" }],
768
+ },
769
+ });
770
+
771
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({
772
+ cellId: cellId("marimo-import"),
773
+ });
774
+ expect(store.get(stagedAICellsAtom).has(cellId("marimo-import"))).toBe(
775
+ false,
517
776
  );
777
+ });
778
+
779
+ it("should discard provisional cells when generation fails", () => {
780
+ const { result } = renderGenerationHook();
781
+ result.current.beginStagedCellGeneration();
782
+
783
+ vi.mocked(CellId.create).mockReturnValue(cellId("partial-cell"));
784
+ result.current.onData({
785
+ type: "data-notebook-cells-completion",
786
+ data: {
787
+ cells: [{ language: "python", code: "partial" }],
788
+ },
789
+ });
790
+ result.current.finishStagedCellGeneration(false);
791
+
792
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({
793
+ cellId: cellId("partial-cell"),
794
+ });
795
+ expect(store.get(stagedAICellsAtom)).toEqual(new Map());
796
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(false);
797
+ });
798
+
799
+ it("should not let a superseded generation mutate active cells", () => {
800
+ const firstGeneration = renderGenerationHook();
801
+ firstGeneration.result.current.beginStagedCellGeneration();
518
802
 
519
- result.current.onStream({
520
- type: "text-delta",
521
- id: "test-id",
522
- delta: "\n```",
803
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("superseded-cell"));
804
+ firstGeneration.result.current.onData({
805
+ type: "data-notebook-cells-completion",
806
+ data: {
807
+ cells: [{ language: "python", code: "old" }],
808
+ },
523
809
  });
810
+
811
+ const activeGeneration = renderGenerationHook();
812
+ activeGeneration.result.current.beginStagedCellGeneration();
813
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({
814
+ cellId: cellId("superseded-cell"),
815
+ });
816
+ mockDeleteCellCallback.mockClear();
817
+
818
+ vi.mocked(CellId.create).mockReturnValueOnce(cellId("active-cell"));
819
+ activeGeneration.result.current.onData({
820
+ type: "data-notebook-cells-completion",
821
+ data: {
822
+ cells: [{ language: "python", code: "active" }],
823
+ },
824
+ });
825
+
826
+ firstGeneration.result.current.finishStagedCellGeneration(true);
827
+ firstGeneration.result.current.onData({
828
+ type: "data-notebook-cells-completion",
829
+ data: {
830
+ cells: [{ language: "python", code: "stale" }],
831
+ },
832
+ });
833
+
834
+ expect(mockDeleteCellCallback).not.toHaveBeenCalledWith({
835
+ cellId: cellId("active-cell"),
836
+ });
837
+ expect(store.get(stagedAICellsAtom)).toEqual(
838
+ new Map([[cellId("active-cell"), { type: "add_cell" }]]),
839
+ );
840
+ expect(store.get(stagedGenerationInProgressAtom)).toBe(true);
524
841
  });
525
842
  });