@marimo-team/islands 0.25.3-dev266 → 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-CvxlC3F1.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-SRM1JiD_.js → code-visibility-BKBl9Rh6.js} +9 -9
  14. package/dist/{common-yiRhZ6ZZ.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-DLTzcQ4O.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-DBUrE4OL.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-Cfv4HeOh.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-dIbBIR5-.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-CTiJp5Mj.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-DH09ygHo.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
@@ -4,16 +4,23 @@ import { fireEvent, render, screen } from "@testing-library/react";
4
4
  import { createStore, Provider } from "jotai";
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import type { ChatStatus } from "ai";
7
- import { useChatGeneration } from "@/components/chat/use-chat-generation";
7
+ import {
8
+ isChatBusy,
9
+ useChatGeneration,
10
+ } from "@/components/chat/use-chat-generation";
8
11
  import { cellId } from "@/__tests__/branded";
12
+ import { HTMLCellId } from "@/core/cells/ids";
13
+ import { scrollToCell } from "@/core/cells/scrollCellIntoView";
9
14
  import { TooltipProvider } from "@/components/ui/tooltip";
10
15
  import { stagedAICellsAtom } from "@/core/ai/staged-cells";
11
- import { scrollAndHighlightCell } from "../../../links/cell-link";
12
- import { StagedAICellFooter } from "../../../cell/StagedAICell";
13
16
  import { PendingAICells } from "../pending-ai-cells";
17
+ import { PendingAIChangesBadge } from "../pending-ai-changes-badge";
18
+
19
+ const deleteCell = vi.hoisted(() => vi.fn());
20
+ const setCellCode = vi.hoisted(() => vi.fn());
14
21
 
15
22
  vi.mock("@/components/editor/cell/useDeleteCell", () => ({
16
- useDeleteCellCallback: () => vi.fn(),
23
+ useDeleteCellCallback: () => deleteCell,
17
24
  }));
18
25
 
19
26
  vi.mock("@/core/cells/cells", () => ({
@@ -22,7 +29,7 @@ vi.mock("@/core/cells/cells", () => ({
22
29
  vi.mock("@/core/cells/actions", () => ({
23
30
  useCellActions: () => ({
24
31
  createCell: vi.fn(),
25
- setCellCode: vi.fn(),
32
+ setCellCode,
26
33
  }),
27
34
  }));
28
35
 
@@ -31,8 +38,8 @@ vi.mock("@/components/editor/cell/useRunCells", () => ({
31
38
  useRunCell: () => vi.fn(),
32
39
  }));
33
40
 
34
- vi.mock("@/components/editor/links/cell-link", () => ({
35
- scrollAndHighlightCell: vi.fn(),
41
+ vi.mock("@/core/cells/scrollCellIntoView", () => ({
42
+ scrollToCell: vi.fn(),
36
43
  }));
37
44
 
38
45
  describe("PendingAICells", () => {
@@ -63,60 +70,60 @@ describe("PendingAICells", () => {
63
70
  name: "Previous pending AI cell",
64
71
  });
65
72
 
66
- fireEvent.click(next);
67
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(first, "focus");
68
- expect(screen.getByText("1 / 3")).toBeInTheDocument();
73
+ fireEvent.click(screen.getByRole("button", { name: "Cell 1 of 3" }));
74
+ expect(scrollToCell).toHaveBeenLastCalledWith(first);
75
+ expect(screen.getByText("Cell 1 of 3")).toBeInTheDocument();
69
76
 
70
77
  fireEvent.click(next);
71
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(second, "focus");
72
- expect(screen.getByText("2 / 3")).toBeInTheDocument();
78
+ expect(scrollToCell).toHaveBeenLastCalledWith(second);
79
+ expect(screen.getByText("Cell 2 of 3")).toBeInTheDocument();
73
80
 
74
81
  fireEvent.click(previous);
75
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(first, "focus");
76
- expect(screen.getByText("1 / 3")).toBeInTheDocument();
82
+ expect(scrollToCell).toHaveBeenLastCalledWith(first);
83
+ expect(screen.getByText("Cell 1 of 3")).toBeInTheDocument();
77
84
 
78
85
  fireEvent.click(next);
79
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(second, "focus");
80
- expect(screen.getByText("2 / 3")).toBeInTheDocument();
86
+ expect(scrollToCell).toHaveBeenLastCalledWith(second);
87
+ expect(screen.getByText("Cell 2 of 3")).toBeInTheDocument();
81
88
 
82
89
  fireEvent.click(next);
83
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(third, "focus");
84
- expect(screen.getByText("3 / 3")).toBeInTheDocument();
90
+ expect(scrollToCell).toHaveBeenLastCalledWith(third);
91
+ expect(screen.getByText("Cell 3 of 3")).toBeInTheDocument();
85
92
 
86
93
  fireEvent.click(next);
87
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(first, "focus");
88
- expect(screen.getByText("1 / 3")).toBeInTheDocument();
94
+ expect(scrollToCell).toHaveBeenLastCalledWith(first);
95
+ expect(screen.getByText("Cell 1 of 3")).toBeInTheDocument();
89
96
 
90
97
  fireEvent.click(previous);
91
- expect(scrollAndHighlightCell).toHaveBeenLastCalledWith(third, "focus");
92
- expect(screen.getByText("3 / 3")).toBeInTheDocument();
98
+ expect(scrollToCell).toHaveBeenLastCalledWith(third);
99
+ expect(screen.getByText("Cell 3 of 3")).toBeInTheDocument();
93
100
  });
94
101
  });
95
102
 
96
103
  function ChatGeneration({ status }: { status: ChatStatus }) {
97
- useChatGeneration(status);
104
+ useChatGeneration(isChatBusy(status));
98
105
  return null;
99
106
  }
100
107
 
101
108
  it("locks staged edits during generation and unlocks after completion, stop, error, or unmount", () => {
102
109
  const store = createStore();
103
110
  const pendingCell = cellId("pending-cell");
104
- store.set(stagedAICellsAtom, new Map([[pendingCell, { type: "add_cell" }]]));
111
+ store.set(
112
+ stagedAICellsAtom,
113
+ new Map([
114
+ [pendingCell, { type: "add_cell" }],
115
+ [cellId("other-pending-cell"), { type: "add_cell" }],
116
+ ]),
117
+ );
105
118
  const ui = (status?: ChatStatus) => (
106
119
  <Provider store={store}>
107
120
  <TooltipProvider>
108
121
  {status && <ChatGeneration status={status} />}
109
122
  <PendingAICells />
110
- <StagedAICellFooter cellId={pendingCell} />
111
123
  </TooltipProvider>
112
124
  </Provider>
113
125
  );
114
126
  const { rerender } = render(ui("ready"));
115
- const keep = screen.getByRole("button", { name: "Keep all" });
116
- const discard = screen.getByRole("button", { name: "Discard all" });
117
- const keepCell = screen.getByRole("button", { name: "Keep cell" });
118
- const discardCell = screen.getByRole("button", { name: "Discard cell" });
119
- const keepAndRun = keep.parentElement!.querySelectorAll("button")[1];
120
127
  for (const status of [
121
128
  "submitted",
122
129
  "streaming",
@@ -130,15 +137,169 @@ it("locks staged edits during generation and unlocks after completion, stop, err
130
137
  ] as const) {
131
138
  rerender(ui(status));
132
139
  const isGenerating = status === "submitted" || status === "streaming";
133
- for (const button of [keep, discard, keepAndRun, keepCell, discardCell]) {
140
+ const keep = screen.getByRole("button", { name: "Keep all" });
141
+ expect(keep.hasAttribute("disabled")).toBe(isGenerating);
142
+ expect(
143
+ screen.queryByRole("button", { name: "Keep all" }),
144
+ ).toBeInTheDocument();
145
+ const keepCell = screen.getByRole("button", { name: "Keep" });
146
+ const discardCell = screen.getByRole("button", { name: "Discard" });
147
+ for (const button of [keepCell, discardCell]) {
134
148
  expect(button.hasAttribute("disabled")).toBe(isGenerating);
135
149
  if (isGenerating) {
136
150
  fireEvent.click(button);
137
151
  }
138
152
  }
139
153
  expect(store.get(stagedAICellsAtom).has(pendingCell)).toBe(true);
154
+ expect(screen.queryByRole("button", { name: "Keep" })).toBeInTheDocument();
140
155
  }
141
156
 
142
- fireEvent.click(keep);
157
+ fireEvent.click(screen.getByRole("button", { name: "Keep all" }));
143
158
  expect(store.get(stagedAICellsAtom).size).toBe(0);
144
159
  });
160
+
161
+ it.each(["Delete cell", "Keep cell"])(
162
+ "resolves a proposed deletion with %s and advances review",
163
+ (label) => {
164
+ deleteCell.mockClear();
165
+ const store = createStore();
166
+ const first = cellId("deleted-cell");
167
+ const second = cellId("added-cell");
168
+ store.set(
169
+ stagedAICellsAtom,
170
+ new Map([
171
+ [first, { type: "delete_cell", previousCode: "x = 1" }],
172
+ [second, { type: "add_cell" }],
173
+ ]),
174
+ );
175
+ render(
176
+ <Provider store={store}>
177
+ <PendingAICells />
178
+ </Provider>,
179
+ );
180
+ fireEvent.click(screen.getByRole("button", { name: label }));
181
+ expect(store.get(stagedAICellsAtom).has(first)).toBe(false);
182
+ expect(deleteCell).toHaveBeenCalledTimes(label === "Delete cell" ? 1 : 0);
183
+ expect(scrollToCell).toHaveBeenLastCalledWith(second);
184
+ expect(screen.getByText("1 cell changed")).toBeInTheDocument();
185
+ expect(
186
+ screen.queryByRole("button", { name: "Keep all" }),
187
+ ).not.toBeInTheDocument();
188
+ expect(
189
+ screen.queryByRole("button", { name: "Discard all" }),
190
+ ).not.toBeInTheDocument();
191
+ fireEvent.click(screen.getByRole("button", { name: "Keep" }));
192
+ expect(
193
+ screen.queryByRole("region", { name: "Review AI changes" }),
194
+ ).not.toBeInTheDocument();
195
+ },
196
+ );
197
+
198
+ it("keeps batch discard visible and locks it during generation", () => {
199
+ deleteCell.mockClear();
200
+ const store = createStore();
201
+ const pendingCell = cellId("discarded-cell");
202
+ store.set(
203
+ stagedAICellsAtom,
204
+ new Map([
205
+ [pendingCell, { type: "add_cell" }],
206
+ [cellId("other-pending-cell"), { type: "add_cell" }],
207
+ ]),
208
+ );
209
+ const ui = (status: ChatStatus) => (
210
+ <Provider store={store}>
211
+ <ChatGeneration status={status} />
212
+ <PendingAICells />
213
+ </Provider>
214
+ );
215
+ const { rerender } = render(ui("streaming"));
216
+ const discard = screen.getByRole("button", { name: "Discard all" });
217
+ expect(discard).toBeVisible();
218
+ expect(discard).toBeDisabled();
219
+ fireEvent.click(discard);
220
+ expect(store.get(stagedAICellsAtom).has(pendingCell)).toBe(true);
221
+ rerender(ui("ready"));
222
+ expect(discard).toBeEnabled();
223
+ fireEvent.click(discard);
224
+ expect(deleteCell).toHaveBeenCalledTimes(2);
225
+ expect(deleteCell).toHaveBeenCalledWith({ cellId: pendingCell });
226
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
227
+ });
228
+
229
+ it("clears the panel badge when all pending changes are accepted", () => {
230
+ const store = createStore();
231
+ store.set(
232
+ stagedAICellsAtom,
233
+ new Map([[cellId("pending"), { type: "add_cell" }]]),
234
+ );
235
+ render(
236
+ <Provider store={store}>
237
+ <PendingAIChangesBadge />
238
+ <PendingAICells />
239
+ </Provider>,
240
+ );
241
+ expect(screen.getByText("1 pending AI change")).toBeInTheDocument();
242
+ fireEvent.click(screen.getByRole("button", { name: "Keep" }));
243
+ expect(screen.queryByText("1 pending AI change")).not.toBeInTheDocument();
244
+ });
245
+
246
+ it("discards an edit to a cell whose editor is not mounted", () => {
247
+ setCellCode.mockClear();
248
+ const store = createStore();
249
+ const collapsed = cellId("collapsed-cell");
250
+ store.set(
251
+ stagedAICellsAtom,
252
+ new Map([[collapsed, { type: "update_cell", previousCode: "x = 1" }]]),
253
+ );
254
+ render(
255
+ <Provider store={store}>
256
+ <PendingAICells />
257
+ </Provider>,
258
+ );
259
+ fireEvent.click(screen.getByRole("button", { name: "Discard" }));
260
+ expect(setCellCode).toHaveBeenCalledWith({
261
+ cellId: collapsed,
262
+ code: "x = 1",
263
+ });
264
+ expect(store.get(stagedAICellsAtom).size).toBe(0);
265
+ });
266
+
267
+ it("outlines only the reviewed cell, moving the outline with the selection", () => {
268
+ const store = createStore();
269
+ const first = cellId("outlined-first");
270
+ const second = cellId("outlined-second");
271
+ store.set(
272
+ stagedAICellsAtom,
273
+ new Map([
274
+ [first, { type: "add_cell" }],
275
+ [second, { type: "add_cell" }],
276
+ ]),
277
+ );
278
+ const cells = [first, second].map((id) => {
279
+ const element = document.createElement("div");
280
+ element.id = HTMLCellId.create(id);
281
+ document.body.append(element);
282
+ return element;
283
+ });
284
+ const { unmount } = render(
285
+ <Provider store={store}>
286
+ <TooltipProvider>
287
+ <PendingAICells />
288
+ </TooltipProvider>
289
+ </Provider>,
290
+ );
291
+ const outlined = () =>
292
+ cells.map((cell) => cell.hasAttribute("data-ai-review-selected"));
293
+
294
+ expect(outlined()).toEqual([false, false]);
295
+ fireEvent.click(screen.getByRole("button", { name: "Cell 1 of 2" }));
296
+ expect(outlined()).toEqual([true, false]);
297
+ fireEvent.click(screen.getByRole("button", { name: "Next pending AI cell" }));
298
+ expect(outlined()).toEqual([false, true]);
299
+
300
+ unmount();
301
+ expect(outlined()).toEqual([false, false]);
302
+ for (const cell of cells) {
303
+ cell.remove();
304
+ }
305
+ });
@@ -68,7 +68,7 @@ import {
68
68
  import { BackendConnectionStatus } from "./footer-items/backend-status";
69
69
  import { LspStatus } from "./footer-items/lsp-status";
70
70
  import { PanelsWrapper } from "./panels";
71
- import { PendingAICells } from "./pending-ai-cells";
71
+ import { PendingAIChangesBadge } from "./pending-ai-changes-badge";
72
72
  import { useAiPanelTab } from "./useAiPanel";
73
73
  import { useDependencyPanelTab } from "./useDependencyPanelTab";
74
74
  import { handleDragging } from "./utils";
@@ -558,6 +558,7 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
558
558
  )}
559
559
  />
560
560
  {panel.label}
561
+ {panel.type === "ai" && <PendingAIChangesBadge />}
561
562
  </div>
562
563
  )}
563
564
  />
@@ -618,7 +619,6 @@ export const AppChrome: React.FC<PropsWithChildren> = ({ children }) => {
618
619
  </Panel>
619
620
  <ContextAwarePanel />
620
621
  </PanelGroup>
621
- <PendingAICells />
622
622
  <ErrorBoundary>
623
623
  <TooltipProvider>
624
624
  <Footer />
@@ -1,38 +1,47 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { useAtomValue, useStore } from "jotai";
4
- import { ChevronDown, ChevronUp, SparklesIcon } from "lucide-react";
5
- import { useState } from "react";
4
+ import { ChevronLeft, ChevronRight } from "lucide-react";
5
+ import { useEffect, useState } from "react";
6
6
  import { Button } from "@/components/ui/button";
7
- import { stagedAICellsAtom, useStagedCells } from "@/core/ai/staged-cells";
7
+ import {
8
+ type Edit,
9
+ stagedAICellsAtom,
10
+ useStagedCells,
11
+ } from "@/core/ai/staged-cells";
8
12
  import { chatGenerationInProgressAtom } from "@/core/ai/state";
9
- import type { CellId } from "@/core/cells/ids";
13
+ import { type CellId, HTMLCellId } from "@/core/cells/ids";
14
+ import { scrollToCell } from "@/core/cells/scrollCellIntoView";
10
15
  import { getNextIndex } from "@/utils/arrays";
11
- import { cn } from "@/utils/cn";
12
- import {
13
- AcceptCompletionButton,
14
- RejectCompletionButton,
15
- } from "../../ai/completion-handlers";
16
- import { acceptStagedCell, rejectStagedCell } from "../../cell/StagedAICell";
17
- import { useRunCells } from "../../cell/useRunCells";
18
- import { scrollAndHighlightCell } from "../../links/cell-link";
16
+
17
+ const REVIEW_LABELS: Record<Edit["type"], { accept: string; reject: string }> =
18
+ {
19
+ add_cell: { accept: "Keep", reject: "Discard" },
20
+ update_cell: { accept: "Keep", reject: "Discard" },
21
+ delete_cell: { accept: "Delete cell", reject: "Keep cell" },
22
+ };
19
23
 
20
24
  export const PendingAICells: React.FC = () => {
21
- const [currentIndex, setCurrentIndex] = useState<number | null>(null);
25
+ const [selectedCellId, setSelectedCellId] = useState<CellId | null>(null);
22
26
 
23
27
  const stagedAiCells = useAtomValue(stagedAICellsAtom);
24
28
  const isGenerating = useAtomValue(chatGenerationInProgressAtom);
25
29
  const listStagedCells = [...stagedAiCells.keys()];
30
+ const selectedIndex =
31
+ selectedCellId === null ? -1 : listStagedCells.indexOf(selectedCellId);
32
+ const currentIndex = Math.max(0, selectedIndex);
26
33
  const store = useStore();
27
- const { deleteStagedCell, removeStagedCell } = useStagedCells(store);
28
- const runCell = useRunCells();
34
+ const { acceptStagedCell, rejectStagedCell } = useStagedCells();
35
+ const reviewedCellId = selectedIndex === -1 ? null : selectedCellId;
36
+ useReviewedCellMarker(reviewedCellId);
29
37
 
30
38
  if (stagedAiCells.size === 0) {
31
39
  return null;
32
40
  }
33
41
 
34
- const scrollToCell = (cellId: CellId) => {
35
- scrollAndHighlightCell(cellId, "focus");
42
+ const selectCell = (cellId: CellId) => {
43
+ setSelectedCellId(cellId);
44
+ scrollToCell(cellId);
36
45
  };
37
46
 
38
47
  const clickNext = (direction: "up" | "down") => {
@@ -41,86 +50,141 @@ export const PendingAICells: React.FC = () => {
41
50
  listStagedCells.length,
42
51
  direction,
43
52
  );
44
- setCurrentIndex(newIndex);
45
- scrollToCell(listStagedCells[newIndex]);
53
+ selectCell(listStagedCells[newIndex]);
46
54
  };
47
55
 
48
56
  const acceptAllCompletions = () => {
49
57
  for (const [cellId, edit] of stagedAiCells) {
50
- acceptStagedCell(cellId, edit, removeStagedCell, deleteStagedCell);
58
+ acceptStagedCell(cellId, edit);
51
59
  }
52
60
  };
53
61
 
54
62
  const rejectAllCompletions = () => {
55
63
  for (const [cellId, edit] of stagedAiCells) {
56
- rejectStagedCell(cellId, edit, removeStagedCell, deleteStagedCell);
64
+ rejectStagedCell(cellId, edit);
57
65
  }
58
66
  };
59
67
 
60
- const runAllCells = () => {
61
- const cellsToRun = listStagedCells.filter(
62
- (cellId) => stagedAiCells.get(cellId)?.type !== "delete_cell",
63
- );
64
- runCell(cellsToRun);
65
- };
68
+ const selectedCell = listStagedCells[currentIndex];
69
+ const selectedEdit = stagedAiCells.get(selectedCell);
70
+ const hasMultipleCells = listStagedCells.length > 1;
66
71
 
67
- const cyanShadow = "shadow-[0_0_6px_0_#00A2C733]";
72
+ const resolveSelected = (decision: "accept" | "reject") => {
73
+ if (!selectedEdit || store.get(chatGenerationInProgressAtom)) {
74
+ return;
75
+ }
76
+ if (decision === "accept") {
77
+ acceptStagedCell(selectedCell, selectedEdit);
78
+ } else {
79
+ rejectStagedCell(selectedCell, selectedEdit);
80
+ }
81
+ if (hasMultipleCells) {
82
+ clickNext("up");
83
+ } else {
84
+ setSelectedCellId(null);
85
+ }
86
+ };
68
87
 
69
88
  return (
70
- <div
71
- className={cn(
72
- "fixed bottom-16 left-1/2 transform -translate-x-1/2 z-50 bg-background/95 backdrop-blur-sm supports-backdrop-filter:bg-background/80 border border-border rounded-lg px-3 py-2 flex items-center justify-between gap-2.5 w-100",
73
- cyanShadow,
74
- )}
89
+ <section
90
+ aria-label="Review AI changes"
91
+ className="@container/review shrink-0 border-t border-border bg-background px-3 py-2 text-sm"
75
92
  >
76
- <SparklesIcon className="h-4 w-4 text-primary" />
77
-
78
- <div className="flex items-center">
79
- <Button
80
- aria-label="Previous pending AI cell"
81
- variant="ghost"
82
- size="icon"
83
- onClick={() => clickNext("down")}
84
- >
85
- <ChevronUp className="h-3.5 w-3.5" />
86
- </Button>
87
- <span className="text-xs font-mono min-w-14 text-center">
88
- {currentIndex === null
89
- ? `${listStagedCells.length} pending`
90
- : `${currentIndex + 1} / ${listStagedCells.length}`}
91
- </span>
92
- <Button
93
- aria-label="Next pending AI cell"
94
- variant="ghost"
95
- size="icon"
96
- onClick={() => clickNext("up")}
97
- >
98
- <ChevronDown className="h-3.5 w-3.5" />
99
- </Button>
100
- </div>
101
-
102
- <div className="h-5 w-px bg-border" />
103
-
104
- <div className="flex items-center gap-1.5">
105
- <AcceptCompletionButton
106
- multipleCompletions={true}
107
- onAccept={acceptAllCompletions}
108
- isLoading={isGenerating}
109
- label="Keep all"
110
- size="xs"
111
- buttonStyles="h-6.5"
112
- playButtonStyles="h-6.5"
113
- runCell={runAllCells}
114
- />
115
- <RejectCompletionButton
116
- multipleCompletions={true}
117
- onDecline={rejectAllCompletions}
118
- disabled={isGenerating}
119
- size="xs"
120
- label="Discard all"
121
- className="h-6.5"
122
- />
93
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
94
+ {hasMultipleCells && (
95
+ <div className="flex flex-wrap items-center gap-2">
96
+ <span className="whitespace-nowrap text-muted-foreground">
97
+ {listStagedCells.length} cells changed
98
+ </span>
99
+ <Button
100
+ variant="outline"
101
+ size="sm"
102
+ className="chat-control chat-selector h-7 px-2 rounded-sm font-normal"
103
+ onClick={acceptAllCompletions}
104
+ disabled={isGenerating}
105
+ >
106
+ Keep all
107
+ </Button>
108
+ <Button
109
+ variant="outline"
110
+ size="sm"
111
+ className="chat-control chat-selector h-7 px-2 rounded-sm font-normal"
112
+ onClick={rejectAllCompletions}
113
+ disabled={isGenerating}
114
+ >
115
+ Discard all
116
+ </Button>
117
+ </div>
118
+ )}
119
+ <div className="flex flex-wrap items-center gap-2">
120
+ {hasMultipleCells && (
121
+ <span
122
+ aria-hidden="true"
123
+ className="hidden h-4 w-px bg-border @min-[600px]/review:block"
124
+ />
125
+ )}
126
+ <div className="flex h-7 w-fit items-center">
127
+ {hasMultipleCells && (
128
+ <Button
129
+ aria-label="Previous pending AI cell"
130
+ variant="ghost"
131
+ size="icon"
132
+ className="chat-control size-6 rounded-sm"
133
+ onClick={() => clickNext("down")}
134
+ >
135
+ <ChevronLeft className="size-4" />
136
+ </Button>
137
+ )}
138
+ <Button
139
+ variant="ghost"
140
+ size="sm"
141
+ className="chat-control h-6 px-1 rounded-sm font-normal tabular-nums whitespace-nowrap"
142
+ onClick={() => selectCell(selectedCell)}
143
+ >
144
+ <span aria-live="polite">
145
+ {hasMultipleCells
146
+ ? `Cell ${currentIndex + 1} of ${listStagedCells.length}`
147
+ : "1 cell changed"}
148
+ </span>
149
+ </Button>
150
+ {hasMultipleCells && (
151
+ <Button
152
+ aria-label="Next pending AI cell"
153
+ variant="ghost"
154
+ size="icon"
155
+ className="chat-control size-6 rounded-sm"
156
+ onClick={() => clickNext("up")}
157
+ >
158
+ <ChevronRight className="size-4" />
159
+ </Button>
160
+ )}
161
+ </div>
162
+ {selectedEdit &&
163
+ (["accept", "reject"] as const).map((decision) => (
164
+ <Button
165
+ key={decision}
166
+ variant="outline"
167
+ size="sm"
168
+ className="chat-control chat-selector h-7 px-2 rounded-sm font-normal"
169
+ disabled={isGenerating}
170
+ onClick={() => resolveSelected(decision)}
171
+ >
172
+ {REVIEW_LABELS[selectedEdit.type][decision]}
173
+ </Button>
174
+ ))}
175
+ </div>
123
176
  </div>
124
- </div>
177
+ </section>
125
178
  );
126
179
  };
180
+
181
+ function useReviewedCellMarker(cellId: CellId | null) {
182
+ useEffect(() => {
183
+ if (cellId === null) {
184
+ return;
185
+ }
186
+ const cell = document.getElementById(HTMLCellId.create(cellId));
187
+ cell?.setAttribute("data-ai-review-selected", "");
188
+ return () => cell?.removeAttribute("data-ai-review-selected");
189
+ }, [cellId]);
190
+ }
@@ -0,0 +1,25 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useAtomValue } from "jotai";
4
+ import { stagedAICellsAtom } from "@/core/ai/staged-cells";
5
+ import { cn } from "@/utils/cn";
6
+
7
+ export function PendingAIChangesBadge({ className }: { className?: string }) {
8
+ const count = useAtomValue(stagedAICellsAtom).size;
9
+ if (count === 0) {
10
+ return null;
11
+ }
12
+ return (
13
+ <span
14
+ className={cn(
15
+ "inline-flex h-4 min-w-4 items-center justify-center rounded-full border border-border bg-muted px-1 text-[10px] leading-none tabular-nums text-foreground",
16
+ className,
17
+ )}
18
+ >
19
+ <span aria-hidden={true}>{count}</span>
20
+ <span className="sr-only">
21
+ {count} pending AI {count === 1 ? "change" : "changes"}
22
+ </span>
23
+ </span>
24
+ );
25
+ }
@@ -22,6 +22,7 @@ import {
22
22
  PANELS,
23
23
  type PanelDescriptor,
24
24
  } from "../types";
25
+ import { PendingAIChangesBadge } from "./pending-ai-changes-badge";
25
26
  import { PANEL_PRELOADERS } from "./lazy-panels";
26
27
 
27
28
  export const Sidebar: React.FC = () => {
@@ -150,6 +151,11 @@ export const Sidebar: React.FC = () => {
150
151
  >
151
152
  {panel.type === "errors" ? (
152
153
  <ErrorPanelIcon Icon={panel.Icon} />
154
+ ) : panel.type === "ai" ? (
155
+ <span className="relative">
156
+ {renderIcon(panel)}
157
+ <PendingAIChangesBadge className="absolute -top-2 -right-2" />
158
+ </span>
153
159
  ) : (
154
160
  renderIcon(panel)
155
161
  )}