@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.
- package/dist/assets/add-cell-with-ai-DVGZNNUv.js +76 -0
- package/dist/assets/{agent-panel-D7mo8YLQ.js → agent-panel-BTY3oeHD.js} +3 -3
- package/dist/assets/cell-editor-D3YsOG7a.js +21 -0
- package/dist/assets/{chat-display-Cgg0xLir.js → chat-display-NTNQQyoE.js} +1 -1
- package/dist/assets/chat-panel-Bow0QKrA.js +4 -0
- package/dist/assets/{chat-ui-DYLpA8l4.js → chat-ui-Cg69zAqt.js} +4 -4
- package/dist/assets/{command-palette-DiT0E_K7.js → command-palette-QFmIvDcT.js} +1 -1
- package/dist/assets/{edit-page-DAC4cNKe.js → edit-page-ds4edlkF.js} +6 -6
- package/dist/assets/{index-W5CUNEV9.js → index-B36dhEV6.js} +3 -3
- package/dist/assets/index-ecw9ngZu.css +2 -0
- package/dist/assets/{layout-D3zwsTvR.js → layout-iNJ-1JI2.js} +2 -2
- package/dist/assets/{panels-ChFSBlsU.js → panels-BbkuRtEi.js} +1 -1
- package/dist/assets/{reveal-component-BFRmOcQr.js → reveal-component-C4pF9OE7.js} +1 -1
- package/dist/assets/{run-page-MZCCEXct.js → run-page-Bo57bjbp.js} +1 -1
- package/dist/assets/{scratchpad-panel-BqYT2vj3.js → scratchpad-panel-vBIq9BfA.js} +1 -1
- package/dist/assets/{skeleton-gmxAkHfa.js → skeleton-B5iAa5n_.js} +1 -1
- package/dist/assets/{useNotebookActions-BoFJmccb.js → useNotebookActions-DGuDG0Px.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/components/editor/ai/__tests__/completion-utils.test.ts +0 -178
- package/src/components/editor/ai/__tests__/staged-cell-submission.test.ts +106 -0
- package/src/components/editor/ai/add-cell-with-ai.tsx +83 -62
- package/src/components/editor/ai/ai-completion-editor.tsx +71 -37
- package/src/components/editor/ai/completion-handlers.tsx +4 -0
- package/src/components/editor/ai/completion-utils.ts +0 -92
- package/src/components/editor/ai/staged-cell-submission.ts +46 -0
- package/src/components/editor/cell/StagedAICell.tsx +4 -2
- package/src/components/editor/cell/__tests__/StagedAICell.test.tsx +64 -0
- package/src/components/editor/chrome/wrapper/__tests__/pending-ai-cells.test.tsx +77 -0
- package/src/components/editor/chrome/wrapper/pending-ai-cells.tsx +8 -2
- package/src/core/ai/__tests__/staged-cells.test.ts +442 -125
- package/src/core/ai/__tests__/stream-completion-text.test.ts +74 -0
- package/src/core/ai/completion-output.ts +56 -0
- package/src/core/ai/staged-cells.ts +245 -164
- package/src/core/ai/stream-completion-text.ts +23 -6
- package/dist/assets/add-cell-with-ai-0dqk0f7O.js +0 -77
- package/dist/assets/cell-editor-e7_o4mEh.js +0 -24
- package/dist/assets/chat-panel-BaG-5LiN.js +0 -4
- package/dist/assets/index-DIUt_9oR.css +0 -2
- package/src/core/ai/__tests__/strip-wrapping-backticks.test.ts +0 -133
- 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 {
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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: (
|
|
146
|
-
|
|
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={
|
|
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={
|
|
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 {
|
|
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={
|
|
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"
|