@marimo-team/frontend 0.24.1-dev57 → 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 (42) 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-DTQ4RS9q.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/src/css/globals.css +1 -0
  37. package/dist/assets/add-cell-with-ai-0dqk0f7O.js +0 -77
  38. package/dist/assets/cell-editor-e7_o4mEh.js +0 -24
  39. package/dist/assets/chat-panel-BaG-5LiN.js +0 -4
  40. package/dist/assets/index-BrmH4-pI.css +0 -2
  41. package/src/core/ai/__tests__/strip-wrapping-backticks.test.ts +0 -133
  42. package/src/core/ai/strip-wrapping-backticks.ts +0 -88
@@ -19,7 +19,6 @@ import { store } from "@/core/state/jotai";
19
19
  import { variablesAtom } from "@/core/variables/state";
20
20
  import type { FileUIPart, UIMessage } from "ai";
21
21
  import {
22
- codeToCells,
23
22
  getAICompletionBody,
24
23
  getAICompletionBodyWithAttachments,
25
24
  isContextAttachment,
@@ -485,180 +484,3 @@ describe("context attachment stamping", () => {
485
484
  expect(isContextAttachment(attachments[0])).toBe(true);
486
485
  });
487
486
  });
488
-
489
- describe("codeToCells", () => {
490
- it("should return empty array for empty string", () => {
491
- const code = "";
492
- const result = codeToCells(code);
493
- expect(result).toEqual([]);
494
- });
495
-
496
- it("should return empty array for whitespace only", () => {
497
- const code = " \n\t ";
498
- const result = codeToCells(code);
499
- expect(result).toEqual([]);
500
- });
501
-
502
- it("should convert code without backticks to single python cell", () => {
503
- const code = "print('Hello, world!')";
504
- const result = codeToCells(code);
505
- expect(result).toEqual([
506
- { language: "python", code: "print('Hello, world!')" },
507
- ]);
508
- });
509
-
510
- it("should convert code with single closed backticks to cells", () => {
511
- const code = "```python\nprint('Hello, world!')\n```";
512
- const result = codeToCells(code);
513
- expect(result).toEqual([
514
- { language: "python", code: "print('Hello, world!')" },
515
- ]);
516
- });
517
-
518
- it("should convert code with unclosed backticks to cells", () => {
519
- const code = "```python\nprint('Hello, world!')\n";
520
- const result = codeToCells(code);
521
- expect(result).toEqual([
522
- { language: "python", code: "print('Hello, world!')" },
523
- ]);
524
- });
525
-
526
- it("should convert code with multiple closed cells", () => {
527
- const code =
528
- "```python\nprint('Hello, world!')\n```\n```sql\nSELECT * FROM users\n```";
529
- const result = codeToCells(code);
530
- expect(result).toEqual([
531
- { language: "python", code: "print('Hello, world!')" },
532
- { language: "sql", code: "SELECT * FROM users" },
533
- ]);
534
- });
535
-
536
- it("should handle code with no language identifier", () => {
537
- const code = "```\nprint('Hello, world!')\n```";
538
- const result = codeToCells(code);
539
- expect(result).toEqual([
540
- { language: "python", code: "print('Hello, world!')" },
541
- ]);
542
- });
543
-
544
- it("should handle unclosed code with no language identifier", () => {
545
- const code = "```\nprint('Hello, world!')\n";
546
- const result = codeToCells(code);
547
- expect(result).toEqual([
548
- { language: "python", code: "print('Hello, world!')" },
549
- ]);
550
- });
551
-
552
- it("should handle markdown language", () => {
553
- const code = "```markdown\n# Hello, world!\n```";
554
- const result = codeToCells(code);
555
- expect(result).toEqual([{ language: "markdown", code: "# Hello, world!" }]);
556
- });
557
-
558
- it("should handle sql language", () => {
559
- const code = "```sql\nSELECT * FROM users\n```";
560
- const result = codeToCells(code);
561
- expect(result).toEqual([{ language: "sql", code: "SELECT * FROM users" }]);
562
- });
563
-
564
- it("should handle unclosed markdown cell", () => {
565
- const code = "```markdown\n# Hello, world!\n";
566
- const result = codeToCells(code);
567
- expect(result).toEqual([{ language: "markdown", code: "# Hello, world!" }]);
568
- });
569
-
570
- it("should handle unclosed sql cell", () => {
571
- const code = "```sql\nSELECT * FROM users\n";
572
- const result = codeToCells(code);
573
- expect(result).toEqual([{ language: "sql", code: "SELECT * FROM users" }]);
574
- });
575
-
576
- it("should handle empty cells and skip them", () => {
577
- const code = "```python\n\n```\n```sql\nSELECT * FROM users\n```";
578
- const result = codeToCells(code);
579
- expect(result).toEqual([{ language: "sql", code: "SELECT * FROM users" }]);
580
- });
581
-
582
- it("should handle cells with only whitespace and skip them", () => {
583
- const code = "```python\n \n```\n```sql\nSELECT * FROM users\n```";
584
- const result = codeToCells(code);
585
- expect(result).toEqual([{ language: "sql", code: "SELECT * FROM users" }]);
586
- });
587
-
588
- it("should handle code with trailing newlines", () => {
589
- const code = "```python\nprint('Hello, world!')\n\n\n```";
590
- const result = codeToCells(code);
591
- expect(result).toEqual([
592
- { language: "python", code: "print('Hello, world!')" },
593
- ]);
594
- });
595
-
596
- it("should handle unclosed code with trailing newlines", () => {
597
- const code = "```python\nprint('Hello, world!')\n\n\n";
598
- const result = codeToCells(code);
599
- expect(result).toEqual([
600
- { language: "python", code: "print('Hello, world!')" },
601
- ]);
602
- });
603
-
604
- it("should handle multiple cells with different languages", () => {
605
- const code =
606
- "```python\nprint('Hello, world!')\n```\n```sql\nSELECT * FROM users\n```\n```markdown\n# Title\nThis is markdown\n```";
607
-
608
- const result = codeToCells(code);
609
- expect(result).toEqual([
610
- { language: "python", code: "print('Hello, world!')" },
611
- { language: "sql", code: "SELECT * FROM users" },
612
- { language: "markdown", code: "# Title\nThis is markdown" },
613
- ]);
614
- });
615
-
616
- it("should handle complex multiline code", () => {
617
- const code =
618
- '```python\ndef hello():\n print("Hello, world!")\n return "success"\n\nhello()\n```\n```sql\nSELECT \n id,\n name,\n email\nFROM users\nWHERE active = true\nORDER BY name;\n```';
619
-
620
- const result = codeToCells(code);
621
- expect(result).toEqual([
622
- {
623
- language: "python",
624
- code: 'def hello():\n print("Hello, world!")\n return "success"\n\nhello()',
625
- },
626
- {
627
- language: "sql",
628
- code: "SELECT \n id,\n name,\n email\nFROM users\nWHERE active = true\nORDER BY name;",
629
- },
630
- ]);
631
- });
632
-
633
- it("should handle code with backticks in the content", () => {
634
- const code = "```python\nprint('```')\n```";
635
- const result = codeToCells(code);
636
- expect(result).toEqual([{ language: "python", code: "print('" }]);
637
- });
638
-
639
- it("should handle code with no backticks in the last cell", () => {
640
- const code =
641
- "```python\nprint('Hello, world!')\n```\n```python\nprint('Hello, world!')";
642
- const result = codeToCells(code);
643
- expect(result).toEqual([
644
- { language: "python", code: "print('Hello, world!')" },
645
- { language: "python", code: "print('Hello, world!')" },
646
- ]);
647
- });
648
-
649
- it("should handle case insensitive language detection", () => {
650
- const code = "```PYTHON\nprint('Hello, world!')\n```";
651
- const result = codeToCells(code);
652
- expect(result).toEqual([
653
- { language: "python", code: "print('Hello, world!')" },
654
- ]);
655
- });
656
-
657
- it("should handle unknown language", { fails: true }, () => {
658
- const code = "```javascript\nconsole.log('Hello, world!')\n```";
659
- const result = codeToCells(code);
660
- expect(result).toEqual([
661
- { language: "javascript", code: "console.log('Hello, world!')" },
662
- ]);
663
- });
664
- });
@@ -0,0 +1,106 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { Deferred } from "@/utils/Deferred";
5
+ import { StagedCellSubmissionController } from "../staged-cell-submission";
6
+
7
+ describe("StagedCellSubmissionController", () => {
8
+ it("ignores duplicate submissions while preprocessing", async () => {
9
+ const preparation = new Deferred<string>();
10
+ const submit = vi.fn(async () => undefined);
11
+ const onError = vi.fn();
12
+ const controller = new StagedCellSubmissionController();
13
+
14
+ const first = controller.run({
15
+ prepare: () => preparation.promise,
16
+ submit,
17
+ onError,
18
+ });
19
+ await controller.run({
20
+ prepare: vi.fn(async () => "duplicate"),
21
+ submit,
22
+ onError,
23
+ });
24
+ preparation.resolve("prepared");
25
+ await first;
26
+
27
+ expect(submit).toHaveBeenCalledExactlyOnceWith("prepared");
28
+ expect(onError).not.toHaveBeenCalled();
29
+ });
30
+
31
+ it("does not submit after cancellation during preprocessing", async () => {
32
+ const preparation = new Deferred<string>();
33
+ const submit = vi.fn(async () => undefined);
34
+ const onError = vi.fn();
35
+ const controller = new StagedCellSubmissionController();
36
+
37
+ const run = controller.run({
38
+ prepare: () => preparation.promise,
39
+ submit,
40
+ onError,
41
+ });
42
+ controller.cancel();
43
+ preparation.resolve("prepared");
44
+ await run;
45
+
46
+ expect(submit).not.toHaveBeenCalled();
47
+ expect(onError).not.toHaveBeenCalled();
48
+ });
49
+
50
+ it("blocks another submission until a cancelled submission settles", async () => {
51
+ const submission = new Deferred<void>();
52
+ const firstSubmit = vi.fn(() => submission.promise);
53
+ const secondSubmit = vi.fn(async () => undefined);
54
+ const onError = vi.fn();
55
+ const controller = new StagedCellSubmissionController();
56
+
57
+ const firstRun = controller.run({
58
+ prepare: async () => "first",
59
+ submit: firstSubmit,
60
+ onError,
61
+ });
62
+ await vi.waitFor(() => {
63
+ expect(firstSubmit).toHaveBeenCalledExactlyOnceWith("first");
64
+ });
65
+
66
+ controller.cancel();
67
+ await controller.run({
68
+ prepare: async () => "second",
69
+ submit: secondSubmit,
70
+ onError,
71
+ });
72
+ expect(secondSubmit).not.toHaveBeenCalled();
73
+
74
+ submission.resolve();
75
+ await firstRun;
76
+ await controller.run({
77
+ prepare: async () => "second",
78
+ submit: secondSubmit,
79
+ onError,
80
+ });
81
+
82
+ expect(secondSubmit).toHaveBeenCalledExactlyOnceWith("second");
83
+ expect(onError).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it("reports preprocessing and submission errors", async () => {
87
+ const preprocessingError = new Error("preprocessing failed");
88
+ const submissionError = new Error("submission failed");
89
+ const onError = vi.fn();
90
+ const controller = new StagedCellSubmissionController();
91
+
92
+ await controller.run({
93
+ prepare: async () => Promise.reject(preprocessingError),
94
+ submit: vi.fn(),
95
+ onError,
96
+ });
97
+ await controller.run({
98
+ prepare: async () => "prepared",
99
+ submit: async () => Promise.reject(submissionError),
100
+ onError,
101
+ });
102
+
103
+ expect(onError).toHaveBeenNthCalledWith(1, preprocessingError);
104
+ expect(onError).toHaveBeenNthCalledWith(2, submissionError);
105
+ });
106
+ });
@@ -24,8 +24,9 @@ import {
24
24
  SparklesIcon,
25
25
  XIcon,
26
26
  } from "lucide-react";
27
- import { useMemo, useRef, useState } from "react";
27
+ import { useEffect, useMemo, useRef, useState } from "react";
28
28
  import useEvent from "react-use-event-hook";
29
+ import { DefaultChatTransport } from "ai";
29
30
  import { z } from "zod";
30
31
  import { AIModelDropdown } from "@/components/ai/ai-model-dropdown";
31
32
  import {
@@ -37,7 +38,6 @@ import {
37
38
  import {
38
39
  buildCompletionRequestBody,
39
40
  convertToFileUIPart,
40
- handleToolCall,
41
41
  PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
42
42
  useFileState,
43
43
  } from "@/components/chat/chat-utils";
@@ -52,13 +52,11 @@ import {
52
52
  import { toast } from "@/components/ui/use-toast";
53
53
  import { AiModelId } from "@/core/ai/ids/ids";
54
54
  import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants";
55
- import { stagedAICellsAtom, useStagedCells } from "@/core/ai/staged-cells";
56
- import type { ToolNotebookContext } from "@/core/ai/tools/base";
57
- import { useCellActions } from "@/core/cells/cells";
55
+ import type { CompletionUIMessage } from "@/core/ai/completion-output";
56
+ import { useStagedCellGeneration } from "@/core/ai/staged-cells";
58
57
  import { resourceExtension } from "@/core/codemirror/ai/resources";
59
58
  import { aiAtom } from "@/core/config/config";
60
59
  import { DEFAULT_AI_MODEL } from "@/core/config/config-schema";
61
- import { useRequestClient } from "@/core/network/requests";
62
60
  import type { AiCompletionRequest } from "@/core/network/types";
63
61
  import { useRuntimeManager } from "@/core/runtime/config";
64
62
  import { useTheme } from "@/theme/useTheme";
@@ -73,7 +71,7 @@ import {
73
71
  CONTEXT_TRIGGER,
74
72
  mentionsCompletionSource,
75
73
  } from "./completion-utils";
76
- import { StreamingChunkTransport } from "./transport/chat-transport";
74
+ import { StagedCellSubmissionController } from "./staged-cell-submission";
77
75
 
78
76
  // Persist across sessions
79
77
  const languageAtom = atomWithStorage<"python" | "sql">(
@@ -95,78 +93,95 @@ export const AddCellWithAI: React.FC<{
95
93
  const store = useStore();
96
94
  const [input, setInput] = useState("");
97
95
 
98
- const { deleteAllStagedCells, clearStagedCells, onStream, addStagedCell } =
99
- useStagedCells(store);
96
+ const {
97
+ acceptOwnedStagedCells,
98
+ beginStagedCellGeneration,
99
+ discardOwnedStagedCells,
100
+ finishStagedCellGeneration,
101
+ hasOwnedStagedCells,
102
+ onData,
103
+ } = useStagedCellGeneration(store);
100
104
  const [language, setLanguage] = useAtom(languageAtom);
101
105
  const runtimeManager = useRuntimeManager();
102
- const { invokeAiTool, sendRun } = useRequestClient();
103
106
 
104
- const stagedAICells = useAtomValue(stagedAICellsAtom);
105
107
  const inputRef = useRef<ReactCodeMirrorRef>(null);
108
+ const submissionController = useRef(
109
+ new StagedCellSubmissionController(),
110
+ ).current;
106
111
 
107
112
  const fileInputRef = useRef<HTMLInputElement>(null);
108
113
  const { files, addFiles, removeFile } = useFileState();
109
114
  const aiConfig = useAtomValue(aiAtom);
110
115
 
111
- const { createNewCell, prepareForRun } = useCellActions();
112
- const toolContext: ToolNotebookContext = {
113
- store,
114
- addStagedCell,
115
- createNewCell,
116
- prepareForRun,
117
- sendRun,
118
- };
119
-
120
- const { sendMessage, stop, status, addToolOutput } = useChat({
121
- throttle: AI_SDK_UI_THROTTLE_MS,
122
- transport: new StreamingChunkTransport(
123
- {
116
+ const transport = useMemo(
117
+ () =>
118
+ new DefaultChatTransport<CompletionUIMessage>({
124
119
  api: runtimeManager.getAiURL("completion").toString(),
125
120
  headers: () => runtimeManager.headers(),
126
121
  prepareSendMessagesRequest: async (options) => {
127
122
  const completionBody = await buildCompletionRequestBody(
128
123
  options.messages,
129
124
  );
130
- const body: AiCompletionRequest = {
131
- ...options,
125
+ const body = {
132
126
  ...completionBody,
133
127
  code: "",
134
128
  prompt: "", // Don't need prompt since we are using messages
135
129
  language: language,
136
- };
130
+ } satisfies AiCompletionRequest;
137
131
 
138
132
  return {
139
133
  api: runtimeManager.getAiURL("completion").toString(),
140
134
  body: body,
141
135
  };
142
136
  },
143
- },
144
- (chunk) => {
145
- onStream(chunk);
146
- },
147
- ),
148
- onToolCall: async ({ toolCall }) => {
149
- await handleToolCall({
150
- invokeAiTool,
151
- addToolOutput,
152
- toolCall: {
153
- toolName: toolCall.toolName,
154
- toolCallId: toolCall.toolCallId,
155
- input: toolCall.input as Record<string, never>,
156
- },
157
- toolContext,
158
- });
159
- },
160
- onError: (error) => {
161
- toast({
162
- title: "Generate with AI failed",
163
- description: prettyError(error),
164
- });
137
+ }),
138
+ [language, runtimeManager],
139
+ );
140
+
141
+ const handleGenerationError = useEvent((error: unknown) => {
142
+ finishStagedCellGeneration(false);
143
+ toast({
144
+ title: "Generate with AI failed",
145
+ description: prettyError(error),
146
+ });
147
+ });
148
+
149
+ const {
150
+ sendMessage,
151
+ stop: stopChat,
152
+ status,
153
+ } = useChat<CompletionUIMessage>({
154
+ throttle: AI_SDK_UI_THROTTLE_MS,
155
+ transport,
156
+ onData,
157
+ onError: handleGenerationError,
158
+ onFinish: ({ isAbort, isDisconnect, isError, finishReason }) => {
159
+ finishStagedCellGeneration(
160
+ !isAbort && !isDisconnect && !isError && finishReason === "stop",
161
+ );
165
162
  },
166
163
  });
167
164
 
168
165
  const isLoading = status === "streaming" || status === "submitted";
169
- const hasCompletion = stagedAICells.size > 0;
166
+ const hasCompletion = hasOwnedStagedCells();
167
+ const stop = useEvent(() => {
168
+ submissionController.cancel();
169
+ finishStagedCellGeneration(false);
170
+ void stopChat();
171
+ });
172
+
173
+ const reject = useEvent(() => {
174
+ submissionController.cancel();
175
+ discardOwnedStagedCells();
176
+ void stopChat();
177
+ });
178
+
179
+ // Parent-driven unmounts bypass the close handlers, so cancel in-flight work here.
180
+ useEffect(() => {
181
+ return () => {
182
+ stop();
183
+ };
184
+ }, [stop]);
170
185
 
171
186
  const currentModel = aiConfig?.models?.edit_model || DEFAULT_AI_MODEL;
172
187
  const currentProvider = AiModelId.parse(currentModel).providerId;
@@ -175,14 +190,19 @@ export const AddCellWithAI: React.FC<{
175
190
 
176
191
  const submit = async () => {
177
192
  if (!isLoading) {
178
- if (inputRef.current?.view) {
179
- storePrompt(inputRef.current.view);
180
- }
181
- // TODO: When we have conversations, don't delete existing cells
182
- deleteAllStagedCells();
183
-
184
- const fileParts = files ? await convertToFileUIPart(files) : undefined;
185
- sendMessage({ text: input, files: fileParts });
193
+ await submissionController.run({
194
+ prepare: async () => {
195
+ if (inputRef.current?.view) {
196
+ storePrompt(inputRef.current.view);
197
+ }
198
+ return files ? await convertToFileUIPart(files) : undefined;
199
+ },
200
+ submit: async (fileParts) => {
201
+ beginStagedCellGeneration();
202
+ await sendMessage({ text: input, files: fileParts });
203
+ },
204
+ onError: handleGenerationError,
205
+ });
186
206
  }
187
207
  };
188
208
 
@@ -225,18 +245,19 @@ export const AddCellWithAI: React.FC<{
225
245
  );
226
246
 
227
247
  const handleAcceptCompletion = () => {
228
- clearStagedCells();
229
- onClose();
248
+ if (acceptOwnedStagedCells()) {
249
+ onClose();
250
+ }
230
251
  };
231
252
 
232
253
  const handleDeclineCompletion = () => {
233
- deleteAllStagedCells();
254
+ reject();
234
255
  // Focus the input so the user can refine the prompt.
235
256
  inputRef.current?.view?.focus();
236
257
  };
237
258
 
238
259
  const handleClose = () => {
239
- deleteAllStagedCells();
260
+ reject();
240
261
  onClose();
241
262
  };
242
263