feat(web): switch models and agents from chat composer (#497)

This commit is contained in:
tianma-if committed 2026-10-09 15:52:35 +08:00
1 parent cc18b4329f
commit fbff622249
12 files changed
+354 -92

No files matched your search

@@ -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 (
<div className="min-w-0 max-w-44 flex-1">
<DropdownMenu open={open} onOpenChange={(next) => {
if (mutation.isPending) return;
setOpen(next);
if (next) { mutation.reset(); void settings.refetch(); }
}}>
<DropdownMenuTrigger asChild>
<button type="button" disabled={disabled || mutation.isPending}
aria-label={`${t("aiAssistant.agentSource.switch")} · ${label}`}
className="flex w-full min-w-0 items-center gap-1 rounded-full px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 disabled:opacity-50">
<span className="truncate">{buttonLabel}</span>
{mutation.isPending ? <Loader2 className="size-3 shrink-0 animate-spin" /> : <ChevronDown className="size-3 shrink-0" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="end" className="w-80 max-w-[calc(100vw-24px)]">
{settings.isError || mutation.isError ? <p role="alert" className="px-2 py-1 text-xs text-rose-600">{aiErrorMessage(mutation.error ?? settings.error, t("aiAssistant.agentSource.switchUnavailable"), t("aiModel.encryptionKeyMissing"), t("aiModel.savedCredentialsUnavailable"))}</p> : null}
<div className="max-h-72 overflow-y-auto">
<DropdownMenuRadioGroup value={selected} onValueChange={choose}>
{source === "local" ? <DropdownMenuRadioItem value="builtin" disabled={mutation.isPending} onSelect={(event) => event.preventDefault()}>{t("aiAssistant.agentSource.builtin")}</DropdownMenuRadioItem> : null}
{settings.isPending ? <p className="px-2 py-2 text-xs">{t("common.loading")}</p> : null}
{providers.map((provider) => (
<div key={provider.id}>
<DropdownMenuLabel>{t("aiAssistant.agentSource.builtin")} · {provider.name}</DropdownMenuLabel>
{provider.models.map((model) => <DropdownMenuRadioItem key={model.id} value={`model:${model.id}`}
disabled={mutation.isPending || settings.data?.readOnly || !settings.data?.encryptionConfigured || !provider.isEnabled || provider.credentialsUnavailable}
onSelect={(event) => event.preventDefault()}>
<span className="min-w-0 break-words">{model.displayName || model.modelId}{!provider.isEnabled || provider.credentialsUnavailable ? <span className="ml-2 text-[10px] text-slate-500">{t("aiAssistant.agentSource.optionUnavailable")}</span> : null}</span>
</DropdownMenuRadioItem>)}
</div>
))}
{!settings.isPending && !providers.length ? <p className="px-2 py-2 text-xs text-slate-500">{t("aiAssistant.agentSource.noModels")}</p> : null}
{desktop ? <>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("aiAssistant.agentSource.local")}</DropdownMenuLabel>
<p className="px-2 pb-1 text-[11px] text-slate-500">{localHint}</p>
{agents.isPending ? <p className="px-2 py-2 text-xs">{t("common.loading")}</p> : null}
{agents.isError ? <p role="alert" className="px-2 py-2 text-xs text-rose-600">{t("aiAssistant.agentSource.switchUnavailable")}</p> : null}
{localAgents.map((agent) => <DropdownMenuRadioItem key={agent.id} value={`agent:${agent.id}`} disabled={mutation.isPending || agents.isFetching || agent.state !== "available"} onSelect={(event) => event.preventDefault()}>
<span className="flex min-w-0 flex-1 flex-wrap justify-between gap-x-2"><span>{t(`aiAssistant.agentSource.${agent.id}`)}</span><span className="text-[10px] text-slate-500">{t(`aiAssistant.agentSource.states.${agent.state}`)}</span></span>
</DropdownMenuRadioItem>)}
{!agents.isPending && !agents.isError && !localAgents.length ? <p className="px-2 py-2 text-xs text-slate-500">{t("aiAssistant.agentSource.switchUnavailable")}</p> : null}
</> : null}
</DropdownMenuRadioGroup>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem asChild disabled={mutation.isPending}>
<Link to={`${WORKSPACE_SETTINGS_PATH}?tab=ai`}>{t("aiAssistant.agentSource.configure")}</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
@@ -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}
/>
<PromptInputFooter className="px-2.5 pb-2.5 pt-0">
<PromptInputTools>
<PromptInputTools className="shrink-0">
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -699,20 +703,23 @@ function SidebarComposer({
</Button>
) : null}
</PromptInputTools>
{busy ? (
<Button type="button" size="sm" variant="outline" onClick={onStop}>
{t("aiAssistant.sidebar.stop")}
</Button>
) : (
<PromptInputSubmit
aria-label={t("companion.send")}
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
disabled={locked || !draft.trim()}
variant="solid"
>
<ArrowUp className="size-4" />
</PromptInputSubmit>
)}
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
{aiSelector}
{busy ? (
<Button type="button" size="sm" variant="outline" onClick={onStop}>
{t("aiAssistant.sidebar.stop")}
</Button>
) : (
<PromptInputSubmit
aria-label={t("companion.send")}
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
disabled={locked || !draft.trim()}
variant="solid"
>
<ArrowUp className="size-4" />
</PromptInputSubmit>
)}
</div>
</PromptInputFooter>
</PromptInput>
<input
@@ -777,6 +784,9 @@ function AiSidebarSession({
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<Record<string, string>>({});
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<string | null>(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<string> => {
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" ? (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span
className="ml-auto min-w-0 max-w-[46%] shrink truncate rounded-sm text-right text-xs text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400"
data-ai-local-agent=""
tabIndex={0}
>
{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p>{localAdapterId ? t(`aiAssistant.agentSource.${localAdapterId}`) : t("aiAssistant.sidebar.localStatus")}</p>
<p className="mt-1">{t("aiAssistant.agentSource.localHint")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : <BuiltinAgentStatus />}
<Button
type="button"
size="icon-sm"
className="ml-auto shrink-0"
variant="ghost"
title={t("aiAssistant.sidebar.close")}
aria-label={t("aiAssistant.sidebar.close")}
@@ -1716,7 +1722,7 @@ function AiSidebarSession({
attachments={attachments}
attachmentError={attachmentError}
busy={running}
locked={acting}
locked={acting || selectionPending}
includeCurrentNote={includeCurrentNote}
currentNoteAvailable={Boolean(noteTitle?.trim() || contentMarkdown?.trim())}
onIncludeCurrentNoteChange={setIncludeCurrentNote}
@@ -1726,6 +1732,17 @@ function AiSidebarSession({
onLaunch={launch}
onStop={() => { void stop(); }}
skillPrompt={skillPrompt}
aiSelector={
<AiAgentSelector
source={source}
adapterId={localAdapterId}
disabled={running || acting || loading}
onPendingChange={(pending) => {
selectionPendingRef.current = pending;
setSelectionPending(pending);
}}
/>
}
focusToken={composerFocusToken}
/>
</PromptInputProvider>
@@ -1,6 +1,6 @@
import { useTranslation } from "react-i18next";
import { ArrowUp, PanelRightClose, Sparkles, Undo2 } from "lucide-react";
import type { MouseEvent } from "react";
import { useState, type MouseEvent } from "react";
import type { InfographicConversationTurn } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "@/components/ai-elements/conversation";
@@ -47,7 +47,7 @@ export type InfographicSidebarController = {
onStop: () => void;
};
function InfographicSidebarComposer({ session }: { session: InfographicSidebarController }) {
function InfographicSidebarComposer({ session, selectionPending, onPendingChange }: { session: InfographicSidebarController; selectionPending: boolean; onPendingChange: (pending: boolean) => void }) {
const { t } = useTranslation();
const { textInput } = usePromptInputController();
const draft = textInput.value.trim();
@@ -56,39 +56,42 @@ function InfographicSidebarComposer({ session }: { session: InfographicSidebarCo
inputGroupClassName="rounded-[22px] border-slate-200 bg-card shadow-sm has-[[data-slot=input-group-control]:focus-visible]:ring-0"
onSubmit={async ({ text }) => {
const raw = text.trim().slice(0, 1000);
if (!raw || session.generating) throw new Error("empty");
if (!raw || session.generating || selectionPending) throw new Error("empty");
await session.onSubmit(raw);
}}
>
<PromptInputTextarea
className="min-h-20 px-4 pb-2 pt-4 text-[13px] leading-5 md:text-[13px]"
disabled={session.generating}
disabled={session.generating || selectionPending}
maxLength={1000}
placeholder={t(session.hasGraphic ? "infographic.refinePrompt" : "infographic.prompt")}
/>
<PromptInputFooter className="px-2.5 pb-2.5 pt-0">
<PromptInputTools>
<PromptInputTools className="shrink-0">
{session.canUndo ? (
<Button type="button" size="sm" variant="outline" disabled={session.generating} onClick={session.onUndo}>
<Button type="button" size="sm" variant="outline" disabled={session.generating || selectionPending} onClick={session.onUndo}>
<Undo2 className="mr-1 h-3.5 w-3.5" />
{t("infographic.undoGeneration")}
</Button>
) : null}
</PromptInputTools>
{session.generating ? (
<Button type="button" size="sm" variant="outline" onClick={session.onStop}>
{t("aiAssistant.sidebar.stop")}
</Button>
) : (
<PromptInputSubmit
aria-label={t(session.hasGraphic ? "infographic.applyRefinement" : "infographic.generate")}
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
disabled={!draft}
variant="solid"
>
<ArrowUp className="size-4" />
</PromptInputSubmit>
)}
<div className="ml-auto flex min-w-0 flex-1 items-center justify-end gap-1">
<SidebarAgentModeStatus disabled={session.generating} onPendingChange={onPendingChange} />
{session.generating ? (
<Button type="button" size="sm" variant="outline" onClick={session.onStop}>
{t("aiAssistant.sidebar.stop")}
</Button>
) : (
<PromptInputSubmit
aria-label={t(session.hasGraphic ? "infographic.applyRefinement" : "infographic.generate")}
className="rounded-full border-slate-900 bg-slate-900 text-slate-50 hover:border-slate-800 hover:bg-slate-800 disabled:border-slate-100 disabled:bg-slate-100 disabled:text-slate-300"
disabled={!draft || selectionPending}
variant="solid"
>
<ArrowUp className="size-4" />
</PromptInputSubmit>
)}
</div>
</PromptInputFooter>
</PromptInput>
);
@@ -119,6 +122,7 @@ export function InfographicSidebarSession({
onOpenNote?: (memoId: string, notebookId: string) => void;
}) {
const { t, i18n } = useTranslation();
const [selectionPending, setSelectionPending] = useState(false);
const title = noteTitle?.trim() || t("infographic.name");
const openLinkedNote = (event: MouseEvent<HTMLElement>) => {
const linkedId = memoIdFromSidebarLinkEvent(event);
@@ -139,7 +143,6 @@ export function InfographicSidebarSession({
<Sparkles className="size-3.5 shrink-0" aria-hidden="true" />
<span className="truncate">{title}</span>
</span>
<SidebarAgentModeStatus />
<Button
type="button"
size="icon-sm"
@@ -199,7 +202,7 @@ export function InfographicSidebarSession({
{!session.readOnly ? (
<div className="shrink-0 p-3">
<PromptInputProvider>
<InfographicSidebarComposer session={session} />
<InfographicSidebarComposer session={session} selectionPending={selectionPending} onPendingChange={setSelectionPending} />
</PromptInputProvider>
</div>
) : null}
@@ -1,13 +1,8 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { readAiSidebarAdapter, readAiSidebarSource, type AiSidebarSource } from "@/lib/desktop-acp";
import { BuiltinAgentStatus } from "./BuiltinAgentStatus";
import { AI_SIDEBAR_SELECTION_EVENT, readAiSidebarAdapter, readAiSidebarSource, type AiSidebarSource } from "@/lib/desktop-acp";
import { AiAgentSelector } from "./AiAgentSelector";
const chipClassName = "ml-auto min-w-0 max-w-[46%] shrink-0 truncate rounded-sm text-right text-[11px] leading-4 text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400";
export function SidebarAgentModeStatus() {
const { t } = useTranslation();
export function SidebarAgentModeStatus({ disabled, onPendingChange }: { disabled: boolean; onPendingChange: (pending: boolean) => void }) {
const [source, setSource] = useState<AiSidebarSource>(readAiSidebarSource);
const [adapterId, setAdapterId] = useState(() => readAiSidebarAdapter()?.id ?? null);
@@ -19,30 +14,15 @@ export function SidebarAgentModeStatus() {
sync();
window.addEventListener("focus", sync);
window.addEventListener("storage", sync);
window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
document.addEventListener("visibilitychange", sync);
return () => {
window.removeEventListener("focus", sync);
window.removeEventListener("storage", sync);
window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
document.removeEventListener("visibilitychange", sync);
};
}, []);
if (source !== "local") return <BuiltinAgentStatus />;
const label = adapterId ? t(`aiAssistant.agentSource.${adapterId}`) : t("aiAssistant.sidebar.localStatus");
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className={chipClassName} data-ai-local-agent="" tabIndex={0} aria-label={label}>
{label}
</span>
</TooltipTrigger>
<TooltipContent className="max-w-xs" side="bottom">
<p>{label}</p>
<p className="mt-1">{t("aiAssistant.agentSource.localHint")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return <AiAgentSelector noteContext source={source} adapterId={adapterId} disabled={disabled} onPendingChange={onPendingChange} />;
}
@@ -16,15 +16,14 @@ describe("AI assistant modes", () => {
expect(sidebar).not.toContain("hidden w-0");
expect(sidebar).toContain("props.infographic ? (");
expect(sidebar).toContain("<InfographicSidebarSession");
expect(sidebar).toContain("<BuiltinAgentStatus");
expect(sidebar).toContain("<AiAgentSelector");
const infographic = readFileSync(new URL("../ai-sidebar/InfographicSidebarSession.tsx", import.meta.url), "utf8");
const mode = readFileSync(new URL("../ai-sidebar/SidebarAgentModeStatus.tsx", import.meta.url), "utf8");
const status = readFileSync(new URL("../ai-sidebar/BuiltinAgentStatus.tsx", import.meta.url), "utf8");
const pane = readFileSync(new URL("../InfographicEditorPane.tsx", import.meta.url), "utf8");
expect(infographic).toContain("<SidebarAgentModeStatus");
expect(mode).toContain("readAiSidebarSource");
expect(mode).toContain("<BuiltinAgentStatus");
expect(mode).toContain('data-ai-local-agent=""');
expect(mode).toContain("<AiAgentSelector");
expect(pane).toContain('readAiSidebarSource() === "local"');
expect(pane).toContain("noteAccess: false");
expect(status).toContain('data-ai-builtin-agent=""');
@@ -9,6 +9,7 @@ import {
AI_SIDEBAR_ADAPTER_KEY,
AI_SIDEBAR_ADAPTER_PATH_KEY,
AI_SIDEBAR_SOURCE_KEY,
AI_SIDEBAR_SELECTION_EVENT,
displayedDesktopAcpAdapter,
listDesktopAcpAdapters,
installDesktopAcpAdapter,
@@ -99,6 +100,17 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
setAdapterId(readAdapterId());
setAdapterPath(localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY) ?? "");
setReady(true);
const sync = () => {
setSource(bridge && readSource() === "local" ? "local" : "builtin");
setAdapterId(readAdapterId());
setAdapterPath(localStorage.getItem(AI_SIDEBAR_ADAPTER_PATH_KEY) ?? "");
};
window.addEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
window.addEventListener("storage", sync);
return () => {
window.removeEventListener(AI_SIDEBAR_SELECTION_EVENT, sync);
window.removeEventListener("storage", sync);
};
}, [bridge]);
useEffect(() => {
@@ -106,6 +118,7 @@ const DesktopAcpAgentCardBody = ({ bridge }: { bridge: boolean }) => {
localStorage.setItem(AI_SIDEBAR_SOURCE_KEY, source);
localStorage.setItem(AI_SIDEBAR_ADAPTER_KEY, adapterId);
localStorage.setItem(AI_SIDEBAR_ADAPTER_PATH_KEY, adapterPath);
window.dispatchEvent(new Event(AI_SIDEBAR_SELECTION_EVENT));
}, [adapterId, adapterPath, ready, source]);
useEffect(() => {
+45
View File
@@ -30,3 +30,48 @@ test("a prompt-time login failure takes priority over an earlier successful sess
const earlierProbe = { id: "grokBuild", label: "Grok Build", state: "available" };
expect(displayedDesktopAcpAdapter({ id: "grokBuild", path: "", listed: [current], probed: earlierProbe })).toBe(current);
});
test("switching into an external agent starts a fresh chat without changing built-in chats", async () => {
const { startsNewLocalAgentThread } = await import("./desktop-acp.ts");
const builtin = { source: "builtin", adapterId: "codex" };
const codex = { source: "local", adapterId: "codex" };
const claude = { source: "local", adapterId: "claudeCode" };
expect(startsNewLocalAgentThread(builtin, codex)).toBe(true);
expect(startsNewLocalAgentThread(codex, claude)).toBe(true);
expect(startsNewLocalAgentThread(codex, codex)).toBe(false);
expect(startsNewLocalAgentThread(codex, builtin)).toBe(false);
});
test("agent selection reuses preference keys, preserves the custom path and notifies only after saving", async () => {
const { selectAiSidebarAgent, AI_SIDEBAR_SELECTION_EVENT, AI_SIDEBAR_SOURCE_KEY, AI_SIDEBAR_ADAPTER_KEY, AI_SIDEBAR_ADAPTER_PATH_KEY } = await import("./desktop-acp.ts");
const previousWindow = globalThis.window;
const data = new Map([[AI_SIDEBAR_SOURCE_KEY, "builtin"], [AI_SIDEBAR_ADAPTER_KEY, "codex"], [AI_SIDEBAR_ADAPTER_PATH_KEY, "/custom/antigravity"]]);
const snapshots = [];
let fail = false;
globalThis.window = {
localStorage: {
getItem: key => data.get(key) ?? null,
setItem: (key, value) => {
if (fail && key === AI_SIDEBAR_SOURCE_KEY) { fail = false; throw new Error("storage full"); }
data.set(key, value);
},
removeItem: key => data.delete(key),
},
dispatchEvent: event => snapshots.push({ type: event.type, source: data.get(AI_SIDEBAR_SOURCE_KEY), adapter: data.get(AI_SIDEBAR_ADAPTER_KEY) }),
};
try {
selectAiSidebarAgent("local", "claudeCode");
expect(snapshots).toEqual([{ type: AI_SIDEBAR_SELECTION_EVENT, source: "local", adapter: "claudeCode" }]);
expect(data.get(AI_SIDEBAR_ADAPTER_PATH_KEY)).toBe("/custom/antigravity");
selectAiSidebarAgent("builtin");
expect(data.get(AI_SIDEBAR_ADAPTER_KEY)).toBe("claudeCode");
fail = true;
expect(() => selectAiSidebarAgent("local", "codex")).toThrow("storage full");
expect(data.get(AI_SIDEBAR_SOURCE_KEY)).toBe("builtin");
expect(data.get(AI_SIDEBAR_ADAPTER_KEY)).toBe("claudeCode");
expect(snapshots).toHaveLength(2);
} finally {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
}
});
+27
View File
@@ -75,9 +75,15 @@ export const AI_SIDEBAR_LOCAL_THREADS_KEY = "edgeever.aiSidebar.localThreads";
export const AI_SIDEBAR_SOURCE_KEY = "edgeever.aiSidebar.source";
export const AI_SIDEBAR_ADAPTER_KEY = "edgeever.aiSidebar.adapterId";
export const AI_SIDEBAR_ADAPTER_PATH_KEY = "edgeever.aiSidebar.adapterPath";
export const AI_SIDEBAR_SELECTION_EVENT = "edgeever:ai-sidebar-selection";
export type AiSidebarSource = "builtin" | "local";
export const startsNewLocalAgentThread = (
previous: { source: AiSidebarSource; adapterId: DesktopAcpAdapterId | null },
next: { source: AiSidebarSource; adapterId: DesktopAcpAdapterId | null },
) => next.source === "local" && (previous.source !== "local" || previous.adapterId !== next.adapterId);
const AI_SIDEBAR_ADAPTER_IDS = new Set<string>([
"codex", "claudeCode", "antigravity", "openClaw", "hermesAgent", "grokBuild", "deepseekHarness", "piAgent", "workbuddyCn", "workbuddyIntl",
]);
@@ -111,6 +117,27 @@ export const readAiSidebarAdapter = () => aiSidebarAdapterFromStorage(
readStorageItem(AI_SIDEBAR_ADAPTER_PATH_KEY),
);
// Keep the settings page and sidebar on the same existing preference keys.
export const selectAiSidebarAgent = (source: AiSidebarSource, adapterId?: DesktopAcpAdapterId) => {
const storage = window.localStorage;
const previousSource = storage.getItem(AI_SIDEBAR_SOURCE_KEY);
const previousAdapter = storage.getItem(AI_SIDEBAR_ADAPTER_KEY);
try {
if (adapterId) storage.setItem(AI_SIDEBAR_ADAPTER_KEY, adapterId);
storage.setItem(AI_SIDEBAR_SOURCE_KEY, source);
} catch (error) {
try {
const preferences: Array<[string, string | null]> = [[AI_SIDEBAR_SOURCE_KEY, previousSource], [AI_SIDEBAR_ADAPTER_KEY, previousAdapter]];
for (const [key, value] of preferences) {
if (value === null) storage.removeItem(key);
else storage.setItem(key, value);
}
} catch { /* Report the original preference-write failure. */ }
throw error;
}
window.dispatchEvent(new Event(AI_SIDEBAR_SELECTION_EVENT));
};
const bridge = () => (typeof window === "undefined" ? undefined : window.edgeeverDesktop);
export const desktopAcpAvailable = () => Boolean(bridge()?.listAcpAdapters);
+9
View File
@@ -1487,6 +1487,15 @@ export const enUS = {
imageSaveFailed: "The image could not be saved locally. Download it now; it may be lost when you close this page.",
},
agentSource: {
configure: "Configure models and agents",
switchNoteAgentHint: "The selected agent will handle subsequent edits to this infographic.",
optionUnavailable: "Unavailable",
switch: "Switch AI",
newAgentThread: "Switching to another agent starts a new chat. Previous chats remain in history.",
noModels: "Configure AI models in Settings.",
switchUnavailable: "Agent unavailable. Check its connection in Settings.",
title: "Agent mode",
description: "Use the built-in Agent, or connect to an Agent (ACP) running on this machine.",
localDisabled: "Requires the desktop app.",
+9
View File
@@ -1487,6 +1487,15 @@ export const ja = {
imageSaveFailed: "画像を端末に保存できませんでした。今すぐダウンロードしてください。ページを閉じると失われる可能性があります。",
},
agentSource: {
configure: "モデルと Agent を設定",
switchNoteAgentHint: "選択した Agent が、このインフォグラフィックの次の編集を処理します。",
optionUnavailable: "利用不可",
switch: "AI を切り替え",
newAgentThread: "外部 Agent への切り替えは新しい会話を開始します。以前の会話は履歴に残ります。",
noModels: "設定で AI モデルを構成してください。",
switchUnavailable: "Agent は利用できません。設定で接続を確認してください。",
title: "エージェントの動作モード",
description: "内蔵 Agent を使用するか、このマシンで動作する Agent (ACP) に接続します。",
localDisabled: "デスクトップアプリが必要です。",
+9
View File
@@ -1487,6 +1487,15 @@ export const pl = {
imageSaveFailed: "Nie udało się zapisać obrazu lokalnie. Pobierz go teraz — może zostać utracony po zamknięciu tej strony.",
},
agentSource: {
configure: "Konfiguruj modele i agentów",
switchNoteAgentHint: "Wybrany agent obsłuży kolejne zmiany tej infografiki.",
optionUnavailable: "Niedostępny",
switch: "Zmień AI",
newAgentThread: "Zmiana na zewnętrznego agenta rozpoczyna nową rozmowę. Poprzednie rozmowy pozostają w historii.",
noModels: "Skonfiguruj modele AI w ustawieniach.",
switchUnavailable: "Agent jest niedostępny. Sprawdź połączenie w ustawieniach.",
title: "Tryb agenta",
description: "Użyj wbudowanego agenta lub połącz się z agentem (ACP) działającym na tym komputerze.",
localDisabled: "Wymaga aplikacji desktopowej.",
+9
View File
@@ -1487,6 +1487,15 @@ export const zhCN = {
imageSaveFailed: "图片暂时无法保存到本机。请先下载图片,关闭页面后可能无法找回。",
},
agentSource: {
configure: "配置模型与 Agent",
switchNoteAgentHint: "切换后由所选 Agent 继续处理当前信息图。",
optionUnavailable: "不可用",
switch: "切换 AI",
newAgentThread: "切换外部 Agent 会开启新对话,原对话保留在历史中。",
noModels: "请在设置中配置 AI 模型。",
switchUnavailable: "Agent 暂不可用,请在设置中检查连接。",
title: "Agent 运行模式",
description: "使用应用内置 Agent,或连接本机运行的 Agent (ACP)。",
localDisabled: "仅支持桌面客户端。",