@marimo-team/islands 0.23.17-dev43 → 0.23.17-dev45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.17-dev43",
3
+ "version": "0.23.17-dev45",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -9,7 +9,7 @@ import {
9
9
  CircleHelpIcon,
10
10
  } from "lucide-react";
11
11
  import React from "react";
12
- import { type SupportedRole, useModelChange } from "@/core/ai/config";
12
+ import { type SupportedRole, useAIConfigActions } from "@/core/ai/config";
13
13
  import {
14
14
  AiModelId,
15
15
  isKnownAIProvider,
@@ -64,7 +64,7 @@ export const AIModelDropdown = ({
64
64
 
65
65
  const ai = useAtomValue(aiAtom);
66
66
  const completion = useAtomValue(completionAtom);
67
- const { saveModelChange } = useModelChange();
67
+ const { saveModelChange } = useAIConfigActions();
68
68
  const { handleClick } = useOpenSettingsToTab();
69
69
 
70
70
  // Only include autocompleteModel if copilot is set to "custom"
@@ -0,0 +1,38 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { fireEvent, render, screen } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import { describe, expect, it } from "vitest";
6
+ import { chatOptionsAtom } from "@/core/ai/state";
7
+ import { CapabilitiesPopover } from "../capabilities-popover";
8
+
9
+ describe("CapabilitiesPopover", () => {
10
+ it("updates ephemeral chat options", () => {
11
+ const store = createStore();
12
+
13
+ render(
14
+ <Provider store={store}>
15
+ <CapabilitiesPopover />
16
+ </Provider>,
17
+ );
18
+
19
+ const trigger = screen.getByRole("button", { name: "Capabilities" });
20
+ expect(trigger).not.toHaveAttribute("data-active");
21
+
22
+ fireEvent.click(trigger);
23
+ const webSearch = screen.getByRole("switch", { name: "Web search" });
24
+ expect(webSearch).not.toBeChecked();
25
+
26
+ fireEvent.click(webSearch);
27
+
28
+ expect(webSearch).toBeChecked();
29
+ expect(trigger).toHaveAttribute("data-active", "true");
30
+ expect(trigger).toHaveClass("bg-primary/15", "text-primary", "ring-1");
31
+ expect(trigger).toHaveAttribute(
32
+ "title",
33
+ "Capabilities (web search enabled)",
34
+ );
35
+ expect(store.get(chatOptionsAtom)).toEqual({ webSearch: true });
36
+ expect(createStore().get(chatOptionsAtom)).toEqual({ webSearch: false });
37
+ });
38
+ });
@@ -0,0 +1,68 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useAtom } from "jotai";
4
+ import { SlidersHorizontalIcon } from "lucide-react";
5
+ import type React from "react";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ Popover,
9
+ PopoverContent,
10
+ PopoverTrigger,
11
+ } from "@/components/ui/popover";
12
+ import { ExternalLink } from "@/components/ui/links";
13
+ import { Switch } from "@/components/ui/switch";
14
+ import { chatOptionsAtom } from "@/core/ai/state";
15
+ import { cn } from "@/utils/cn";
16
+
17
+ export const CapabilitiesPopover: React.FC = () => {
18
+ const [chatOptions, setChatOptions] = useAtom(chatOptionsAtom);
19
+ const webSearchOn = chatOptions.webSearch;
20
+
21
+ return (
22
+ <Popover>
23
+ <PopoverTrigger asChild={true}>
24
+ <Button
25
+ aria-label="Capabilities"
26
+ title={
27
+ webSearchOn ? "Capabilities (web search enabled)" : "Capabilities"
28
+ }
29
+ data-active={webSearchOn || undefined}
30
+ variant="text"
31
+ size="icon"
32
+ className={cn(
33
+ "h-6 w-6 shrink-0 bg-muted hover:bg-muted/30",
34
+ webSearchOn &&
35
+ "bg-primary/15 text-primary ring-1 ring-primary/30 opacity-100 hover:bg-primary/20",
36
+ )}
37
+ >
38
+ <SlidersHorizontalIcon className="h-3.5 w-3.5" />
39
+ </Button>
40
+ </PopoverTrigger>
41
+ <PopoverContent className="w-76 p-3" align="start" side="top">
42
+ <label
43
+ htmlFor="web-search-toggle"
44
+ className="flex items-start justify-between gap-3 cursor-pointer"
45
+ >
46
+ <div className="flex flex-col gap-0.5">
47
+ <span className="text-sm font-semibold">Web search</span>
48
+ <span className="text-xs text-muted-foreground">
49
+ Search the web when the model supports it.{" "}
50
+ <ExternalLink href="https://docs.marimo.io/guides/editor_features/tools#web-search-and-fetch">
51
+ Learn more
52
+ </ExternalLink>
53
+ </span>
54
+ </div>
55
+ <Switch
56
+ id="web-search-toggle"
57
+ aria-label="Web search"
58
+ size="sm"
59
+ checked={webSearchOn}
60
+ onCheckedChange={(webSearch) =>
61
+ setChatOptions((options) => ({ ...options, webSearch }))
62
+ }
63
+ />
64
+ </label>
65
+ </PopoverContent>
66
+ </Popover>
67
+ );
68
+ };
@@ -36,7 +36,7 @@ import {
36
36
  SelectTrigger,
37
37
  } from "@/components/ui/select";
38
38
  import { replaceMessagesInChat } from "@/core/ai/chat-utils";
39
- import { useModelChange } from "@/core/ai/config";
39
+ import { useAIConfigActions } from "@/core/ai/config";
40
40
  import { AI_SDK_UI_THROTTLE_MS } from "@/core/ai/constants";
41
41
  import { AiModelId } from "@/core/ai/ids/ids";
42
42
  import { useStagedAICellsActions } from "@/core/ai/staged-cells";
@@ -44,6 +44,7 @@ import {
44
44
  activeChatAtom,
45
45
  type Chat,
46
46
  type ChatId,
47
+ chatOptionsAtom,
47
48
  chatStateAtom,
48
49
  pendingAiPromptAtom,
49
50
  } from "@/core/ai/state";
@@ -91,6 +92,7 @@ import {
91
92
  } from "./chat-abort";
92
93
  import { renderUIMessage } from "./chat-display";
93
94
  import { ChatHistoryPopover } from "./chat-history-popover";
95
+ import { CapabilitiesPopover } from "./capabilities-popover";
94
96
  import {
95
97
  type ChatMessagePart,
96
98
  convertToFileUIPart,
@@ -298,7 +300,7 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
298
300
  const currentModel = ai?.models?.chat_model || DEFAULT_AI_MODEL;
299
301
  const currentProvider = AiModelId.parse(currentModel).providerId;
300
302
 
301
- const { saveModeChange } = useModelChange();
303
+ const { saveModeChange } = useAIConfigActions();
302
304
 
303
305
  const modeOptions: {
304
306
  value: CopilotMode;
@@ -326,8 +328,9 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
326
328
  },
327
329
  {
328
330
  value: "code_mode",
329
- label: "Code Mode (experimental)",
330
- subtitle: "AI with access to the notebook's kernel. Use with caution.",
331
+ label: "Code Mode",
332
+ subtitle:
333
+ "AI with access to the notebook's kernel. Can overwrite changes.",
331
334
  Icon: CodeIcon,
332
335
  },
333
336
  ];
@@ -341,12 +344,12 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
341
344
 
342
345
  return (
343
346
  <TooltipProvider>
344
- <div className="px-3 py-2 border-t border-border/20 flex flex-row flex-wrap items-center justify-between gap-1">
345
- <div className="flex items-center gap-2">
347
+ <div className="px-3 py-2 border-t border-border/20 flex flex-wrap items-center gap-1">
348
+ <div className="flex flex-wrap items-center gap-1">
346
349
  <Select value={currentMode} onValueChange={saveModeChange}>
347
- <SelectTrigger className="h-6 text-xs border-border shadow-none! ring-0! bg-muted hover:bg-muted/30 py-0 px-2 gap-1.5">
350
+ <SelectTrigger className="h-6 text-xs border-border shadow-none! ring-0! bg-muted hover:bg-muted/30 py-0 px-2 gap-1.5 shrink-0">
348
351
  {CurrentModeIcon && <CurrentModeIcon className="h-3 w-3" />}
349
- <span>{CurrentModeLabel}</span>
352
+ <span className="text-nowrap">{CurrentModeLabel}</span>
350
353
  </SelectTrigger>
351
354
  <SelectContent>
352
355
  <SelectGroup>
@@ -377,13 +380,14 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
377
380
  </Select>
378
381
  <AIModelDropdown
379
382
  placeholder="Model"
380
- triggerClassName="h-6 text-xs shadow-none! ring-0! bg-muted hover:bg-muted/30 rounded-sm"
383
+ triggerClassName="h-6 text-xs shadow-none! ring-0! bg-muted hover:bg-muted/30 rounded-sm max-w-[200px]"
381
384
  iconSize="small"
382
385
  showAddCustomModelDocs={true}
383
386
  forRole="chat"
384
387
  />
388
+ <CapabilitiesPopover />
385
389
  </div>
386
- <div className="flex flex-row">
390
+ <div className="flex flex-row ml-auto">
387
391
  <AddContextButton
388
392
  handleAddContext={onAddContext}
389
393
  isLoading={isLoading}
@@ -445,8 +449,8 @@ const ChatInput: React.FC<ChatInputProps> = memo(
445
449
  });
446
450
 
447
451
  return (
448
- <div className="relative shrink-0 min-h-[80px] flex flex-col border-t">
449
- <div className={cn("px-2 py-3 flex-1", inputClassName)}>
452
+ <div className="relative shrink-0 flex flex-col border-t">
453
+ <div className={cn("px-2 py-1.5", inputClassName)}>
450
454
  <PromptInput
451
455
  className="max-h-[400px]"
452
456
  inputRef={inputRef}
@@ -598,6 +602,7 @@ const ChatPanelBody = () => {
598
602
  const completionBody = {
599
603
  uiMessages: options.messages,
600
604
  includeOtherCode: getCodes(""),
605
+ options: store.get(chatOptionsAtom),
601
606
  };
602
607
 
603
608
  // Call this here to ensure the value is not stale
@@ -4,7 +4,7 @@ import { useSetAtom, useStore } from "jotai";
4
4
  import useEvent from "react-use-event-hook";
5
5
  import { agentSessionStateAtom } from "@/components/chat/acp/state";
6
6
  import { toast } from "@/components/ui/use-toast";
7
- import { useModelChange } from "@/core/ai/config";
7
+ import { useAIConfigActions } from "@/core/ai/config";
8
8
  import { pendingAiPromptAtom } from "@/core/ai/state";
9
9
  import type { CopilotMode } from "@/core/ai/tools/registry";
10
10
  import { aiModelConfiguredAtom } from "@/core/config/config";
@@ -43,7 +43,7 @@ export function resolveAiPanelTab(
43
43
  export function useOpenAiAssistant() {
44
44
  const { openApplication } = useChromeActions();
45
45
  const { setAiPanelTab } = useAiPanelTab();
46
- const { saveModeChange } = useModelChange();
46
+ const { saveModeChange } = useAIConfigActions();
47
47
  const setPendingPrompt = useSetAtom(pendingAiPromptAtom);
48
48
  const store = useStore();
49
49
 
@@ -0,0 +1,96 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { createStore, Provider } from "jotai";
5
+ import type { ReactNode } from "react";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { userConfigAtom } from "@/core/config/config";
8
+ import { defaultUserConfig } from "@/core/config/config-schema";
9
+ import { requestClientAtom } from "@/core/network/requests";
10
+ import type { EditRequests, RunRequests } from "@/core/network/types";
11
+ import { AiModelId } from "../ids/ids";
12
+ import { useAIConfigActions } from "../config";
13
+
14
+ describe("useAIConfigActions", () => {
15
+ it("persists a model change without overwriting newer AI config", async () => {
16
+ const store = createStore();
17
+ const saveUserConfig = vi.fn().mockResolvedValue(null);
18
+ store.set(userConfigAtom, defaultUserConfig());
19
+ store.set(requestClientAtom, {
20
+ saveUserConfig,
21
+ } as unknown as EditRequests & RunRequests);
22
+
23
+ const wrapper = ({ children }: { children: ReactNode }) => (
24
+ <Provider store={store}>{children}</Provider>
25
+ );
26
+ const { result } = renderHook(() => useAIConfigActions(), { wrapper });
27
+
28
+ store.set(userConfigAtom, (config) => ({
29
+ ...config,
30
+ ai: {
31
+ ...config.ai,
32
+ mode: "agent",
33
+ },
34
+ }));
35
+
36
+ await act(async () => {
37
+ await result.current.saveModelChange(
38
+ AiModelId.parse("openai/gpt-4o").id,
39
+ "chat",
40
+ );
41
+ });
42
+
43
+ expect(saveUserConfig).toHaveBeenCalledWith({
44
+ config: { ai: { models: { chat_model: "openai/gpt-4o" } } },
45
+ });
46
+ expect(store.get(userConfigAtom).ai).toMatchObject({
47
+ mode: "agent",
48
+ models: { chat_model: "openai/gpt-4o" },
49
+ });
50
+ });
51
+
52
+ it("preserves concurrent AI changes when saves resolve out of order", async () => {
53
+ const store = createStore();
54
+ const resolvers: Array<() => void> = [];
55
+ const saveUserConfig = vi.fn(
56
+ () =>
57
+ new Promise<null>((resolve) => {
58
+ resolvers.push(() => resolve(null));
59
+ }),
60
+ );
61
+ store.set(userConfigAtom, defaultUserConfig());
62
+ store.set(requestClientAtom, {
63
+ saveUserConfig,
64
+ } as unknown as EditRequests & RunRequests);
65
+
66
+ const wrapper = ({ children }: { children: ReactNode }) => (
67
+ <Provider store={store}>{children}</Provider>
68
+ );
69
+ const { result } = renderHook(() => useAIConfigActions(), { wrapper });
70
+
71
+ const modelChange = result.current.saveModelChange(
72
+ AiModelId.parse("openai/gpt-4o").id,
73
+ "chat",
74
+ );
75
+ const modeChange = result.current.saveModeChange("agent");
76
+
77
+ expect(saveUserConfig).toHaveBeenNthCalledWith(1, {
78
+ config: { ai: { models: { chat_model: "openai/gpt-4o" } } },
79
+ });
80
+ expect(saveUserConfig).toHaveBeenNthCalledWith(2, {
81
+ config: { ai: { mode: "agent" } },
82
+ });
83
+
84
+ await act(async () => {
85
+ resolvers[1]();
86
+ await modeChange;
87
+ resolvers[0]();
88
+ await modelChange;
89
+ });
90
+
91
+ expect(store.get(userConfigAtom).ai).toMatchObject({
92
+ mode: "agent",
93
+ models: { chat_model: "openai/gpt-4o" },
94
+ });
95
+ });
96
+ });
@@ -1,7 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import type { Role } from "@marimo-team/llm-info";
4
- import { useAtom } from "jotai";
4
+ import { useSetAtom } from "jotai";
5
+ import { merge } from "lodash-es";
5
6
  import type { QualifiedModelId } from "@/core/ai/ids/ids";
6
7
  import { userConfigAtom } from "@/core/config/config";
7
8
  import type {
@@ -14,6 +15,11 @@ import { useRequestClient } from "@/core/network/requests";
14
15
  // Extract only the supported roles from the Role type
15
16
  export type SupportedRole = Extract<Role, "chat" | "autocomplete" | "edit">;
16
17
 
18
+ interface AiConfigPatch {
19
+ mode?: CopilotMode;
20
+ models?: Partial<NonNullable<NonNullable<UserConfig["ai"]>["models"]>>;
21
+ }
22
+
17
23
  const getModelKeyForRole = (forRole: SupportedRole): AIModelKey | null => {
18
24
  switch (forRole) {
19
25
  case "chat":
@@ -26,15 +32,16 @@ const getModelKeyForRole = (forRole: SupportedRole): AIModelKey | null => {
26
32
  };
27
33
 
28
34
  /**
29
- * Hook for saving model and mode changes.
35
+ * Hook for saving AI config changes
30
36
  */
31
- export const useModelChange = () => {
32
- const [userConfig, setUserConfig] = useAtom(userConfigAtom);
37
+ export const useAIConfigActions = () => {
38
+ const setUserConfig = useSetAtom(userConfigAtom);
33
39
  const { saveUserConfig } = useRequestClient();
34
40
 
35
- const saveConfig = async (newConfig: Partial<UserConfig>) => {
41
+ const saveConfig = async (aiConfig: AiConfigPatch) => {
42
+ const newConfig = { ai: aiConfig };
36
43
  await saveUserConfig({ config: newConfig }).then(() => {
37
- setUserConfig((prev) => ({ ...prev, ...newConfig }));
44
+ setUserConfig((prev) => merge({}, prev, newConfig));
38
45
  });
39
46
  };
40
47
 
@@ -48,28 +55,15 @@ export const useModelChange = () => {
48
55
  return;
49
56
  }
50
57
 
51
- const newConfig: Partial<UserConfig> = {
52
- ai: {
53
- ...userConfig.ai,
54
- models: {
55
- custom_models: userConfig.ai?.models?.custom_models ?? [],
56
- displayed_models: userConfig.ai?.models?.displayed_models ?? [],
57
- ...userConfig.ai?.models,
58
- [modelKey]: model,
59
- },
60
- },
58
+ const newConfig: AiConfigPatch = {
59
+ models: { [modelKey]: model },
61
60
  };
62
61
 
63
62
  await saveConfig(newConfig);
64
63
  };
65
64
 
66
65
  const saveModeChange = async (newMode: CopilotMode) => {
67
- const newConfig: Partial<UserConfig> = {
68
- ai: {
69
- ...userConfig.ai,
70
- mode: newMode,
71
- },
72
- };
66
+ const newConfig: AiConfigPatch = { mode: newMode };
73
67
 
74
68
  await saveConfig(newConfig);
75
69
  };
@@ -8,6 +8,7 @@ import { uniqueBy } from "@/utils/arrays";
8
8
  import { adaptForLocalStorage, jotaiJsonStorage } from "@/utils/storage/jotai";
9
9
  import type { TypedString } from "@/utils/typed";
10
10
  import type { CellId } from "../cells/ids";
11
+ import type { ChatOptions } from "../network/types";
11
12
 
12
13
  const KEY = "marimo:ai:chatState:v5";
13
14
 
@@ -32,6 +33,15 @@ export interface PendingAiPrompt {
32
33
 
33
34
  export const pendingAiPromptAtom = atom<PendingAiPrompt | null>(null);
34
35
 
36
+ const CHAT_OPTIONS_KEY = "marimo:ai:chatOptions";
37
+ export const chatOptionsAtom = atomWithStorage<Required<ChatOptions>>(
38
+ CHAT_OPTIONS_KEY,
39
+ {
40
+ webSearch: false,
41
+ },
42
+ jotaiJsonStorage,
43
+ );
44
+
35
45
  const INCLUDE_OTHER_CELLS_KEY = "marimo:ai:includeOtherCells";
36
46
  export const includeOtherCellsAtom = atomWithStorage<boolean>(
37
47
  INCLUDE_OTHER_CELLS_KEY,
@@ -131,6 +131,7 @@ export type OpenTutorialRequest = schemas["OpenTutorialRequest"];
131
131
  export type TutorialId = OpenTutorialRequest["tutorialId"];
132
132
  export type InvokeAiToolRequest = schemas["InvokeAiToolRequest"];
133
133
  export type InvokeAiToolResponse = schemas["InvokeAiToolResponse"];
134
+ export type ChatOptions = schemas["ChatOptions"];
134
135
  export type ClearCacheRequest = schemas["ClearCacheRequest"];
135
136
  export type GetCacheInfoRequest = schemas["GetCacheInfoRequest"];
136
137
  export type LspHealthResponse = schemas["LspHealthResponse"];