diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index e2e42a14a..de0f93c2e 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,6 +1,8 @@ import * as React from "react"; import { RefreshCw, Upload } from "lucide-react"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; import type { AcpRuntimeCatalogEntry, CreatePersonaInput, @@ -9,14 +11,10 @@ import type { import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/shared/ui/dialog"; +import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; +import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; +import { Spinner } from "@/shared/ui/spinner"; import { Textarea } from "@/shared/ui/textarea"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { @@ -25,6 +23,7 @@ import { getImportErrorLabel, IMPORT_ERROR_VISIBILITY_MS, } from "./personaDialogImportState"; +import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; type PersonaDialogProps = { open: boolean; @@ -46,6 +45,106 @@ type PersonaDialogProps = { ) => Promise; }; +const PERSONA_FIELD_SHELL_CLASS = + "rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50"; +const PERSONA_FIELD_CONTROL_CLASS = + "border-0 bg-transparent text-muted-foreground shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-muted-foreground focus:outline-hidden focus-visible:ring-0"; +const PERSONA_LABEL_OPTIONAL_CLASS = + "ml-1 text-xs font-normal text-muted-foreground/50"; +const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__"; +const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__"; + +type PersonaModelOption = { + id: string; + label: string; +}; + +const AUTO_MODEL_OPTION: PersonaModelOption = { + id: "", + label: "Auto (provider default)", +}; + +const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ + { id: "", label: "Auto (runtime default)" }, + { id: "anthropic", label: "Anthropic" }, + { id: "openai", label: "OpenAI" }, + { id: "openai-compat", label: "OpenAI-compatible" }, + { id: "databricks", label: "Databricks" }, +]; + +const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record< + string, + readonly PersonaModelOption[] +> = { + goose: [ + AUTO_MODEL_OPTION, + { id: "goose-claude-4-6-opus", label: "Claude Opus 4.6" }, + { id: "goose-claude-4-6-sonnet", label: "Claude Sonnet 4.6" }, + { id: "gpt-5", label: "GPT-5" }, + { id: "gpt-5-mini", label: "GPT-5 mini" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + ], + "buzz-agent": [ + AUTO_MODEL_OPTION, + { id: "goose-claude-4-6-opus", label: "Claude Opus 4.6" }, + { id: "goose-claude-4-6-sonnet", label: "Claude Sonnet 4.6" }, + { id: "gpt-5", label: "GPT-5" }, + { id: "gpt-5-mini", label: "GPT-5 mini" }, + { id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" }, + { id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" }, + ], + claude: [AUTO_MODEL_OPTION], + codex: [AUTO_MODEL_OPTION], +}; + +function getPersonaModelOptions( + runtimeId: string, + currentModel: string, +): readonly PersonaModelOption[] { + const options = PERSONA_MODEL_OPTIONS_BY_RUNTIME[runtimeId] ?? [ + AUTO_MODEL_OPTION, + ]; + const trimmedModel = currentModel.trim(); + if ( + trimmedModel.length === 0 || + options.some((option) => option.id === trimmedModel) + ) { + return options; + } + + return [...options, { id: trimmedModel, label: `${trimmedModel} (current)` }]; +} + +function getPersonaProviderOptions( + currentProvider: string, +): readonly PersonaModelOption[] { + const trimmedProvider = currentProvider.trim(); + if ( + trimmedProvider.length === 0 || + PERSONA_LLM_PROVIDER_OPTIONS.some((option) => option.id === trimmedProvider) + ) { + return PERSONA_LLM_PROVIDER_OPTIONS; + } + + return [ + ...PERSONA_LLM_PROVIDER_OPTIONS, + { id: trimmedProvider, label: `${trimmedProvider} (current)` }, + ]; +} + +function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { + const suffix = + runtime.availability === "adapter_missing" + ? " (adapter missing)" + : runtime.availability === "cli_missing" + ? " (CLI missing)" + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; + return `${runtime.label}${suffix}`; +} + export function PersonaDialog({ open, title, @@ -67,7 +166,6 @@ export function PersonaDialog({ const [runtime, setRuntime] = React.useState(""); const [model, setModel] = React.useState(""); const [provider, setProvider] = React.useState(""); - const [namePoolText, setNamePoolText] = React.useState(""); const [envVars, setEnvVars] = React.useState({}); const [isImportingUpdate, setIsImportingUpdate] = React.useState(false); const [importErrorMessage, setImportErrorMessage] = React.useState< @@ -92,13 +190,7 @@ export function PersonaDialog({ setRuntime(initialValues.runtime ?? ""); setModel(initialValues.model ?? ""); setProvider(initialValues.provider ?? ""); - setNamePoolText( - ("namePool" in initialValues - ? (initialValues as { namePool?: string[] }).namePool - : undefined - )?.join(", ") ?? "", - ); - setEnvVars(initialValues.envVars ?? {}); + setEnvVars("envVars" in initialValues ? (initialValues.envVars ?? {}) : {}); setImportErrorMessage(null); setIsImportingUpdate(false); }, [initialValues, open]); @@ -219,7 +311,7 @@ export function PersonaDialog({ setRuntime(""); setModel(""); setProvider(""); - setNamePoolText(""); + setEnvVars({}); setImportErrorMessage(null); setIsImportingUpdate(false); setIsWindowFileDragOver(false); @@ -229,22 +321,26 @@ export function PersonaDialog({ } async function handleSubmit() { - if (!initialValues) { + if ( + !initialValues || + displayName.trim().length === 0 || + systemPrompt.trim().length === 0 || + isPending + ) { return; } - const namePool = namePoolText - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const trimmedRuntime = runtime.trim(); + const preservedNamePool = + "namePool" in initialValues ? initialValues.namePool : undefined; const baseInput = { - displayName, + displayName: displayName.trim(), avatarUrl: avatarUrl.trim() || undefined, - systemPrompt, - runtime: runtime.trim() || undefined, + systemPrompt: systemPrompt.trim(), + runtime: trimmedRuntime || undefined, model: model.trim() || undefined, provider: provider.trim() || undefined, - namePool: namePool.length > 0 ? namePool : undefined, + namePool: preservedNamePool, envVars, }; @@ -259,6 +355,11 @@ export function PersonaDialog({ await onSubmit(baseInput); } + function handleSubmitForm(event: React.FormEvent) { + event.preventDefault(); + void handleSubmit(); + } + const importButtonTone = getImportButtonTone({ isWindowFileDragOver, isImportDragOver, @@ -271,6 +372,32 @@ export function PersonaDialog({ }); const selectedRuntime = runtimes.find((p) => p.id === runtime); + const modelFieldVisible = runtime.trim().length > 0; + const isCreateMode = Boolean(initialValues && !("id" in initialValues)); + const selectedRuntimeIsAvailable = + runtime.trim().length === 0 || + selectedRuntime?.availability === "available"; + const canSubmit = + displayName.trim().length > 0 && + systemPrompt.trim().length > 0 && + (!isCreateMode || runtime.trim().length > 0) && + (!isCreateMode || selectedRuntimeIsAvailable) && + !isPending; + const modelOptions = getPersonaModelOptions(runtime, model); + const providerOptions = getPersonaProviderOptions(provider); + const selectedRuntimeLabel = runtimesLoading + ? "Loading providers..." + : (selectedRuntime?.label ?? "Choose a provider"); + const selectedModelLabel = + modelOptions.find((option) => option.id === model)?.label ?? + AUTO_MODEL_OPTION.label; + const selectedProviderLabel = + providerOptions.find((option) => option.id === provider)?.label ?? + (provider.trim() + ? `${provider.trim()} (current)` + : "Auto (runtime default)"); + const previewLabel = displayName.trim() || "Agent name"; + const previewAvatarUrl = avatarUrl.trim() || null; const runtimeWarning = selectedRuntime && selectedRuntime.availability !== "available" ? (

@@ -284,191 +411,24 @@ export function PersonaDialog({ ) : null; return ( -

- -
- - {title} - {description.trim().length > 0 ? ( - {description} - ) : null} - - -
-
- - setDisplayName(event.target.value)} - placeholder="Researcher" - value={displayName} - /> -
- -
- - setAvatarUrl(event.target.value)} - placeholder="https://example.com/avatar.png" - spellCheck={false} - value={avatarUrl} - /> -

- Optional. Deployed agents fall back to the runtime avatar if - this is blank. -

-
- -
- -