diff --git a/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx b/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx
new file mode 100644
index 00000000..a24691ba
--- /dev/null
+++ b/apps/web/src/components/ai-sidebar/AiAgentSelector.tsx
@@ -0,0 +1,142 @@
+import { useEffect, useState } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChevronDown, Loader2 } from "lucide-react";
+import { Link } from "react-router";
+import { WORKSPACE_SETTINGS_PATH } from "@/hooks/useWorkspaceRoute";
+import { useTranslation } from "react-i18next";
+import {
+ DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup,
+ DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { api } from "@/lib/api";
+import {
+ AI_SIDEBAR_ADAPTER_PATH_KEY, aiSidebarAdapterFromStorage,
+ desktopAcpAvailable, listDesktopAcpAdapters, probeDesktopAcpAdapter, selectAiSidebarAgent,
+ type AiSidebarSource, type DesktopAcpAdapterId,
+} from "@/lib/desktop-acp";
+import { aiErrorMessage, formatProviderOrdinal, isLegacyProviderDisplayName } from "../settings/ai-provider-options";
+import { resolveBuiltinAgentModel } from "./builtin-agent-model";
+
+export function AiAgentSelector({ source, adapterId, disabled, onPendingChange, noteContext = false }: {
+ source: AiSidebarSource;
+ adapterId: DesktopAcpAdapterId | null;
+ disabled: boolean;
+ noteContext?: boolean;
+ onPendingChange: (pending: boolean) => void;
+}) {
+ const { t, i18n } = useTranslation();
+ const locale = i18n.resolvedLanguage ?? i18n.language;
+ const queryClient = useQueryClient();
+ const [open, setOpen] = useState(false);
+ const settings = useQuery({ queryKey: ["ai-settings", locale], queryFn: () => api.getAiSettings(locale), staleTime: 30_000 });
+ const desktop = desktopAcpAvailable();
+ const agents = useQuery({
+ queryKey: ["ai-sidebar-adapters"],
+ queryFn: async () => {
+ const listed = await listDesktopAcpAdapters();
+ const configured = aiSidebarAdapterFromStorage("antigravity", window.localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY));
+ if (configured?.id === "antigravity" && configured.path) {
+ const checked = await probeDesktopAcpAdapter(configured);
+ return [...listed.filter((agent) => agent.id !== checked.id), checked];
+ }
+ return listed;
+ },
+ enabled: desktop && open,
+ staleTime: 0,
+ });
+ const mutation = useMutation({
+ mutationFn: async (value: string) => {
+ if (value.startsWith("agent:")) {
+ const id = value.slice(6) as DesktopAcpAdapterId;
+ if (!agents.data?.some((agent) => agent.id === id && agent.state === "available")) throw new Error(t("aiAssistant.agentSource.switchUnavailable"));
+ selectAiSidebarAgent("local", id);
+ } else {
+ if (value !== "builtin" && value.slice(6) !== settings.data?.defaultModelId) {
+ const updated = await api.updateDefaultAiModel(value.slice(6));
+ queryClient.setQueryData(["ai-settings", locale], updated);
+ await queryClient.invalidateQueries({ queryKey: ["ai-settings"] });
+ }
+ selectAiSidebarAgent("builtin");
+ }
+ },
+ onSuccess: () => setOpen(false),
+ });
+ useEffect(() => {
+ if (disabled) setOpen(false);
+ }, [disabled]);
+ const builtin = resolveBuiltinAgentModel(settings.data);
+ const provider = settings.data?.providers.find((item) => item.models.some((model) => model.id === settings.data?.defaultModelId));
+ const modelLabel = [provider?.displayName, builtin?.label].filter(Boolean).join(" · ") || t("aiModel.noDefaultModel");
+ const label = source === "local"
+ ? (adapterId ? t(`aiAssistant.agentSource.${adapterId}`) : t("aiAssistant.agentSource.local"))
+ : `${t("aiAssistant.agentSource.builtin")} · ${modelLabel}`;
+ const buttonLabel = source === "local" ? label : builtin?.label ?? t("aiModel.noDefaultModel");
+ const localHint = t(noteContext ? "aiAssistant.agentSource.switchNoteAgentHint" : "aiAssistant.agentSource.newAgentThread");
+ const selected = source === "local" ? `agent:${adapterId}` : settings.data?.defaultModelId ? `model:${settings.data.defaultModelId}` : "builtin";
+ const providers = (settings.data?.providers ?? []).map((provider, index) => {
+ const name = !provider.displayName || isLegacyProviderDisplayName(provider.displayName, provider.provider)
+ ? t("aiModel.defaultProviderName", { ordinal: formatProviderOrdinal(index + 1, locale) }) : provider.displayName;
+ return { ...provider, name };
+ }).filter((provider) => provider.models.length);
+ const localAgents = agents.data ?? [];
+ const choose = (value: string) => {
+ if (disabled || mutation.isPending) return;
+ if (value === selected) { setOpen(false); return; }
+ onPendingChange(true);
+ void mutation.mutateAsync(value).catch(() => undefined).finally(() => onPendingChange(false));
+ };
+
+ return (
+
+
{
+ if (mutation.isPending) return;
+ setOpen(next);
+ if (next) { mutation.reset(); void settings.refetch(); }
+ }}>
+
+
+
+
+ {settings.isError || mutation.isError ? {aiErrorMessage(mutation.error ?? settings.error, t("aiAssistant.agentSource.switchUnavailable"), t("aiModel.encryptionKeyMissing"), t("aiModel.savedCredentialsUnavailable"))}
: null}
+
+
+ {source === "local" ? event.preventDefault()}>{t("aiAssistant.agentSource.builtin")} : null}
+ {settings.isPending ? {t("common.loading")}
: null}
+ {providers.map((provider) => (
+
+ {t("aiAssistant.agentSource.builtin")} · {provider.name}
+ {provider.models.map((model) => event.preventDefault()}>
+ {model.displayName || model.modelId}{!provider.isEnabled || provider.credentialsUnavailable ? {t("aiAssistant.agentSource.optionUnavailable")} : null}
+ )}
+
+ ))}
+ {!settings.isPending && !providers.length ? {t("aiAssistant.agentSource.noModels")}
: null}
+ {desktop ? <>
+
+ {t("aiAssistant.agentSource.local")}
+ {localHint}
+ {agents.isPending ? {t("common.loading")}
: null}
+ {agents.isError ? {t("aiAssistant.agentSource.switchUnavailable")}
: null}
+ {localAgents.map((agent) => event.preventDefault()}>
+ {t(`aiAssistant.agentSource.${agent.id}`)}{t(`aiAssistant.agentSource.states.${agent.state}`)}
+ )}
+ {!agents.isPending && !agents.isError && !localAgents.length ? {t("aiAssistant.agentSource.switchUnavailable")}
: null}
+ > : null}
+
+
+
+
+ {t("aiAssistant.agentSource.configure")}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx
index 5189e878..abfde4b7 100644
--- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx
+++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type ReactNode } from "react";
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import { useTranslation } from "react-i18next";
@@ -48,6 +48,7 @@ import {
} from "@/lib/ai-attachments";
import { companionLocale } from "@/lib/companion-locale";
import {
+ AI_SIDEBAR_SELECTION_EVENT,
AI_SIDEBAR_LOCAL_THREAD_KEY,
AI_SIDEBAR_LOCAL_THREADS_KEY,
AI_SIDEBAR_OPEN_KEY,
@@ -58,6 +59,7 @@ import {
readAiSidebarAdapter,
readAiSidebarSource,
subscribeDesktopAcp,
+ startsNewLocalAgentThread,
type DesktopAcpEvent,
} from "@/lib/desktop-acp";
import {
@@ -84,7 +86,7 @@ import { CompanionQuestionForm } from "../CompanionQuestionForm";
import { CompanionCreatedNoteLinks } from "../CompanionCreatedNoteLinks";
import { AiSidebarMessage } from "./AiSidebarMessage";
import { AiSidebarLocalProcess } from "./AiSidebarLocalProcess";
-import { BuiltinAgentStatus } from "./BuiltinAgentStatus";
+import { AiAgentSelector } from "./AiAgentSelector";
import { InfographicSidebarSession, type InfographicSidebarController } from "./InfographicSidebarSession";
import { memoIdFromSidebarLinkEvent } from "./sidebar-note-links";
@@ -565,6 +567,7 @@ function SidebarComposer({
onStop,
skillPrompt,
focusToken,
+ aiSelector,
}: {
attachments: PendingAttachment[];
attachmentError: string | null;
@@ -580,6 +583,7 @@ function SidebarComposer({
onStop: () => void;
skillPrompt: (id: SkillId, rest?: string) => string;
focusToken: number;
+ aiSelector: ReactNode;
}) {
const { t } = useTranslation();
const { textInput } = usePromptInputController();
@@ -665,7 +669,7 @@ function SidebarComposer({
onPaste={onPaste}
/>
-
+
@@ -699,20 +703,23 @@ function SidebarComposer({
) : null}
- {busy ? (
-
- ) : (
-
-
-
- )}
+
+ {aiSelector}
+ {busy ? (
+
+ ) : (
+
+
+
+ )}
+
(null);
const [conflicts, setConflicts] = useState>({});
const [busy, setBusy] = useState(false);
+ const [selectionPending, setSelectionPending] = useState(false);
+ const selectionPendingRef = useRef(false);
+ const previousAgent = useRef({ source, adapterId: localAdapterId });
const [acting, setActing] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -1000,18 +1010,32 @@ function AiSidebarSession({
useEffect(() => {
const syncSource = () => {
- setSource(readAiSidebarSource());
- setLocalAdapterId(readLocalAdapter()?.id ?? null);
+ if (locked.current) return;
+ const nextSource = readAiSidebarSource();
+ const nextAdapter = readLocalAdapter()?.id ?? null;
+ const previous = previousAgent.current;
+ if (startsNewLocalAgentThread(previous, { source: nextSource, adapterId: nextAdapter })) {
+ const id = createClientUuid();
+ setLocalThreadId(id);
+ localThreadIdRef.current = id;
+ writeStorage(AI_SIDEBAR_LOCAL_THREAD_KEY, id);
+ }
+ previousAgent.current = { source: nextSource, adapterId: nextAdapter };
+ setSource(nextSource);
+ setLocalAdapterId(nextAdapter);
+ if (previous.source !== nextSource || previous.adapterId !== nextAdapter) setError(null);
};
syncSource();
if (!open) return;
window.addEventListener("focus", syncSource);
window.addEventListener("storage", syncSource);
+ window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, syncSource);
return () => {
window.removeEventListener("focus", syncSource);
window.removeEventListener("storage", syncSource);
+ window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, syncSource);
};
- }, [open]);
+ }, [open, busy]);
useEffect(() => {
if (!companionAvailable || source === "local") {
@@ -1103,7 +1127,7 @@ function AiSidebarSession({
const launch = useCallback(async (message: string, options?: { includeCurrentNote?: boolean; readOnly?: boolean }): Promise => {
const text = message.trim();
- if (!text || locked.current) throw new Error("busy");
+ if (!text || locked.current || selectionPendingRef.current) throw new Error("busy");
const useCurrentNote = options?.includeCurrentNote ?? includeCurrentNote;
const focusAtSend = focusRef.current;
const mode = readAiSidebarSource();
@@ -1525,28 +1549,10 @@ function AiSidebarSession({
if (companionThreads.some((thread) => thread.id === threadId)) rememberThread(createClientUuid());
}}
/>
- {source === "local" ? (
-
-
-
-
- {localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
-
-
-
- {localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
- {t("aiAssistant.agentSource.localHint")}
-
-
-
- ) : }