@marimo-team/islands 0.25.3-dev265 → 0.25.3-dev267

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 (106) hide show
  1. package/dist/{ConnectedDataExplorerComponent-C_hj7Poi.js → ConnectedDataExplorerComponent-C4yCN38h.js} +1 -1
  2. package/dist/{ErrorBoundary-DzYPyiOs.js → ErrorBoundary-CewXPyL5.js} +64 -69
  3. package/dist/{abnfDiagram-VCTEODGH-DzrZZXRD.js → abnfDiagram-VCTEODGH-o6Ay6Q63.js} +2 -2
  4. package/dist/{any-language-editor-CFeWfC3o.js → any-language-editor-DnG26QXh.js} +1 -1
  5. package/dist/{architectureDiagram-5GKGNRK7-iF_snyFL.js → architectureDiagram-5GKGNRK7-BhiwwlVg.js} +1 -1
  6. package/dist/{chat-ui-DFxNgzXF.js → chat-ui-CbgR5ivy.js} +1964 -1740
  7. package/dist/{chunk-IMKFNOWR-B7xytD4B.js → chunk-IMKFNOWR-WMSMf4Nj.js} +1 -1
  8. package/dist/{chunk-SHT3W25Y-I8ZFP5L1.js → chunk-SHT3W25Y-BRfADrzD.js} +1 -1
  9. package/dist/{chunk-SVP7TREG-Di-uAPdT.js → chunk-SVP7TREG-CuK5QFre.js} +1 -1
  10. package/dist/{chunk-TICWLB2K-BFpUy0ud.js → chunk-TICWLB2K-Bcun_jhJ.js} +1 -1
  11. package/dist/{classDiagram-ZZMXUADV-Cqm4JGpx.js → classDiagram-ZZMXUADV-CgWDbUDT.js} +2 -2
  12. package/dist/{classDiagram-v2-VYDZK3BY-DFHRJpN3.js → classDiagram-v2-VYDZK3BY-DYmc9SFU.js} +2 -2
  13. package/dist/{code-visibility-CJExrSFe.js → code-visibility-BKBl9Rh6.js} +9 -9
  14. package/dist/{common-5mM7a8cm.js → common-N3Jd_6m9.js} +5944 -5626
  15. package/dist/{cynefinDiagram-5FMLGOSQ-cuGOeTBC.js → cynefinDiagram-5FMLGOSQ-PPKWipjT.js} +1 -1
  16. package/dist/{dagre-GXQ25YYZ-DJ76aC4k.js → dagre-GXQ25YYZ-niRBZh--.js} +1 -1
  17. package/dist/{data-grid-overlay-editor--Un2Lk6m.js → data-grid-overlay-editor-Cp59CrqZ.js} +1 -1
  18. package/dist/{diagram-S7CK7UJ4-DLLc0sw7.js → diagram-S7CK7UJ4-DPiP9rot.js} +1 -1
  19. package/dist/{diagram-UQ7AKVKN-Tz_d6qmk.js → diagram-UQ7AKVKN-B0WZuaxV.js} +1 -1
  20. package/dist/{diagram-VX7I27RA-D7SzPrDN.js → diagram-VX7I27RA-Rn42WBuN.js} +1 -1
  21. package/dist/{diagram-Z3DM3KII-qr37XV58.js → diagram-Z3DM3KII-BB93--ry.js} +1 -1
  22. package/dist/{dropdown-menu-CkJ7ErY6.js → dropdown-menu-BpImEfn3.js} +1211 -1205
  23. package/dist/{ebnfDiagram-PWID7BFC-BKOQb4J0.js → ebnfDiagram-PWID7BFC-V4t5h6mZ.js} +2 -2
  24. package/dist/{erDiagram-RLTQ6QDP-CM15Vq68.js → erDiagram-RLTQ6QDP-C1MsAKXd.js} +1 -1
  25. package/dist/{error-banner-DbRFOd2O.js → error-banner-DPc9eIw5.js} +3 -3
  26. package/dist/{error-entries-CMoaUX9V.js → error-entries-CJGdD2ky.js} +49 -42
  27. package/dist/{feedback-button-C2NUXbzh.js → feedback-button-Qc1f9iPq.js} +70 -70
  28. package/dist/{flowDiagram-HODETNUW-Dt3N5yFT.js → flowDiagram-HODETNUW-DHjeJP96.js} +2 -2
  29. package/dist/{glide-data-editor-Dj9U-NK9.js → glide-data-editor-hCq3K0sd.js} +2042 -2041
  30. package/dist/{glide-utils-Ba9WD2IN.js → glide-utils-DC3sk6EX.js} +28 -34
  31. package/dist/{globals-CkbsxIrq.js → globals-B2_uMYDY.js} +1 -1
  32. package/dist/{html-to-image-D_5CMreP.js → html-to-image-S7spRQCi.js} +5526 -5828
  33. package/dist/{infoDiagram-27XIBGKW-C7axJMCl.js → infoDiagram-27XIBGKW-Dovv6iKx.js} +1 -1
  34. package/dist/{ishikawaDiagram-5VMMS53U-CdC9Eur7.js → ishikawaDiagram-5VMMS53U-D2nyygsc.js} +1 -1
  35. package/dist/{kanban-definition-UXKFOSKX-BHnMIgD7.js → kanban-definition-UXKFOSKX-KyVx0uTY.js} +1 -1
  36. package/dist/main.js +1288 -1287
  37. package/dist/{mermaid-DFYN_PXd.js → mermaid-B-jKSwGt.js} +33 -33
  38. package/dist/{mindmap-definition-YA3MSWOX-BagHoCGU.js → mindmap-definition-YA3MSWOX-Bz9tKQFl.js} +1 -1
  39. package/dist/{number-overlay-editor-H8QAAmdr.js → number-overlay-editor-CfNYvbdz.js} +1 -1
  40. package/dist/{pegDiagram-XKGWAZYB-8IIZczip.js → pegDiagram-XKGWAZYB-C_qpq6Tv.js} +2 -2
  41. package/dist/pencil-DcT8AE0h.js +10 -0
  42. package/dist/{pieDiagram-E7YTZNPT-CE6dKBIr.js → pieDiagram-E7YTZNPT-Bp9ucgI4.js} +1 -1
  43. package/dist/{process-output-CGFNVC60.js → process-output-f34bkXTm.js} +2 -2
  44. package/dist/{railroadDiagram-O6MQD6OU-CkexPq3b.js → railroadDiagram-O6MQD6OU-tq-VEH6j.js} +2 -2
  45. package/dist/{requirementDiagram-BXWQKSXE-B0-OkuAe.js → requirementDiagram-BXWQKSXE-DW-vHadb.js} +1 -1
  46. package/dist/{reveal-component-BperrCFw.js → reveal-component-CUxv-kkP.js} +9 -9
  47. package/dist/{sequenceDiagram-WJ2MYXX4-Dxysg8DX.js → sequenceDiagram-WJ2MYXX4-Dv9U9qRP.js} +1 -1
  48. package/dist/{stateDiagram-D77RDMKH-DTEN3-OJ.js → stateDiagram-D77RDMKH-DTmPQ0EB.js} +2 -2
  49. package/dist/{stateDiagram-v2-MP3YSRHH-vktk3Xc5.js → stateDiagram-v2-MP3YSRHH-CkCif0Iw.js} +2 -2
  50. package/dist/style.css +1 -1
  51. package/dist/{swimlanes-42K2YHIH-Cjveh9OM.js → swimlanes-42K2YHIH-21kCq6l8.js} +1 -1
  52. package/dist/{swimlanesDiagram-VR7AAH4N-Bdr7Ev_2.js → swimlanesDiagram-VR7AAH4N-BpRJP6KU.js} +2 -2
  53. package/dist/{timeline-definition-24CTP7MA-KIuDcefn.js → timeline-definition-24CTP7MA-Ckgh4lS4.js} +1 -1
  54. package/dist/{toast-CY1_Qv41.js → toast-DpczUryP.js} +1 -1
  55. package/dist/{vega-component-C-lqjV0q.js → vega-component-CF9kI6C4.js} +1 -1
  56. package/dist/{vennDiagram-4TSXK5OY-DrIOUHfb.js → vennDiagram-4TSXK5OY-CDei2Bt3.js} +1 -1
  57. package/dist/{wardleyDiagram-VM6X3IG4-CJNjf4OH.js → wardleyDiagram-VM6X3IG4-CXavT601.js} +1 -1
  58. package/dist/{xychartDiagram-S5SC5T6Z-DEshFho-.js → xychartDiagram-S5SC5T6Z-CvJhcZri.js} +1 -1
  59. package/package.json +1 -1
  60. package/src/components/chat/__tests__/chat-display.test.tsx +157 -86
  61. package/src/components/chat/__tests__/chat-utils.test.ts +42 -0
  62. package/src/components/chat/__tests__/turn-timing.test.ts +84 -0
  63. package/src/components/chat/__tests__/use-assistant-turn.test.ts +95 -0
  64. package/src/components/chat/__tests__/user-message.test.tsx +27 -0
  65. package/src/components/chat/acp/agent-panel.tsx +2 -0
  66. package/src/components/chat/agent-activity.tsx +132 -0
  67. package/src/components/chat/chat-display.tsx +125 -96
  68. package/src/components/chat/chat-panel.tsx +123 -106
  69. package/src/components/chat/chat-utils.ts +12 -24
  70. package/src/components/chat/tool-call/__tests__/tool-labels.test.ts +76 -0
  71. package/src/components/chat/tool-call/__tests__/tool-step.test.tsx +57 -0
  72. package/src/components/chat/tool-call/shared.ts +6 -0
  73. package/src/components/chat/tool-call/tool-call-view.tsx +22 -76
  74. package/src/components/chat/tool-call/tool-labels.ts +172 -0
  75. package/src/components/chat/tool-call/tool-step.tsx +145 -0
  76. package/src/components/chat/turn-timing.ts +73 -0
  77. package/src/components/chat/use-assistant-turn.ts +65 -0
  78. package/src/components/chat/use-chat-generation.ts +5 -4
  79. package/src/components/chat/user-message.tsx +145 -0
  80. package/src/components/editor/actions/__tests__/useCellActionButton.test.tsx +18 -1
  81. package/src/components/editor/actions/useCellActionButton.tsx +3 -0
  82. package/src/components/editor/ai/completion-handlers.tsx +0 -37
  83. package/src/components/editor/cell/StagedAICell.tsx +51 -144
  84. package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +193 -32
  85. package/src/components/editor/chrome/wrapper/app-chrome.tsx +2 -2
  86. package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +145 -81
  87. package/src/components/editor/chrome/wrapper/pending-ai-changes-badge.tsx +25 -0
  88. package/src/components/editor/chrome/wrapper/sidebar.tsx +6 -0
  89. package/src/components/editor/links/cell-link.tsx +4 -4
  90. package/src/components/editor/notebook-cell.tsx +3 -11
  91. package/src/components/ui/toast.tsx +1 -1
  92. package/src/core/ai/__tests__/staged-cells.test.ts +74 -126
  93. package/src/core/ai/staged-cells.ts +48 -38
  94. package/src/core/cells/scrollCellIntoView.ts +6 -0
  95. package/src/core/codemirror/placeholder/__tests__/extensions.test.ts +59 -0
  96. package/src/core/codemirror/placeholder/extensions.ts +23 -0
  97. package/src/core/session/__tests__/useNotebookSession.hook.test.tsx +34 -0
  98. package/src/core/session/useNotebookSession.tsx +7 -2
  99. package/src/css/app/Cell.css +7 -36
  100. package/src/plugins/impl/chat/chat-ui.tsx +2 -3
  101. package/src/plugins/impl/common/error-banner.tsx +5 -3
  102. package/src/components/chat/reasoning-accordion.tsx +0 -61
  103. package/src/components/chat/tool-call/tool-activity-group.tsx +0 -49
  104. package/src/components/chat/tool-call/tool-error-card.tsx +0 -81
  105. package/src/components/chat/tool-call/tool-history-row.tsx +0 -147
  106. package/src/components/icons/loading-ellipsis.tsx +0 -46
@@ -5,6 +5,7 @@ import { useCellIds, useCellNames } from "@/core/cells/cells";
5
5
  import { useCellActions } from "@/core/cells/actions";
6
6
  import { type CellId, HTMLCellId, SCRATCH_CELL_ID } from "@/core/cells/ids";
7
7
  import { displayCellName } from "@/core/cells/names";
8
+ import { scrollToCell } from "@/core/cells/scrollCellIntoView";
8
9
  import { goToCellLine } from "@/core/codemirror/go-to-definition/utils";
9
10
  import { useFilename } from "@/core/saving/filename";
10
11
  import { cn } from "@/utils/cn";
@@ -106,16 +107,15 @@ export function scrollAndHighlightCell(
106
107
  skipScroll?: boolean,
107
108
  ): boolean {
108
109
  const cellHtmlId = HTMLCellId.create(cellId);
109
- const cell: HTMLElement | null = document.getElementById(cellHtmlId);
110
+ const cell = skipScroll
111
+ ? document.getElementById(cellHtmlId)
112
+ : scrollToCell(cellId);
110
113
  const isCellErrored = cell?.classList.contains("has-error");
111
114
 
112
115
  if (cell === null) {
113
116
  Logger.error(`Cell ${cellHtmlId} not found on page.`);
114
117
  return false;
115
118
  }
116
- if (!skipScroll) {
117
- cell.scrollIntoView({ behavior: "smooth", block: "center" });
118
- }
119
119
 
120
120
  if (variant === "destructive" || (isCellErrored && variant === undefined)) {
121
121
  cell.classList.add("error-outline");
@@ -78,10 +78,7 @@ import { CollapsedCellBanner, CollapseToggle } from "./cell/collapse";
78
78
  import { DeleteButton } from "./cell/DeleteButton";
79
79
  import { PendingDeleteConfirmation } from "./cell/PendingDeleteConfirmation";
80
80
  import { RunButton } from "./cell/RunButton";
81
- import {
82
- StagedAICellBackground,
83
- StagedAICellFooter,
84
- } from "./cell/StagedAICell";
81
+ import { StagedAIDeletionMarker } from "./cell/StagedAICell";
85
82
  import { useDeleteCellCallback } from "./cell/useDeleteCell";
86
83
  import { useRunCell } from "./cell/useRunCells";
87
84
  import { cellDomProps } from "./common";
@@ -674,7 +671,7 @@ const EditableCellComponent = ({
674
671
  data-has-output-above={hasOutputAbove}
675
672
  data-hidden={isMarkdownCodeHidden}
676
673
  >
677
- <StagedAICellBackground cellId={cellId} />
674
+ <StagedAIDeletionMarker cellId={cellId} />
678
675
  <div className="absolute right-2 -top-4 z-10">
679
676
  <CellToolbar
680
677
  edited={cellData.edited}
@@ -837,7 +834,6 @@ const EditableCellComponent = ({
837
834
  />
838
835
  )}
839
836
  <PendingDeleteConfirmation cellId={cellId} />
840
- {!isPresenting && <StagedAICellFooter cellId={cellId} />}
841
837
  </div>
842
838
  {isCollapsed && !isPresenting && (
843
839
  <CollapsedCellBanner
@@ -1198,10 +1194,7 @@ const SetupCellComponent = ({
1198
1194
  data-has-output-above={false}
1199
1195
  data-hidden={!isCellCodeShown}
1200
1196
  >
1201
- <StagedAICellBackground
1202
- cellId={cellId}
1203
- className="mo-ai-setup-cell"
1204
- />
1197
+ <StagedAIDeletionMarker cellId={cellId} />
1205
1198
  <div className="absolute right-2 -top-4 z-10">
1206
1199
  <CellToolbar
1207
1200
  edited={cellData.edited}
@@ -1326,7 +1319,6 @@ const SetupCellComponent = ({
1326
1319
  cellId={cellId}
1327
1320
  debuggerActive={cellRuntime.debuggerActive}
1328
1321
  />
1329
- <StagedAICellFooter cellId={cellId} />
1330
1322
  </div>
1331
1323
  </div>
1332
1324
  </CellActionsContextMenu>
@@ -29,7 +29,7 @@ const VARIANT_CLASSES = {
29
29
  default:
30
30
  "bg-background border forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]",
31
31
  danger:
32
- "group destructive text-error border-destructive bg-(--red-1) shadow-md-solid shadow-error forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]",
32
+ "group destructive text-(--red-11) border-destructive bg-(--red-1) shadow-md-solid shadow-error forced-colors:border-[CanvasText] forced-colors:bg-[Canvas] forced-colors:text-[CanvasText]",
33
33
  } as const satisfies Record<string, string>;
34
34
 
35
35
  type ToastVariant = keyof typeof VARIANT_CLASSES;
@@ -5,8 +5,9 @@ import { getDefaultStore, Provider } from "jotai";
5
5
  import { createElement, type ReactNode } from "react";
6
6
  import { beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { cellId } from "@/__tests__/branded";
8
- import { CellId } from "@/core/cells/ids";
8
+ import type { CellId } from "@/core/cells/ids";
9
9
  import {
10
+ type Edit,
10
11
  stagedAICellsAtom,
11
12
  useStagedAICell,
12
13
  useStagedCells,
@@ -16,7 +17,6 @@ import {
16
17
  const { createActions, reducer, initialState } = visibleForTesting;
17
18
 
18
19
  // Mock the dependencies
19
- const mockCreateCell = vi.fn();
20
20
  const mockSetCellCode = vi.fn();
21
21
  const mockDeleteCellCallback = vi.fn();
22
22
 
@@ -31,7 +31,6 @@ const mockCellHandle = {
31
31
 
32
32
  vi.mock("../../cells/actions", () => ({
33
33
  useCellActions: () => ({
34
- createCell: mockCreateCell,
35
34
  setCellCode: mockSetCellCode,
36
35
  }),
37
36
  }));
@@ -55,13 +54,6 @@ vi.mock("../../codemirror/language/cell-code", () => ({
55
54
  prepareCellCodeUpdate: vi.fn(() => ({ effects: [] })),
56
55
  }));
57
56
 
58
- // Mock CellId.create
59
- vi.mock("@/core/cells/ids", () => ({
60
- CellId: {
61
- create: vi.fn(),
62
- },
63
- }));
64
-
65
57
  describe("staged-cells", () => {
66
58
  let store: ReturnType<typeof getDefaultStore>;
67
59
  let cellId1: CellId;
@@ -74,7 +66,6 @@ describe("staged-cells", () => {
74
66
 
75
67
  // Reset mocks
76
68
  vi.clearAllMocks();
77
- vi.mocked(CellId.create).mockReset();
78
69
 
79
70
  // Reset the atom state
80
71
  store.set(stagedAICellsAtom, new Map());
@@ -143,6 +134,31 @@ describe("staged-cells", () => {
143
134
  });
144
135
  });
145
136
 
137
+ it("drops a replaced cell while preserving the four remaining pending edits", () => {
138
+ const original = cellId("original");
139
+ const remaining = [cellId("a"), cellId("b"), cellId("c"), cellId("d")];
140
+ const state = new Map<CellId, Edit>([
141
+ [original, { type: "add_cell" }],
142
+ [remaining[0], { type: "add_cell" }],
143
+ [remaining[1], { type: "add_cell" }],
144
+ [remaining[2], { type: "update_cell", previousCode: "x = 1" }],
145
+ [remaining[3], { type: "delete_cell", previousCode: "y = 2" }],
146
+ ]);
147
+ const next = reducer(state, {
148
+ type: "reconcileStagedCells",
149
+ payload: remaining,
150
+ });
151
+ expect(next).toEqual(
152
+ new Map([...state].filter(([id]) => id !== original)),
153
+ );
154
+ expect(
155
+ reducer(next, { type: "reconcileStagedCells", payload: remaining }),
156
+ ).toBe(next);
157
+ expect(
158
+ reducer(next, { type: "reconcileStagedCells", payload: [] }),
159
+ ).toEqual(new Map());
160
+ });
161
+
146
162
  it("should remove cell IDs", () => {
147
163
  const state = new Map([
148
164
  [cellId1, { type: "add_cell" as const }],
@@ -242,134 +258,66 @@ describe("staged-cells", () => {
242
258
  });
243
259
  });
244
260
 
245
- describe("useStagedCells hook", () => {
246
- it("should create a staged cell with code", () => {
247
- const { result } = renderHook(() => useStagedCells(store));
248
- const testCode = "print('hello world')";
249
-
250
- // Mock CellId.create to return a predictable ID
251
- const mockCellId = cellId("mock-cell-id");
252
- vi.mocked(CellId.create).mockReturnValue(mockCellId);
253
-
254
- const returnedCellId = result.current.createStagedCell(testCode);
255
-
256
- expect(returnedCellId).toBe(mockCellId);
257
- expect(mockCreateCell).toHaveBeenCalledWith({
258
- code: testCode,
259
- id: mockCellId,
260
- });
261
- });
262
-
263
- it("should delete a staged cell", () => {
264
- const { result } = renderHook(() => useStagedCells(store));
265
- const testCellId = cellId("test-cell-id");
266
-
267
- result.current.deleteStagedCell(testCellId);
261
+ describe("useStagedCells", () => {
262
+ const stage = (id: CellId, edit: Edit) =>
263
+ store.set(stagedAICellsAtom, new Map([[id, edit]]));
268
264
 
269
- expect(mockDeleteCellCallback).toHaveBeenCalledWith({
270
- cellId: testCellId,
271
- });
272
- });
273
-
274
- it("should add staged cell with edit info", () => {
275
- const { result } = renderHook(() => useStagedCells(store));
276
-
277
- result.current.addStagedCell({
278
- cellId: cellId1,
279
- edit: { type: "update_cell", previousCode: "old code" },
280
- });
281
-
282
- // Check that the cell was added to the atom with edit info
283
- const state = store.get(stagedAICellsAtom);
284
- expect(state.has(cellId1)).toBe(true);
285
- expect(state.get(cellId1)).toEqual({
265
+ it("keeps added and updated cells by unstaging them", () => {
266
+ const { result } = renderHook(() => useStagedCells());
267
+ stage(cellId1, { type: "update_cell", previousCode: "x = 1" });
268
+ result.current.acceptStagedCell(cellId1, {
286
269
  type: "update_cell",
287
- previousCode: "old code",
270
+ previousCode: "x = 1",
288
271
  });
272
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
273
+ expect(mockDeleteCellCallback).not.toHaveBeenCalled();
289
274
  });
290
275
 
291
- it("should remove staged cell", () => {
292
- const { result } = renderHook(() => useStagedCells(store));
293
-
294
- // First add cells
295
- result.current.addStagedCell({
296
- cellId: cellId1,
297
- edit: { type: "add_cell" },
298
- });
299
- result.current.addStagedCell({
300
- cellId: cellId2,
301
- edit: { type: "add_cell" },
302
- });
303
-
304
- // Then remove one
305
- result.current.removeStagedCell(cellId1);
306
-
307
- // Check that only the remaining cell is in the map
308
- const state = store.get(stagedAICellsAtom);
309
- expect(state.has(cellId1)).toBe(false);
310
- expect(state.has(cellId2)).toBe(true);
276
+ it("deletes the cell when a proposed deletion is kept", () => {
277
+ const { result } = renderHook(() => useStagedCells());
278
+ const edit: Edit = { type: "delete_cell", previousCode: "x = 1" };
279
+ stage(cellId1, edit);
280
+ result.current.acceptStagedCell(cellId1, edit);
281
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({ cellId: cellId1 });
282
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
311
283
  });
312
284
 
313
- it("should handle multiple operations correctly", () => {
314
- const { result } = renderHook(() => useStagedCells(store));
315
-
316
- // Create a staged cell
317
- const mockCellId = cellId("mock-cell-id");
318
- vi.mocked(CellId.create).mockReturnValue(mockCellId);
319
-
320
- const createdCellId = result.current.createStagedCell("test code");
321
-
322
- // Verify it was created and added
323
- expect(createdCellId).toBe(mockCellId);
324
- expect(mockCreateCell).toHaveBeenCalled();
325
-
326
- let state = store.get(stagedAICellsAtom);
327
- expect(state.has(mockCellId)).toBe(true);
328
- expect(state.get(mockCellId)).toEqual({ type: cellId("add_cell") });
329
-
330
- // Delete the staged cell
331
- result.current.deleteStagedCell(mockCellId);
332
- expect(mockDeleteCellCallback).toHaveBeenCalledWith({
333
- cellId: mockCellId,
334
- });
335
-
336
- // Verify it was removed from staged cells
337
- state = store.get(stagedAICellsAtom);
338
- expect(state.has(mockCellId)).toBe(false);
285
+ it("restores the previous code when an update is discarded", () => {
286
+ const { result } = renderHook(() => useStagedCells());
287
+ const edit: Edit = { type: "update_cell", previousCode: "x = 1" };
288
+ stage(cellId1, edit);
289
+ result.current.rejectStagedCell(cellId1, edit);
290
+ expect(
291
+ mockCellHandle.current.editorViewOrNull.dispatch,
292
+ ).toHaveBeenCalled();
293
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
339
294
  });
340
295
 
341
- it("should track edit history for updated cells", () => {
342
- const { result } = renderHook(() => useStagedCells(store));
343
-
344
- // Add a cell with update_cell edit type
345
- result.current.addStagedCell({
296
+ it("writes to the document when the editor is not mounted", async () => {
297
+ const { getCellEditorView } = await import("../../cells/cells");
298
+ vi.mocked(getCellEditorView).mockReturnValueOnce(undefined);
299
+ const { result } = renderHook(() => useStagedCells());
300
+ const edit: Edit = { type: "update_cell", previousCode: "x = 1" };
301
+ stage(cellId1, edit);
302
+ result.current.rejectStagedCell(cellId1, edit);
303
+ expect(mockSetCellCode).toHaveBeenCalledWith({
346
304
  cellId: cellId1,
347
- edit: { type: "update_cell", previousCode: "previous code" },
348
- });
349
-
350
- const state = store.get(stagedAICellsAtom);
351
- const edit = state.get(cellId1);
352
- expect(edit).toEqual({
353
- type: "update_cell",
354
- previousCode: "previous code",
305
+ code: "x = 1",
355
306
  });
356
307
  });
357
308
 
358
- it("should track edit history for deleted cells", () => {
359
- const { result } = renderHook(() => useStagedCells(store));
360
-
361
- // Add a cell with delete_cell edit type
362
- result.current.addStagedCell({
363
- cellId: cellId1,
364
- edit: { type: "delete_cell", previousCode: "deleted content" },
365
- });
366
-
367
- const state = store.get(stagedAICellsAtom);
368
- const edit = state.get(cellId1);
369
- expect(edit).toEqual({
370
- type: "delete_cell",
371
- previousCode: "deleted content",
372
- });
309
+ it("deletes an added cell and leaves a proposed deletion in place when discarded", () => {
310
+ const { result } = renderHook(() => useStagedCells());
311
+ stage(cellId1, { type: "add_cell" });
312
+ result.current.rejectStagedCell(cellId1, { type: "add_cell" });
313
+ expect(mockDeleteCellCallback).toHaveBeenCalledWith({ cellId: cellId1 });
314
+
315
+ mockDeleteCellCallback.mockClear();
316
+ const deletion: Edit = { type: "delete_cell", previousCode: "x = 1" };
317
+ stage(cellId2, deletion);
318
+ result.current.rejectStagedCell(cellId2, deletion);
319
+ expect(mockDeleteCellCallback).not.toHaveBeenCalled();
320
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
373
321
  });
374
322
  });
375
323
  });
@@ -4,13 +4,11 @@ import { useAtomValue } from "jotai";
4
4
  import { selectAtom } from "jotai/utils";
5
5
  import { useMemo } from "react";
6
6
  import { useDeleteCellCallback } from "@/components/editor/cell/useDeleteCell";
7
- import { CellId } from "@/core/cells/ids";
7
+ import type { CellId } from "@/core/cells/ids";
8
8
  import { createReducerAndAtoms } from "@/utils/createReducer";
9
- import { Logger } from "@/utils/Logger";
10
9
  import { getCellEditorView } from "../cells/cells";
11
10
  import { useCellActions } from "../cells/actions";
12
11
  import { prepareCellCodeUpdate } from "../codemirror/language/cell-code";
13
- import type { JotaiStore } from "../state/jotai";
14
12
  import type { EditType } from "./tools/edit-notebook-tool";
15
13
 
16
14
  /** Pending AI edits shared by chat and agent workflows. */
@@ -36,6 +34,18 @@ const {
36
34
  const { cellId, edit } = action;
37
35
  return new Map([...state, [cellId, edit]]);
38
36
  },
37
+ reconcileStagedCells: (state, cellIds: readonly string[]) => {
38
+ const existing = new Set(cellIds);
39
+ const removed = [...state.keys()].filter((id) => !existing.has(id));
40
+ if (removed.length === 0) {
41
+ return state;
42
+ }
43
+ const next = new Map(state);
44
+ for (const id of removed) {
45
+ next.delete(id);
46
+ }
47
+ return next;
48
+ },
39
49
  removeStagedCell: (state, cellId: CellId) => {
40
50
  if (!state.has(cellId)) {
41
51
  return state;
@@ -48,44 +58,27 @@ const {
48
58
 
49
59
  export { useStagedAICellsActions };
50
60
 
51
- export function useStagedAICell(cellId: CellId): Edit | undefined {
61
+ export function useStagedAICell(cellId: CellId | undefined): Edit | undefined {
52
62
  const stagedCellAtom = useMemo(
53
63
  () =>
54
- selectAtom(stagedAICellsAtom, (cells) => cells.get(cellId), Object.is),
64
+ selectAtom(
65
+ stagedAICellsAtom,
66
+ (cells) => (cellId === undefined ? undefined : cells.get(cellId)),
67
+ Object.is,
68
+ ),
55
69
  [cellId],
56
70
  );
57
71
  return useAtomValue(stagedCellAtom);
58
72
  }
59
73
 
60
- interface UpdateStagedCellAction {
61
- cellId: CellId;
62
- code: string;
63
- }
64
-
65
- /** Manage staged cells without assuming which AI workflow owns them. */
66
- export function useStagedCells(store: JotaiStore) {
67
- const { addStagedCell, removeStagedCell } = useStagedAICellsActions();
68
- const { setCellCode, createCell } = useCellActions();
74
+ /** Keep or discard staged cells, whichever AI workflow staged them. */
75
+ export function useStagedCells() {
76
+ const { removeStagedCell } = useStagedAICellsActions();
77
+ const { setCellCode } = useCellActions();
69
78
  const deleteCellCallback = useDeleteCellCallback();
70
79
 
71
- const createStagedCell = (code: string): CellId => {
72
- const newCellId = CellId.create();
73
- addStagedCell({ cellId: newCellId, edit: { type: "add_cell" } });
74
- createCell({ code, id: newCellId });
75
- return newCellId;
76
- };
77
-
78
- const updateStagedCell = (opts: UpdateStagedCellAction) => {
79
- const { cellId, code } = opts;
80
- const stagedAICells = store.get(stagedAICellsAtom);
81
-
82
- if (!stagedAICells.has(cellId)) {
83
- Logger.error("Staged cell not found", { cellId });
84
- return;
85
- }
86
-
87
- // The AI may change the cell's language, and only the editor infers
88
- // one. Without a mounted editor the code arrives through the document.
80
+ // Only a mounted editor infers the cell's language; otherwise use the document.
81
+ const writeCellCode = (cellId: CellId, code: string) => {
89
82
  const editorView = getCellEditorView(cellId);
90
83
  if (editorView) {
91
84
  const update = prepareCellCodeUpdate(editorView.state, code, {
@@ -104,13 +97,30 @@ export function useStagedCells(store: JotaiStore) {
104
97
  deleteCellCallback({ cellId });
105
98
  };
106
99
 
107
- return {
108
- createStagedCell,
109
- updateStagedCell,
110
- addStagedCell,
111
- removeStagedCell,
112
- deleteStagedCell,
100
+ const acceptStagedCell = (cellId: CellId, edit: Edit) => {
101
+ if (edit.type === "delete_cell") {
102
+ deleteStagedCell(cellId);
103
+ } else {
104
+ removeStagedCell(cellId);
105
+ }
106
+ };
107
+
108
+ const rejectStagedCell = (cellId: CellId, edit: Edit) => {
109
+ switch (edit.type) {
110
+ case "update_cell":
111
+ writeCellCode(cellId, edit.previousCode);
112
+ removeStagedCell(cellId);
113
+ break;
114
+ case "add_cell":
115
+ deleteStagedCell(cellId);
116
+ break;
117
+ case "delete_cell":
118
+ removeStagedCell(cellId);
119
+ break;
120
+ }
113
121
  };
122
+
123
+ return { acceptStagedCell, rejectStagedCell };
114
124
  }
115
125
 
116
126
  export { stagedAICellsAtom };
@@ -160,3 +160,9 @@ export function scrollCellIntoView(cellId: CellId): void {
160
160
  );
161
161
  }
162
162
  }
163
+
164
+ export function scrollToCell(cellId: CellId): HTMLElement | null {
165
+ const cell = document.getElementById(HTMLCellId.create(cellId));
166
+ cell?.scrollIntoView({ behavior: "smooth", block: "center" });
167
+ return cell;
168
+ }
@@ -145,4 +145,63 @@ describe("clickablePlaceholderExtension", () => {
145
145
 
146
146
  view.destroy();
147
147
  });
148
+
149
+ describe("caret coordinates", () => {
150
+ const original = Range.prototype.getClientRects;
151
+ let rects: Array<Pick<DOMRect, "left" | "right" | "top" | "bottom">>;
152
+
153
+ beforeEach(() => {
154
+ rects = [{ left: 10, right: 50, top: 2, bottom: 18 }];
155
+ Range.prototype.getClientRects = () => rects as unknown as DOMRectList;
156
+ });
157
+
158
+ afterEach(() => {
159
+ Range.prototype.getClientRects = original;
160
+ });
161
+
162
+ function renderWidget(beforeText: string) {
163
+ const view = new EditorView({
164
+ state: EditorState.create({
165
+ doc: "",
166
+ extensions: clickablePlaceholderExtension({
167
+ beforeText,
168
+ linkText: "here",
169
+ afterText: "",
170
+ onClick: vi.fn(),
171
+ }),
172
+ }),
173
+ });
174
+ const widget = view.state
175
+ .facet(EditorView.decorations)
176
+ .flatMap((deco) => (typeof deco === "function" ? [deco(view)] : [deco]))
177
+ .map((set) => set.iter().value?.spec.widget)
178
+ .find(Boolean);
179
+ const dom = widget.toDOM(view);
180
+ document.body.append(dom);
181
+ return { view, widget, dom };
182
+ }
183
+
184
+ it("collapses the caret to the start of the first line of text", () => {
185
+ const { view, widget, dom } = renderWidget("Type or ");
186
+ expect(widget.coordsAt(dom, 0, 1)).toEqual({
187
+ left: 10,
188
+ right: 10,
189
+ top: 2,
190
+ bottom: 18,
191
+ });
192
+ dom.style.direction = "rtl";
193
+ expect(widget.coordsAt(dom, 0, 1)).toMatchObject({ left: 50, right: 50 });
194
+ dom.remove();
195
+ view.destroy();
196
+ });
197
+
198
+ it("skips empty text and falls back when nothing is laid out", () => {
199
+ const { view, widget, dom } = renderWidget("");
200
+ expect(widget.coordsAt(dom, 0, 1)).toMatchObject({ left: 10 });
201
+ rects = [];
202
+ expect(widget.coordsAt(dom, 0, 1)).toBeNull();
203
+ dom.remove();
204
+ view.destroy();
205
+ });
206
+ });
148
207
  });
@@ -90,6 +90,29 @@ export function clickablePlaceholderExtension(opts: {
90
90
  return wrapper;
91
91
  }
92
92
 
93
+ public override coordsAt(dom: HTMLElement) {
94
+ // A wrapped placeholder must not make the caret span every line.
95
+ const walker = document.createTreeWalker(dom, NodeFilter.SHOW_TEXT);
96
+ let text = walker.nextNode();
97
+ while (text && !text.textContent) {
98
+ text = walker.nextNode();
99
+ }
100
+ if (!text) {
101
+ return null;
102
+ }
103
+ const range = document.createRange();
104
+ range.selectNodeContents(text);
105
+ const rect = range.getClientRects()[0];
106
+ if (!rect) {
107
+ return null;
108
+ }
109
+ const left =
110
+ window.getComputedStyle(dom).direction === "rtl"
111
+ ? rect.right
112
+ : rect.left;
113
+ return { left, right: left, top: rect.top, bottom: rect.bottom };
114
+ }
115
+
93
116
  public override ignoreEvent(event: Event) {
94
117
  return (
95
118
  event.target instanceof Element &&
@@ -39,6 +39,7 @@ import { MockNotebook } from "@/__mocks__/notebook";
39
39
  import { Deferred } from "@/utils/Deferred";
40
40
  import { cellId } from "@/__tests__/branded";
41
41
  import { alertAtom, getPackageAlert } from "@/core/alerts/state";
42
+ import { stagedAICellsAtom } from "@/core/ai/staged-cells";
42
43
  import { notebookAtom } from "@/core/cells/cells";
43
44
  import { AppConfigSchema } from "@/core/config/config-schema";
44
45
  import { ConnectionNotice } from "@/components/editor/alerts/connection-notice";
@@ -260,6 +261,39 @@ describe("useNotebookSession document model", () => {
260
261
  await expect(model.flush()).rejects.toThrow("not started");
261
262
  });
262
263
 
264
+ it("removes deleted cells from pending AI review while retaining surviving edits", async () => {
265
+ const fake = withTransport();
266
+ vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
267
+ const store = createStore();
268
+ const { result, unmount } = renderConnectionHook(store);
269
+ await started();
270
+ act(() => {
271
+ connection(fake).open();
272
+ fixtureMessages("notebook-events.sse").forEach(connection(fake).receive);
273
+ });
274
+ const removed = cellId("c3");
275
+ const retained = cellId("k7f2");
276
+ act(() =>
277
+ store.set(
278
+ stagedAICellsAtom,
279
+ new Map([
280
+ [removed, { type: "add_cell" }],
281
+ [retained, { type: "add_cell" }],
282
+ ]),
283
+ ),
284
+ );
285
+ act(() =>
286
+ store.set(result.current.documentModel.editAtom, (tx) =>
287
+ tx.deleteCell(removed),
288
+ ),
289
+ );
290
+ expect(store.get(notebookAtom).cellIds).not.toContain(removed);
291
+ expect(store.get(stagedAICellsAtom)).toEqual(
292
+ new Map([[retained, { type: "add_cell" }]]),
293
+ );
294
+ unmount();
295
+ });
296
+
263
297
  it("follows the host document into the notebook's cells", async () => {
264
298
  const fake = withTransport();
265
299
  vi.mocked(useRuntimeManager).mockReturnValue(makeRuntimeManager());
@@ -10,6 +10,7 @@ import {
10
10
  notebookAtom,
11
11
  useReducerActions,
12
12
  } from "@/core/cells/cells";
13
+ import { useStagedAICellsActions } from "@/core/ai/staged-cells";
13
14
  import { followDocument } from "@/core/cells/document";
14
15
  import { AUTOCOMPLETER } from "@/core/codemirror/completion/Autocompleter";
15
16
  import { createDocumentModel } from "@/core/notebook/document";
@@ -96,6 +97,7 @@ export function useNotebookSession(opts: {
96
97
  setLastRun,
97
98
  resetCellRuntimes,
98
99
  } = useReducerActions();
100
+ const { reconcileStagedCells } = useStagedAICellsActions();
99
101
  const { addCellNotification } = useRunsActions();
100
102
  const setKernelState = useSetAtom(kernelStateAtom);
101
103
  const setInitialRunCompleted = useSetAtom(initialRunCompletedAtom);
@@ -441,7 +443,10 @@ export function useNotebookSession(opts: {
441
443
  followDocument({
442
444
  store,
443
445
  model: documentModel,
444
- reconcile: reconcileDocument,
446
+ reconcile: (document) => {
447
+ reconcileDocument(document);
448
+ reconcileStagedCells(document.order);
449
+ },
445
450
  signal: controller.signal,
446
451
  });
447
452
  return () => {
@@ -450,7 +455,7 @@ export function useNotebookSession(opts: {
450
455
  store.set(activeDocumentModelAtom, null);
451
456
  }
452
457
  };
453
- }, [documentModel, store, reconcileDocument]);
458
+ }, [documentModel, store, reconcileDocument, reconcileStagedCells]);
454
459
 
455
460
  // Subscribe before passive connection setup: static/WASM transports can emit synchronously.
456
461
  useLayoutEffect(() => {