@marimo-team/islands 0.19.8-dev41 → 0.19.8-dev48

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 (35) hide show
  1. package/dist/assets/__vite-browser-external-WSlCcXn_.js +1 -0
  2. package/dist/assets/worker-DUYMdbtA.js +73 -0
  3. package/dist/main.js +1740 -1691
  4. package/dist/style.css +1 -1
  5. package/dist/{useDeepCompareMemoize-CMGprt3H.js → useDeepCompareMemoize-BhZZsis0.js} +7 -3
  6. package/dist/{vega-component-DU3aSp4m.js → vega-component-DCxUyPnb.js} +1 -1
  7. package/package.json +1 -1
  8. package/src/components/app-config/optional-features.tsx +1 -1
  9. package/src/components/chat/__tests__/useFileState.test.tsx +93 -0
  10. package/src/components/chat/acp/agent-panel.tsx +26 -77
  11. package/src/components/chat/chat-components.tsx +114 -1
  12. package/src/components/chat/chat-panel.tsx +32 -104
  13. package/src/components/chat/chat-utils.ts +42 -0
  14. package/src/components/editor/ai/add-cell-with-ai.tsx +85 -53
  15. package/src/components/editor/ai/ai-completion-editor.tsx +15 -38
  16. package/src/components/editor/chrome/panels/packages-panel.tsx +12 -9
  17. package/src/core/islands/__tests__/bridge.test.ts +7 -2
  18. package/src/core/islands/bridge.ts +1 -1
  19. package/src/core/islands/main.ts +7 -0
  20. package/src/core/network/types.ts +2 -2
  21. package/src/core/wasm/bridge.ts +1 -1
  22. package/src/core/websocket/useMarimoKernelConnection.tsx +5 -15
  23. package/src/plugins/impl/anywidget/AnyWidgetPlugin.tsx +86 -167
  24. package/src/plugins/impl/anywidget/__tests__/AnyWidgetPlugin.test.tsx +37 -123
  25. package/src/plugins/impl/anywidget/__tests__/model.test.ts +128 -122
  26. package/src/{utils/__tests__/data-views.test.ts → plugins/impl/anywidget/__tests__/serialization.test.ts} +42 -96
  27. package/src/plugins/impl/anywidget/model.ts +348 -223
  28. package/src/plugins/impl/anywidget/schemas.ts +32 -0
  29. package/src/{utils/data-views.ts → plugins/impl/anywidget/serialization.ts} +13 -36
  30. package/src/plugins/impl/anywidget/types.ts +27 -0
  31. package/src/plugins/impl/chat/chat-ui.tsx +22 -20
  32. package/src/utils/Deferred.ts +21 -0
  33. package/src/utils/json/base64.ts +38 -8
  34. package/dist/assets/__vite-browser-external-6-UwTyQC.js +0 -1
  35. package/dist/assets/worker-D3e5wDxM.js +0 -73
@@ -7,18 +7,14 @@ import type { ReactCodeMirrorRef } from "@uiw/react-codemirror";
7
7
  import { DefaultChatTransport, type FileUIPart, type TextUIPart } from "ai";
8
8
  import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
9
9
  import {
10
- AtSignIcon,
11
10
  BotMessageSquareIcon,
12
11
  HatGlasses,
13
12
  Loader2,
14
13
  type LucideIcon,
15
14
  MessageCircleIcon,
16
15
  NotebookText,
17
- PaperclipIcon,
18
16
  PlusIcon,
19
- SendIcon,
20
17
  SettingsIcon,
21
- SquareIcon,
22
18
  } from "lucide-react";
23
19
  import { memo, useEffect, useRef, useState } from "react";
24
20
  import useEvent from "react-use-event-hook";
@@ -33,7 +29,7 @@ import {
33
29
  } from "@/components/ui/select";
34
30
  import { replaceMessagesInChat } from "@/core/ai/chat-utils";
35
31
  import { useModelChange } from "@/core/ai/config";
36
- import { AiModelId, type ProviderId } from "@/core/ai/ids/ids";
32
+ import { AiModelId } from "@/core/ai/ids/ids";
37
33
  import { useStagedAICellsActions } from "@/core/ai/staged-cells";
38
34
  import {
39
35
  activeChatAtom,
@@ -64,10 +60,14 @@ import {
64
60
  import { PanelEmptyState } from "../editor/chrome/panels/empty-state";
65
61
  import { CopyClipboardIcon } from "../icons/copy-icon";
66
62
  import { MCPStatusIndicator } from "../mcp/mcp-status-indicator";
67
- import { Input } from "../ui/input";
68
63
  import { Tooltip, TooltipProvider } from "../ui/tooltip";
69
- import { toast } from "../ui/use-toast";
70
- import { AttachmentRenderer, FileAttachmentPill } from "./chat-components";
64
+ import {
65
+ AddContextButton,
66
+ AttachFileButton,
67
+ AttachmentRenderer,
68
+ FileAttachmentPill,
69
+ SendButton,
70
+ } from "./chat-components";
71
71
  import { renderUIMessage } from "./chat-display";
72
72
  import { ChatHistoryPopover } from "./chat-history-popover";
73
73
  import {
@@ -77,21 +77,13 @@ import {
77
77
  handleToolCall,
78
78
  hasPendingToolCalls,
79
79
  isLastMessageReasoning,
80
+ PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
81
+ useFileState,
80
82
  } from "./chat-utils";
81
83
 
82
84
  // Default mode for the AI
83
85
  const DEFAULT_MODE = "manual";
84
86
 
85
- // We need to modify the backend to support attachments for other providers
86
- // And other types
87
- const PROVIDERS_THAT_SUPPORT_ATTACHMENTS = new Set<ProviderId>([
88
- "openai",
89
- "google",
90
- "anthropic",
91
- ]);
92
- const SUPPORTED_ATTACHMENT_TYPES = ["image/*", "text/*"];
93
- const MAX_ATTACHMENT_SIZE = 1024 * 1024 * 50; // 50MB
94
-
95
87
  interface ChatHeaderProps {
96
88
  onNewChat: () => void;
97
89
  activeChatId: ChatId | undefined;
@@ -316,60 +308,23 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
316
308
  />
317
309
  </div>
318
310
  <div className="flex flex-row">
319
- <Tooltip content="Add context">
320
- <Button
321
- variant="text"
322
- size="icon"
323
- onClick={onAddContext}
324
- disabled={isLoading}
325
- >
326
- <AtSignIcon className="h-3.5 w-3.5" />
327
- </Button>
328
- </Tooltip>
311
+ <AddContextButton
312
+ handleAddContext={onAddContext}
313
+ isLoading={isLoading}
314
+ />
329
315
  {isAttachmentSupported && (
330
- <>
331
- <Tooltip content="Attach a file">
332
- <Button
333
- variant="text"
334
- size="icon"
335
- className="cursor-pointer"
336
- onClick={() => fileInputRef.current?.click()}
337
- title="Attach a file"
338
- disabled={isLoading}
339
- >
340
- <PaperclipIcon className="h-3.5 w-3.5" />
341
- </Button>
342
- </Tooltip>
343
- <Input
344
- ref={fileInputRef}
345
- type="file"
346
- multiple={true}
347
- hidden={true}
348
- onChange={(event) => {
349
- if (event.target.files) {
350
- onAddFiles([...event.target.files]);
351
- }
352
- }}
353
- accept={SUPPORTED_ATTACHMENT_TYPES.join(",")}
354
- />
355
- </>
316
+ <AttachFileButton
317
+ fileInputRef={fileInputRef}
318
+ isLoading={isLoading}
319
+ onAddFiles={onAddFiles}
320
+ />
356
321
  )}
357
-
358
- <Tooltip content={isLoading ? "Stop" : "Submit"}>
359
- <Button
360
- variant="text"
361
- size="sm"
362
- className="h-6 w-6 p-0 hover:bg-muted/30 cursor-pointer"
363
- onClick={isLoading ? onStop : onSendClick}
364
- disabled={isLoading ? false : isEmpty}
365
- >
366
- {isLoading ? (
367
- <SquareIcon className="h-3 w-3 fill-current" />
368
- ) : (
369
- <SendIcon className="h-3 w-3" />
370
- )}
371
- </Button>
372
- </Tooltip>
322
+ <SendButton
323
+ isLoading={isLoading}
324
+ onStop={onStop}
325
+ onSendClick={onSendClick}
326
+ isEmpty={isEmpty}
327
+ />
373
328
  </div>
374
329
  </div>
375
330
  </TooltipProvider>
@@ -473,7 +428,7 @@ const ChatPanelBody = () => {
473
428
  const [activeChat, setActiveChat] = useAtom(activeChatAtom);
474
429
  const [input, setInput] = useState("");
475
430
  const [newThreadInput, setNewThreadInput] = useState("");
476
- const [files, setFiles] = useState<File[]>();
431
+ const { files, addFiles, clearFiles, removeFile } = useFileState();
477
432
  const newThreadInputRef = useRef<ReactCodeMirrorRef>(null);
478
433
  const newMessageInputRef = useRef<ReactCodeMirrorRef>(null);
479
434
  const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -562,33 +517,6 @@ const ChatPanelBody = () => {
562
517
  },
563
518
  });
564
519
 
565
- const onAddFiles = useEvent((files: File[]) => {
566
- if (files.length === 0) {
567
- return;
568
- }
569
-
570
- let fileSize = 0;
571
- for (const file of files) {
572
- fileSize += file.size;
573
- }
574
-
575
- if (fileSize > MAX_ATTACHMENT_SIZE) {
576
- toast({
577
- title: "File size exceeds 50MB limit",
578
- description: "Please remove some files and try again.",
579
- });
580
- return;
581
- }
582
-
583
- setFiles((prev) => [...(prev ?? []), ...files]);
584
- });
585
-
586
- const removeFile = useEvent((file: File) => {
587
- if (files) {
588
- setFiles(files.filter((f) => f !== file));
589
- }
590
- });
591
-
592
520
  const isLoading = status === "submitted" || status === "streaming";
593
521
 
594
522
  // Check if we're currently streaming reasoning in the latest message
@@ -648,7 +576,7 @@ const ChatPanelBody = () => {
648
576
  ...(fileParts ?? []),
649
577
  ],
650
578
  });
651
- setFiles(undefined);
579
+ clearFiles();
652
580
  setInput("");
653
581
  };
654
582
 
@@ -656,7 +584,7 @@ const ChatPanelBody = () => {
656
584
  setActiveChat(null);
657
585
  setInput("");
658
586
  setNewThreadInput("");
659
- setFiles(undefined);
587
+ clearFiles();
660
588
  });
661
589
 
662
590
  const handleMessageEdit = useEvent((index: number, newValue: string) => {
@@ -687,7 +615,7 @@ const ChatPanelBody = () => {
687
615
  files: fileParts,
688
616
  });
689
617
  setInput("");
690
- setFiles(undefined);
618
+ clearFiles();
691
619
  },
692
620
  );
693
621
 
@@ -720,7 +648,7 @@ const ChatPanelBody = () => {
720
648
  isLoading={isLoading}
721
649
  onStop={stop}
722
650
  fileInputRef={fileInputRef}
723
- onAddFiles={onAddFiles}
651
+ onAddFiles={addFiles}
724
652
  onClose={handleOnCloseThread}
725
653
  />
726
654
  ) : (
@@ -733,7 +661,7 @@ const ChatPanelBody = () => {
733
661
  onStop={stop}
734
662
  onClose={() => newMessageInputRef.current?.editor?.blur()}
735
663
  fileInputRef={fileInputRef}
736
- onAddFiles={onAddFiles}
664
+ onAddFiles={addFiles}
737
665
  />
738
666
  );
739
667
 
@@ -794,7 +722,7 @@ const ChatPanelBody = () => {
794
722
 
795
723
  {error && (
796
724
  <div className="flex items-center justify-center space-x-2 mb-4">
797
- <ErrorBanner error={error} />
725
+ <ErrorBanner error={error || new Error("Unknown error")} />
798
726
  <Button variant="outline" size="sm" onClick={handleReload}>
799
727
  Retry
800
728
  </Button>
@@ -2,6 +2,9 @@
2
2
 
3
3
  import type { components } from "@marimo-team/marimo-api";
4
4
  import type { FileUIPart, ToolUIPart, UIMessage } from "ai";
5
+ import { useState } from "react";
6
+ import useEvent from "react-use-event-hook";
7
+ import type { ProviderId } from "@/core/ai/ids/ids";
5
8
  import type { ToolNotebookContext } from "@/core/ai/tools/base";
6
9
  import { FRONTEND_TOOL_REGISTRY } from "@/core/ai/tools/registry";
7
10
  import type {
@@ -11,6 +14,17 @@ import type {
11
14
  import { blobToString } from "@/utils/fileToBase64";
12
15
  import { Logger } from "@/utils/Logger";
13
16
  import { getAICompletionBodyWithAttachments } from "../editor/ai/completion-utils";
17
+ import { toast } from "../ui/use-toast";
18
+
19
+ // We need to modify the backend to support attachments for other providers
20
+ // And other types
21
+ export const PROVIDERS_THAT_SUPPORT_ATTACHMENTS = new Set<ProviderId>([
22
+ "openai",
23
+ "google",
24
+ "anthropic",
25
+ ]);
26
+ export const SUPPORTED_ATTACHMENT_TYPES = ["image/*", "text/*"];
27
+ const MAX_ATTACHMENT_SIZE = 1024 * 1024 * 50; // 50MB
14
28
 
15
29
  export function generateChatTitle(message: string): string {
16
30
  return message.length > 50 ? `${message.slice(0, 50)}...` : message;
@@ -198,3 +212,31 @@ export function hasPendingToolCalls(messages: UIMessage[]): boolean {
198
212
  // Only auto-send if we have completed tool calls and there is no reply yet
199
213
  return allToolCallsCompleted && !hasTextContent;
200
214
  }
215
+
216
+ export function useFileState() {
217
+ const [files, setFiles] = useState<File[]>([]);
218
+
219
+ const addFiles = useEvent((newFiles: File[]) => {
220
+ if (newFiles.length === 0) {
221
+ return;
222
+ }
223
+
224
+ const totalSize = newFiles.reduce((size, file) => size + file.size, 0);
225
+ if (totalSize > MAX_ATTACHMENT_SIZE) {
226
+ toast({
227
+ title: "File size exceeded",
228
+ description: "Attachments must be under 50 MB",
229
+ variant: "danger",
230
+ });
231
+ return;
232
+ }
233
+
234
+ setFiles((prev) => [...prev, ...newFiles]);
235
+ });
236
+
237
+ const clearFiles = () => setFiles([]);
238
+ const removeFile = (fileToRemove: File) =>
239
+ setFiles((prev) => prev.filter((f) => f !== fileToRemove));
240
+
241
+ return { files, addFiles, clearFiles, removeFile };
242
+ }
@@ -21,8 +21,6 @@ import { atomWithStorage } from "jotai/utils";
21
21
  import {
22
22
  ChevronsUpDown,
23
23
  DatabaseIcon,
24
- Loader2Icon,
25
- SendHorizontal,
26
24
  SparklesIcon,
27
25
  XIcon,
28
26
  } from "lucide-react";
@@ -30,9 +28,18 @@ import { useMemo, useRef, useState } from "react";
30
28
  import useEvent from "react-use-event-hook";
31
29
  import { z } from "zod";
32
30
  import { AIModelDropdown } from "@/components/ai/ai-model-dropdown";
31
+ import {
32
+ AddContextButton,
33
+ AttachFileButton,
34
+ FileAttachmentPill,
35
+ SendButton,
36
+ } from "@/components/chat/chat-components";
33
37
  import {
34
38
  buildCompletionRequestBody,
39
+ convertToFileUIPart,
35
40
  handleToolCall,
41
+ PROVIDERS_THAT_SUPPORT_ATTACHMENTS,
42
+ useFileState,
36
43
  } from "@/components/chat/chat-utils";
37
44
  import { Button } from "@/components/ui/button";
38
45
  import {
@@ -43,10 +50,13 @@ import {
43
50
  DropdownMenuTrigger,
44
51
  } from "@/components/ui/dropdown-menu";
45
52
  import { toast } from "@/components/ui/use-toast";
53
+ import { AiModelId } from "@/core/ai/ids/ids";
46
54
  import { stagedAICellsAtom, useStagedCells } from "@/core/ai/staged-cells";
47
55
  import type { ToolNotebookContext } from "@/core/ai/tools/base";
48
56
  import { useCellActions } from "@/core/cells/cells";
49
57
  import { resourceExtension } from "@/core/codemirror/ai/resources";
58
+ import { aiAtom } from "@/core/config/config";
59
+ import { DEFAULT_AI_MODEL } from "@/core/config/config-schema";
50
60
  import { useRequestClient } from "@/core/network/requests";
51
61
  import type { AiCompletionRequest } from "@/core/network/types";
52
62
  import { useRuntimeManager } from "@/core/runtime/config";
@@ -57,7 +67,11 @@ import { jotaiJsonStorage } from "@/utils/storage/jotai";
57
67
  import { ZodLocalStorage } from "@/utils/storage/typed";
58
68
  import { PythonIcon } from "../cell/code/icons";
59
69
  import { createAiCompletionOnKeydown } from "./completion-handlers";
60
- import { CONTEXT_TRIGGER, mentionsCompletionSource } from "./completion-utils";
70
+ import {
71
+ addContextCompletion,
72
+ CONTEXT_TRIGGER,
73
+ mentionsCompletionSource,
74
+ } from "./completion-utils";
61
75
  import { StreamingChunkTransport } from "./transport/chat-transport";
62
76
 
63
77
  // Persist across sessions
@@ -89,6 +103,10 @@ export const AddCellWithAI: React.FC<{
89
103
  const stagedAICells = useAtomValue(stagedAICellsAtom);
90
104
  const inputRef = useRef<ReactCodeMirrorRef>(null);
91
105
 
106
+ const fileInputRef = useRef<HTMLInputElement>(null);
107
+ const { files, addFiles, removeFile } = useFileState();
108
+ const aiConfig = useAtomValue(aiAtom);
109
+
92
110
  const { createNewCell, prepareForRun } = useCellActions();
93
111
  const toolContext: ToolNotebookContext = {
94
112
  store,
@@ -149,14 +167,21 @@ export const AddCellWithAI: React.FC<{
149
167
  const isLoading = status === "streaming" || status === "submitted";
150
168
  const hasCompletion = stagedAICells.size > 0;
151
169
 
152
- const submit = () => {
170
+ const currentModel = aiConfig?.models?.edit_model || DEFAULT_AI_MODEL;
171
+ const currentProvider = AiModelId.parse(currentModel).providerId;
172
+ const isAttachmentSupported =
173
+ PROVIDERS_THAT_SUPPORT_ATTACHMENTS.has(currentProvider);
174
+
175
+ const submit = async () => {
153
176
  if (!isLoading) {
154
177
  if (inputRef.current?.view) {
155
178
  storePrompt(inputRef.current.view);
156
179
  }
157
180
  // TODO: When we have conversations, don't delete existing cells
158
181
  deleteAllStagedCells();
159
- sendMessage({ text: input });
182
+
183
+ const fileParts = files ? await convertToFileUIPart(files) : undefined;
184
+ sendMessage({ text: input, files: fileParts });
160
185
  }
161
186
  };
162
187
 
@@ -176,19 +201,17 @@ export const AddCellWithAI: React.FC<{
176
201
 
177
202
  const languageDropdown = (
178
203
  <DropdownMenu modal={false}>
179
- <DropdownMenuTrigger asChild={true}>
180
- <Button
181
- variant="text"
182
- className="ml-2"
183
- size="xs"
184
- data-testid="language-button"
185
- >
186
- {language === "python" ? pythonIcon : sqlIcon}
187
- <ChevronsUpDown className="ml-1 h-3.5 w-3.5 text-muted-foreground/70" />
188
- </Button>
204
+ <DropdownMenuTrigger
205
+ className="flex items-center justify-between h-7 text-xs px-2 py-0.5 border rounded-md hover:text-accent-foreground"
206
+ data-testid="language-button"
207
+ >
208
+ {language === "python" ? pythonIcon : sqlIcon}
209
+ <ChevronsUpDown className="ml-1 h-3.5 w-3.5 text-muted-foreground/70" />
189
210
  </DropdownMenuTrigger>
190
211
  <DropdownMenuContent align="center">
191
- <div className="px-2 py-1 font-semibold">Select language</div>
212
+ <div className="px-2 py-1 text-sm text-muted-foreground">
213
+ Select language
214
+ </div>
192
215
  <DropdownMenuSeparator />
193
216
  <DropdownMenuItem onClick={() => setLanguage("python")}>
194
217
  {pythonIcon}
@@ -230,54 +253,63 @@ export const AddCellWithAI: React.FC<{
230
253
  hasCompletion,
231
254
  })}
232
255
  />
233
- {isLoading && (
234
- <Button
235
- data-testid="stop-completion-button"
236
- variant="text"
237
- size="sm"
238
- className="mb-0"
239
- onClick={stop}
240
- >
241
- <Loader2Icon className="animate-spin mr-1" size={14} />
242
- Stop
243
- </Button>
244
- )}
245
- <Button variant="text" size="sm" onClick={submit} title="Submit">
246
- <SendHorizontal className="size-4" />
247
- </Button>
248
256
  <Button variant="text" size="sm" className="mb-0 px-1" onClick={onClose}>
249
257
  <XIcon className="size-4" />
250
258
  </Button>
251
259
  </div>
252
260
  );
253
261
 
254
- return (
255
- <div className={cn("flex flex-col w-full py-2")}>
256
- {inputComponent}
257
- <div className="flex flex-row justify-between -mt-1 ml-1 mr-3">
258
- {!hasCompletion && (
259
- <span className="text-xs text-muted-foreground px-3 flex flex-col gap-1 mt-2">
260
- <span>
261
- You can mention{" "}
262
- <span className="text-(--cyan-11)">@dataframe</span> or{" "}
263
- <span className="text-(--cyan-11)">@sql_table</span> to pull
264
- additional context such as column names. Code from other cells is
265
- automatically included.
266
- </span>
267
- </span>
262
+ const footerComponent = (
263
+ <div className="px-3 pt-1 flex flex-row items-center justify-between">
264
+ <div className="flex items-center gap-2">
265
+ <AIModelDropdown
266
+ triggerClassName="h-7 text-xs max-w-64"
267
+ iconSize="small"
268
+ forRole="edit"
269
+ showAddCustomModelDocs={true}
270
+ />
271
+ {languageDropdown}
272
+ </div>
273
+ <div className="flex flex-row items-center">
274
+ {files.length > 0 && (
275
+ <div className="flex flex-row gap-1 flex-wrap pr-1">
276
+ {files?.map((file, index) => (
277
+ <FileAttachmentPill
278
+ file={file}
279
+ key={`${file.name}-${index}`}
280
+ onRemove={() => removeFile(file)}
281
+ />
282
+ ))}
283
+ </div>
268
284
  )}
269
- <div className="ml-auto flex items-center gap-1">
270
- {languageDropdown}
271
- <AIModelDropdown
272
- triggerClassName="h-7 text-xs max-w-64"
273
- iconSize="small"
274
- forRole="edit"
275
- showAddCustomModelDocs={true}
285
+ <AddContextButton
286
+ handleAddContext={() => addContextCompletion(inputRef)}
287
+ isLoading={isLoading}
288
+ />
289
+ {isAttachmentSupported && (
290
+ <AttachFileButton
291
+ fileInputRef={fileInputRef}
292
+ isLoading={isLoading}
293
+ onAddFiles={addFiles}
276
294
  />
277
- </div>
295
+ )}
296
+ <SendButton
297
+ isLoading={isLoading}
298
+ onStop={stop}
299
+ onSendClick={submit}
300
+ isEmpty={!input.trim()}
301
+ showStopLabel={true}
302
+ />
278
303
  </div>
279
304
  </div>
280
305
  );
306
+
307
+ return (
308
+ <div className={cn("flex flex-col w-full py-2")}>
309
+ {inputComponent}
310
+ {footerComponent}
311
+ </div>
312
+ );
281
313
  };
282
314
 
283
315
  export interface AdditionalCompletions {
@@ -3,10 +3,8 @@
3
3
  import { useCompletion } from "@ai-sdk/react";
4
4
  import { EditorView } from "@codemirror/view";
5
5
  import {
6
- AtSignIcon,
7
6
  CircleCheckIcon,
8
7
  Loader2Icon,
9
- SendIcon,
10
8
  SparklesIcon,
11
9
  XIcon,
12
10
  } from "lucide-react";
@@ -20,6 +18,10 @@ import { storePrompt } from "@marimo-team/codemirror-ai";
20
18
  import type { ReactCodeMirrorRef } from "@uiw/react-codemirror";
21
19
  import { useAtom, useAtomValue } from "jotai";
22
20
  import { AIModelDropdown } from "@/components/ai/ai-model-dropdown";
21
+ import {
22
+ AddContextButton,
23
+ SendButton,
24
+ } from "@/components/chat/chat-components";
23
25
  import { Checkbox } from "@/components/ui/checkbox";
24
26
  import { Label } from "@/components/ui/label";
25
27
  import { Switch } from "@/components/ui/switch";
@@ -253,39 +255,6 @@ export const AiCompletionEditor: React.FC<Props> = ({
253
255
  }
254
256
  };
255
257
 
256
- const loadingStopButton = (
257
- <Button
258
- data-testid="stop-completion-button"
259
- variant="text"
260
- size="xs"
261
- className="mb-0"
262
- onClick={stop}
263
- >
264
- <Loader2Icon className="animate-spin mr-1" size={14} />
265
- Stop
266
- </Button>
267
- );
268
-
269
- const submitButton = (
270
- <Tooltip content="Submit">
271
- <Button variant="text" size="icon" onClick={handleSubmit}>
272
- <SendIcon className="h-3 w-3" />
273
- </Button>
274
- </Tooltip>
275
- );
276
-
277
- const contextButton = (
278
- <Tooltip content="Add context">
279
- <Button
280
- variant="text"
281
- size="icon"
282
- onClick={() => addContextCompletion(inputRef)}
283
- >
284
- <AtSignIcon className="h-3 w-3" />
285
- </Button>
286
- </Tooltip>
287
- );
288
-
289
258
  const completionButtons = (
290
259
  <>
291
260
  <AcceptCompletionButton
@@ -355,9 +324,17 @@ export const AiCompletionEditor: React.FC<Props> = ({
355
324
 
356
325
  <div className="-mr-1.5 py-1.5">
357
326
  <div className="flex flex-row items-center justify-end gap-0.5">
358
- {isLoading && loadingStopButton}
359
- {submitButton}
360
- {contextButton}
327
+ <SendButton
328
+ isLoading={isLoading}
329
+ onStop={stop}
330
+ onSendClick={handleSubmit}
331
+ isEmpty={!input.trim()}
332
+ showStopLabel={true}
333
+ />
334
+ <AddContextButton
335
+ handleAddContext={() => addContextCompletion(inputRef)}
336
+ isLoading={isLoading}
337
+ />
361
338
  <AIModelDropdown
362
339
  triggerClassName="h-7 text-xs"
363
340
  iconSize="small"
@@ -349,8 +349,9 @@ const PackagesList: React.FC<{
349
349
 
350
350
  const UpgradeButton: React.FC<{
351
351
  packageName: string;
352
+ tags?: { kind: string; value: string }[];
352
353
  onSuccess: () => void;
353
- }> = ({ packageName, onSuccess }) => {
354
+ }> = ({ packageName, tags, onSuccess }) => {
354
355
  const [loading, setLoading] = React.useState(false);
355
356
  const { addPackage } = useRequestClient();
356
357
 
@@ -362,9 +363,11 @@ const UpgradeButton: React.FC<{
362
363
  const handleUpgradePackage = async () => {
363
364
  try {
364
365
  setLoading(true);
366
+ const group = tags?.find((tag) => tag.kind === "group")?.value;
365
367
  const response = await addPackage({
366
368
  package: packageName,
367
369
  upgrade: true,
370
+ group,
368
371
  });
369
372
  if (response.success) {
370
373
  onSuccess();
@@ -395,12 +398,10 @@ const RemoveButton: React.FC<{
395
398
  const handleRemovePackage = async () => {
396
399
  try {
397
400
  setLoading(true);
398
- const isDev = tags?.some(
399
- (tag) => tag.kind === "group" && tag.value === "dev",
400
- );
401
+ const group = tags?.find((tag) => tag.kind === "group")?.value;
401
402
  const response = await removePackage({
402
403
  package: packageName,
403
- dev: isDev,
404
+ group,
404
405
  });
405
406
  if (response.success) {
406
407
  onSuccess();
@@ -604,12 +605,14 @@ const DependencyTreeNode: React.FC<{
604
605
  {/* Actions for top-level packages */}
605
606
  {isTopLevel && (
606
607
  <div className="flex gap-1 invisible group-hover:visible">
607
- <UpgradeButton packageName={node.name} onSuccess={onSuccess} />
608
+ <UpgradeButton
609
+ packageName={node.name}
610
+ tags={node.tags}
611
+ onSuccess={onSuccess}
612
+ />
613
+
608
614
  <RemoveButton
609
615
  packageName={node.name}
610
- // FIXME: Backend types are wrong/outdated.
611
- // tags actually have the shape: Array<{ kind: string; value: string }>
612
- // @ts-expect-error — backend tag types do not match frontend expectations yet
613
616
  tags={node.tags}
614
617
  onSuccess={onSuccess}
615
618
  />