@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
@@ -1,6 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useCompletion } from "@ai-sdk/react";
3
+ import { useChat } from "@ai-sdk/react";
4
4
  import { EditorView } from "@codemirror/view";
5
5
  import {
6
6
  CircleCheckIcon,
@@ -10,6 +10,7 @@ import {
10
10
  } from "lucide-react";
11
11
  import React, { useCallback, useEffect, useId, useState } from "react";
12
12
  import CodeMirrorMerge from "react-codemirror-merge";
13
+ import { DefaultChatTransport } from "ai";
13
14
  import { Button } from "@/components/ui/button";
14
15
  import { customPythonLanguageSupport } from "@/core/codemirror/language/languages/python";
15
16
 
@@ -27,14 +28,19 @@ import { Label } from "@/components/ui/label";
27
28
  import { Switch } from "@/components/ui/switch";
28
29
  import { Tooltip } from "@/components/ui/tooltip";
29
30
  import { toast } from "@/components/ui/use-toast";
31
+ import {
32
+ CELL_COMPLETION_DATA_TYPE,
33
+ cellCompletionSchema,
34
+ type CompletionUIMessage,
35
+ } from "@/core/ai/completion-output";
30
36
  import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants";
31
- import { stripWrappingBackticks } from "@/core/ai/strip-wrapping-backticks";
32
37
  import { type AiCompletionCell, includeOtherCellsAtom } from "@/core/ai/state";
33
38
  import type { CellId } from "@/core/cells/ids";
34
39
  import { getCodes } from "@/core/codemirror/copilot/getCodes";
35
40
  import type { LanguageAdapterType } from "@/core/codemirror/language/types";
36
41
  import { selectAllText } from "@/core/codemirror/utils";
37
42
  import { useRuntimeManager } from "@/core/runtime/config";
43
+ import type { AiCompletionRequest } from "@/core/network/types";
38
44
  import { useTheme } from "@/theme/useTheme";
39
45
  import { cn } from "@/utils/cn";
40
46
  import { prettyError } from "@/utils/errors";
@@ -90,7 +96,6 @@ export const AiCompletionEditor: React.FC<Props> = ({
90
96
  children,
91
97
  }) => {
92
98
  const [showInputPrompt, setShowInputPrompt] = useState(false);
93
- const [completionBody, setCompletionBody] = useState<object>({});
94
99
 
95
100
  const [includeOtherCells, setIncludeOtherCells] = useAtom(
96
101
  includeOtherCellsAtom,
@@ -112,50 +117,84 @@ export const AiCompletionEditor: React.FC<Props> = ({
112
117
  previousCellCode = updatedCell.previousCode;
113
118
  }
114
119
 
120
+ const [completion, setCompletion] = useState("");
121
+ const [input, setInput] = useState(initialPrompt ?? "");
122
+ const transport = React.useMemo(
123
+ () =>
124
+ new DefaultChatTransport<CompletionUIMessage>({
125
+ api: runtimeManager.getAiURL("completion").toString(),
126
+ headers: () => runtimeManager.headers(),
127
+ prepareSendMessagesRequest: ({ body }) => ({ body: body ?? {} }),
128
+ }),
129
+ [runtimeManager],
130
+ );
115
131
  const {
116
- completion: untrimmedCompletion,
117
- input,
118
- stop,
119
- isLoading,
120
- setCompletion,
121
- setInput,
122
- handleSubmit,
123
- complete,
124
- } = useCompletion({
125
- api: runtimeManager.getAiURL("completion").toString(),
126
- headers: runtimeManager.headers(),
127
- initialInput: initialPrompt,
132
+ sendMessage,
133
+ stop: stopChat,
134
+ status,
135
+ } = useChat<CompletionUIMessage>({
128
136
  throttle: AI_SDK_UI_THROTTLE_MS,
129
- body: {
130
- ...(Object.keys(completionBody).length > 0
131
- ? completionBody
132
- : initialPrompt
133
- ? getAICompletionBody({ input: initialPrompt })
134
- : {}),
135
- includeOtherCode: includeOtherCells ? getCodes(currentCode) : "",
136
- code: currentCode,
137
- language: currentLanguageAdapter,
137
+ transport,
138
+ onData: (part) => {
139
+ if (part.type === CELL_COMPLETION_DATA_TYPE) {
140
+ const completion = cellCompletionSchema.parse(part.data);
141
+ setCompletion(completion.code.trimEnd());
142
+ }
138
143
  },
139
144
  onError: (error) => {
145
+ setCompletion("");
140
146
  toast({
141
147
  title: "Completion failed",
142
148
  description: prettyError(error),
143
149
  });
144
150
  },
145
- onFinish: (_prompt, completion) => {
146
- setCompletion(stripWrappingBackticks(completion).trimEnd());
151
+ onFinish: ({ isAbort, isDisconnect, isError, finishReason }) => {
152
+ if (isAbort || isDisconnect || isError || finishReason !== "stop") {
153
+ setCompletion("");
154
+ }
147
155
  },
148
156
  });
157
+ const isLoading = status === "submitted" || status === "streaming";
158
+
159
+ const stop = useCallback(() => {
160
+ setCompletion("");
161
+ void stopChat();
162
+ }, [stopChat]);
163
+
164
+ const complete = useCallback(
165
+ async (prompt: string) => {
166
+ await stopChat();
167
+ setCompletion("");
168
+
169
+ const body = {
170
+ ...getAICompletionBody({ input: prompt }),
171
+ prompt,
172
+ includeOtherCode: includeOtherCells ? getCodes(currentCode) : "",
173
+ code: currentCode,
174
+ language: currentLanguageAdapter ?? "python",
175
+ } satisfies AiCompletionRequest;
176
+
177
+ await sendMessage({ text: prompt }, { body });
178
+ },
179
+ [
180
+ currentCode,
181
+ currentLanguageAdapter,
182
+ includeOtherCells,
183
+ sendMessage,
184
+ stopChat,
185
+ ],
186
+ );
187
+
188
+ const handleSubmit = useCallback(() => {
189
+ void complete(input);
190
+ }, [complete, input]);
149
191
 
150
192
  const inputRef = React.useRef<ReactCodeMirrorRef>(null);
151
- const completion = stripWrappingBackticks(untrimmedCompletion, {
152
- streaming: isLoading,
153
- }).trimEnd();
154
193
 
155
194
  const initialSubmit = useCallback(() => {
156
195
  if (triggerImmediately && !isLoading && initialPrompt) {
157
196
  // Use complete to pass the prompt directly, else input might be empty
158
- complete(initialPrompt);
197
+ void complete(initialPrompt);
159
198
  }
160
199
  // oxlint-disable-next-line react-hooks/exhaustive-deps
161
200
  }, [triggerImmediately]);
@@ -197,7 +236,6 @@ export const AiCompletionEditor: React.FC<Props> = ({
197
236
  // Reject discards the suggestion but keeps the prompt open for refinement.
198
237
  const handleDeclineCompletion = () => {
199
238
  stop();
200
- setCompletion("");
201
239
  setShowInputPrompt(true);
202
240
  inputRef.current?.view?.focus();
203
241
  };
@@ -299,14 +337,11 @@ export const AiCompletionEditor: React.FC<Props> = ({
299
337
  inputRef={inputRef}
300
338
  className="h-full my-0 py-2 flex items-center"
301
339
  onClose={() => {
340
+ stop();
302
341
  declineChange();
303
- setCompletion("");
304
342
  }}
305
343
  value={input}
306
- onChange={(newValue) => {
307
- setInput(newValue);
308
- setCompletionBody(getAICompletionBody({ input: newValue }));
309
- }}
344
+ onChange={setInput}
310
345
  onSubmit={() => {
311
346
  if (!isLoading) {
312
347
  if (inputRef.current?.view) {
@@ -378,7 +413,6 @@ export const AiCompletionEditor: React.FC<Props> = ({
378
413
  onClick={() => {
379
414
  stop();
380
415
  declineChange();
381
- setCompletion("");
382
416
  }}
383
417
  >
384
418
  <XIcon className="text-(--red-10)" size={16} />
@@ -77,6 +77,7 @@ export const CompletionActionsCellFooter: React.FC<{
77
77
  onDecline={onDecline}
78
78
  size={size}
79
79
  label={declineLabel}
80
+ disabled={isLoading}
80
81
  />
81
82
  </>
82
83
  );
@@ -169,6 +170,7 @@ export const AcceptCompletionButton: React.FC<{
169
170
 
170
171
  export const RejectCompletionButton: React.FC<{
171
172
  onDecline: () => void;
173
+ disabled?: boolean;
172
174
  multipleCompletions?: boolean;
173
175
  size?: "xs" | "sm";
174
176
  className?: string;
@@ -177,6 +179,7 @@ export const RejectCompletionButton: React.FC<{
177
179
  label?: string;
178
180
  }> = ({
179
181
  onDecline,
182
+ disabled = false,
180
183
  multipleCompletions = false,
181
184
  size = "sm",
182
185
  className,
@@ -188,6 +191,7 @@ export const RejectCompletionButton: React.FC<{
188
191
  <Button
189
192
  variant="text"
190
193
  size={size}
194
+ disabled={disabled}
191
195
  onClick={onDecline}
192
196
  className={`h-6 text-(--red-10) bg-(--red-3)/60 hover:bg-(--red-3)
193
197
  dark:bg-(--red-4)/80 dark:hover:bg-(--red-3) rounded px-3 font-semibold
@@ -11,7 +11,6 @@ import type { DataUIPart, FileUIPart, UIMessage } from "ai";
11
11
  import { getAIContextRegistry } from "@/core/ai/context/context";
12
12
  import type { ContextLocatorId } from "@/core/ai/context/registry";
13
13
  import { getCodes } from "@/core/codemirror/copilot/getCodes";
14
- import type { LanguageAdapterType } from "@/core/codemirror/language/types";
15
14
  import type { AiCompletionRequest } from "@/core/network/types";
16
15
  import { store } from "@/core/state/jotai";
17
16
  import { Logger } from "@/utils/Logger";
@@ -249,94 +248,3 @@ export function addContextCompletion(
249
248
  startCompletion(inputRef.current.view);
250
249
  }
251
250
  }
252
-
253
- export interface AiCompletion {
254
- language: LanguageAdapterType;
255
- code: string;
256
- }
257
-
258
- /**
259
- * Extracts code blocks (delimited by triple backticks) and their language ("python", "sql", "markdown").
260
- * Defaults to "python" if no language is specified or no code blocks are found.
261
- * Returns an array of AiCompletion objects.
262
- */
263
- export function codeToCells(code: string): AiCompletion[] {
264
- if (code.trim().length === 0) {
265
- return [];
266
- }
267
-
268
- // If there are no backticks, assume code is in 1 cell and python
269
- if (!code.includes("```")) {
270
- return [{ language: "python", code: code }];
271
- }
272
-
273
- // If code has opening backticks, get the code after it
274
- const cells: AiCompletion[] = [];
275
- let start = 0;
276
-
277
- let openIndex = code.indexOf("```", start);
278
- while (openIndex !== -1) {
279
- const newlineIndex = code.indexOf("\n", openIndex);
280
- if (newlineIndex === -1) {
281
- // If there's no newline after opening backticks, treat everything after as code
282
- const remaining = code.slice(openIndex + 3);
283
- const firstSpace = remaining.indexOf(" ");
284
- const language =
285
- firstSpace === -1 ? remaining : remaining.slice(0, firstSpace);
286
- const finalLanguage =
287
- language === "markdown"
288
- ? "markdown"
289
- : language === "sql"
290
- ? "sql"
291
- : "python";
292
- // Extract code after the language identifier
293
- const codeContent =
294
- firstSpace === -1 ? "" : remaining.slice(firstSpace + 1);
295
- if (codeContent.trim()) {
296
- cells.push({ language: finalLanguage, code: codeContent.trim() });
297
- }
298
- break;
299
- }
300
-
301
- let language = code.slice(openIndex + 3, newlineIndex).trim() || "";
302
- language =
303
- language === "markdown"
304
- ? "markdown"
305
- : language === "sql"
306
- ? "sql"
307
- : "python";
308
- const codeStart = newlineIndex + 1;
309
-
310
- const closeIndex = code.indexOf("```", codeStart);
311
- if (closeIndex === -1) {
312
- // If there's no closing backticks, treat everything after the opening as code
313
- const codeContent = code.slice(codeStart).replace(/\n+$/, "");
314
- if (codeContent.trim()) {
315
- cells.push({
316
- language: language as LanguageAdapterType,
317
- code: codeContent,
318
- });
319
- }
320
- break;
321
- }
322
-
323
- // Remove trailing newlines
324
- const codeContent = code.slice(codeStart, closeIndex).replace(/\n+$/, "");
325
- if (codeContent.trim()) {
326
- cells.push({
327
- language: language as LanguageAdapterType,
328
- code: codeContent,
329
- });
330
- }
331
-
332
- start = closeIndex + 3;
333
- openIndex = code.indexOf("```", start);
334
- }
335
-
336
- // If no cells found, assume code is in 1 cell and python
337
- if (cells.length === 0) {
338
- cells.push({ language: "python", code: code });
339
- }
340
-
341
- return cells;
342
- }
@@ -0,0 +1,46 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ interface SubmissionCallbacks<T> {
4
+ prepare: () => Promise<T>;
5
+ submit: (prepared: T) => Promise<void>;
6
+ onError: (error: unknown) => void;
7
+ }
8
+
9
+ interface SubmissionAttempt {
10
+ cancelled: boolean;
11
+ }
12
+
13
+ /** Coordinates preprocessing and submission without resuming cancelled work. */
14
+ export class StagedCellSubmissionController {
15
+ private activeAttempt: SubmissionAttempt | null = null;
16
+
17
+ cancel() {
18
+ if (this.activeAttempt) {
19
+ this.activeAttempt.cancelled = true;
20
+ }
21
+ }
22
+
23
+ async run<T>({ prepare, submit, onError }: SubmissionCallbacks<T>) {
24
+ if (this.activeAttempt !== null) {
25
+ return;
26
+ }
27
+
28
+ const attempt: SubmissionAttempt = { cancelled: false };
29
+ this.activeAttempt = attempt;
30
+
31
+ try {
32
+ const prepared = await prepare();
33
+ if (attempt.cancelled) {
34
+ return;
35
+ }
36
+
37
+ await submit(prepared);
38
+ } catch (error) {
39
+ if (!attempt.cancelled) {
40
+ onError(error);
41
+ }
42
+ } finally {
43
+ this.activeAttempt = null;
44
+ }
45
+ }
46
+ }
@@ -1,10 +1,11 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useStore } from "jotai";
3
+ import { useAtomValue, useStore } from "jotai";
4
4
  import { SparklesIcon } from "lucide-react";
5
5
  import { Tooltip } from "@/components/ui/tooltip";
6
6
  import {
7
7
  type Edit,
8
+ stagedGenerationInProgressAtom,
8
9
  useStagedAICell,
9
10
  useStagedCells,
10
11
  } from "@/core/ai/staged-cells";
@@ -48,6 +49,7 @@ export const StagedAICellFooter: React.FC<{ cellId: CellId }> = ({
48
49
  cellId,
49
50
  }) => {
50
51
  const store = useStore();
52
+ const generationInProgress = useAtomValue(stagedGenerationInProgressAtom);
51
53
  const stagedAiCell = useStagedAICell(cellId);
52
54
  const runCell = useRunCell(cellId);
53
55
 
@@ -91,7 +93,7 @@ export const StagedAICellFooter: React.FC<{ cellId: CellId }> = ({
91
93
  </div>
92
94
  <div className="flex items-center gap-1.5">
93
95
  <CompletionActionsCellFooter
94
- isLoading={false}
96
+ isLoading={generationInProgress}
95
97
  onAccept={() => handleCompletion("accept")}
96
98
  onDecline={() => handleCompletion("reject")}
97
99
  size="xs"
@@ -0,0 +1,64 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render, screen } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { cellId } from "@/__tests__/branded";
7
+ import { TooltipProvider } from "@/components/ui/tooltip";
8
+ import { stagedAICellsAtom, visibleForTesting } from "@/core/ai/staged-cells";
9
+ import { StagedAICellFooter } from "../StagedAICell";
10
+
11
+ vi.mock("@/components/editor/cell/useDeleteCell", () => ({
12
+ useDeleteCellCallback: () => vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/core/cells/cells", () => ({
16
+ getCellEditorView: vi.fn(),
17
+ useCellActions: () => ({
18
+ createNewCell: vi.fn(),
19
+ updateCellCode: vi.fn(),
20
+ }),
21
+ }));
22
+
23
+ vi.mock("../useRunCells", () => ({
24
+ useRunCell: () => undefined,
25
+ }));
26
+
27
+ describe("StagedAICellFooter", () => {
28
+ it("disables cell actions until generation is complete", () => {
29
+ const store = createStore();
30
+ const generatedCellId = cellId("generated-cell");
31
+ store.set(
32
+ stagedAICellsAtom,
33
+ new Map([[generatedCellId, { type: "add_cell" }]]),
34
+ );
35
+ store.set(visibleForTesting.stagedGenerationAtom, {
36
+ id: Symbol("staged-cell-generation"),
37
+ status: "in_progress",
38
+ cellIds: [generatedCellId],
39
+ });
40
+
41
+ const { rerender } = render(
42
+ <Provider store={store}>
43
+ <TooltipProvider>
44
+ <StagedAICellFooter cellId={generatedCellId} />
45
+ </TooltipProvider>
46
+ </Provider>,
47
+ );
48
+
49
+ expect(screen.getByRole("button", { name: "Keep cell" })).toBeDisabled();
50
+ expect(screen.getByRole("button", { name: "Discard cell" })).toBeDisabled();
51
+
52
+ store.set(visibleForTesting.stagedGenerationAtom, null);
53
+ rerender(
54
+ <Provider store={store}>
55
+ <TooltipProvider>
56
+ <StagedAICellFooter cellId={generatedCellId} />
57
+ </TooltipProvider>
58
+ </Provider>,
59
+ );
60
+
61
+ expect(screen.getByRole("button", { name: "Keep cell" })).toBeEnabled();
62
+ expect(screen.getByRole("button", { name: "Discard cell" })).toBeEnabled();
63
+ });
64
+ });
@@ -0,0 +1,77 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, render, screen, within } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import { describe, expect, it, vi } from "vitest";
6
+ import { cellId } from "@/__tests__/branded";
7
+ import { TooltipProvider } from "@/components/ui/tooltip";
8
+ import { stagedAICellsAtom, visibleForTesting } from "@/core/ai/staged-cells";
9
+ import { PendingAICells } from "../pending-ai-cells";
10
+
11
+ vi.mock("@/components/editor/cell/useDeleteCell", () => ({
12
+ useDeleteCellCallback: () => vi.fn(),
13
+ }));
14
+
15
+ vi.mock("@/core/cells/cells", () => ({
16
+ getCellEditorView: vi.fn(),
17
+ useCellActions: () => ({
18
+ createNewCell: vi.fn(),
19
+ updateCellCode: vi.fn(),
20
+ }),
21
+ }));
22
+
23
+ vi.mock("@/components/editor/cell/useRunCells", () => ({
24
+ useRunCells: () => vi.fn(),
25
+ }));
26
+
27
+ vi.mock("@/components/editor/links/cell-link", () => ({
28
+ scrollAndHighlightCell: vi.fn(),
29
+ }));
30
+
31
+ describe("PendingAICells", () => {
32
+ it("disables bulk actions while cells are still generating", () => {
33
+ const store = createStore();
34
+ const generationId = Symbol("staged-cell-generation");
35
+ store.set(
36
+ stagedAICellsAtom,
37
+ new Map([[cellId("generated-cell"), { type: "add_cell" }]]),
38
+ );
39
+ store.set(visibleForTesting.stagedGenerationAtom, {
40
+ id: generationId,
41
+ status: "in_progress",
42
+ cellIds: [cellId("generated-cell")],
43
+ });
44
+
45
+ const view = (
46
+ <Provider store={store}>
47
+ <TooltipProvider>
48
+ <PendingAICells />
49
+ </TooltipProvider>
50
+ </Provider>
51
+ );
52
+ const { rerender } = render(view);
53
+
54
+ const acceptButton = screen.getByRole("button", { name: "Keep all" });
55
+ const acceptButtons = within(
56
+ acceptButton.parentElement as HTMLElement,
57
+ ).getAllByRole("button");
58
+ expect(acceptButtons).toHaveLength(2);
59
+ for (const button of acceptButtons) {
60
+ expect(button).toBeDisabled();
61
+ }
62
+ expect(screen.getByRole("button", { name: "Discard all" })).toBeDisabled();
63
+
64
+ act(() => {
65
+ store.set(visibleForTesting.stagedGenerationAtom, {
66
+ id: generationId,
67
+ status: "complete",
68
+ });
69
+ });
70
+ rerender(view);
71
+
72
+ for (const button of acceptButtons) {
73
+ expect(button).toBeEnabled();
74
+ }
75
+ expect(screen.getByRole("button", { name: "Discard all" })).toBeEnabled();
76
+ });
77
+ });
@@ -4,7 +4,11 @@ import { useAtomValue, useStore } from "jotai";
4
4
  import { ChevronDown, ChevronUp, SparklesIcon } from "lucide-react";
5
5
  import { useState } from "react";
6
6
  import { Button } from "@/components/ui/button";
7
- import { stagedAICellsAtom, useStagedCells } from "@/core/ai/staged-cells";
7
+ import {
8
+ stagedAICellsAtom,
9
+ stagedGenerationInProgressAtom,
10
+ useStagedCells,
11
+ } from "@/core/ai/staged-cells";
8
12
  import type { CellId } from "@/core/cells/ids";
9
13
  import { getNextIndex } from "@/utils/arrays";
10
14
  import { cn } from "@/utils/cn";
@@ -20,6 +24,7 @@ export const PendingAICells: React.FC = () => {
20
24
  const [currentIndex, setCurrentIndex] = useState<number | null>(null);
21
25
 
22
26
  const stagedAiCells = useAtomValue(stagedAICellsAtom);
27
+ const generationInProgress = useAtomValue(stagedGenerationInProgressAtom);
23
28
  const listStagedCells = [...stagedAiCells.keys()];
24
29
  const store = useStore();
25
30
  const { deleteStagedCell, removeStagedCell } = useStagedCells(store);
@@ -93,7 +98,7 @@ export const PendingAICells: React.FC = () => {
93
98
  <AcceptCompletionButton
94
99
  multipleCompletions={true}
95
100
  onAccept={acceptAllCompletions}
96
- isLoading={false}
101
+ isLoading={generationInProgress}
97
102
  label="Keep all"
98
103
  size="xs"
99
104
  buttonStyles="h-6.5"
@@ -103,6 +108,7 @@ export const PendingAICells: React.FC = () => {
103
108
  <RejectCompletionButton
104
109
  multipleCompletions={true}
105
110
  onDecline={rejectAllCompletions}
111
+ disabled={generationInProgress}
106
112
  size="xs"
107
113
  label="Discard all"
108
114
  className="h-6.5"