diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3bf4631fb..a360d5881 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -67,9 +67,11 @@ const overrides = new Map([ // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); + // alias-preservation coverage extends that matrix so create-time persona + // agents keep an installed runtime alias when the primary command is absent. // types.rs adds the persona/instance harness fields. Load-bearing, not // generic debt. - ["src-tauri/src/managed_agents/discovery.rs", 1043], + ["src-tauri/src/managed_agents/discovery.rs", 1085], ["src-tauri/src/managed_agents/types.rs", 1037], // migration_tests.rs carries the harness-sync migration coverage plus the // patch_json_records owner-only writeback regression test (SECURITY.md:90 diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 2e3518b50..88dbc4a2a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -315,9 +315,9 @@ pub fn divergent_agent_command_override( /// distinct cases that the backend MUST tell apart: /// /// - DELIBERATE OVERRIDE (`harness_override` true): the user explicitly picked a -/// non-persona runtime in a deploy dialog that exposes a runtime selector (e.g. -/// `AddChannelBotDialog`, "overriding persona preferences"). This is a real pin -/// and is preserved via `divergent_agent_command_override`. +/// runtime command in UI that exposes a runtime selector. This is a real pin +/// and is preserved when it differs from the command inheritance would spawn, +/// including installed aliases such as `claude-code-acp`. /// - MISSING-RUNTIME FALLBACK (`harness_override` false): the persona's runtime /// isn't installed locally, so `resolvePersonaRuntime` substitutes a fallback /// default. This is NOT a pin — baking it would freeze the agent on the fallback @@ -341,6 +341,15 @@ pub fn create_time_agent_command_override( if persona_id.is_some() && !harness_override { return None; } + + if persona_id.is_some() && harness_override { + let picked = picked_command + .map(str::trim) + .filter(|value| !value.is_empty())?; + let inherited_command = effective_agent_command(persona_id, personas, None); + return (picked != inherited_command).then(|| picked.to_string()); + } + divergent_agent_command_override(persona_id, personas, picked_command) } @@ -1027,6 +1036,38 @@ mod tests { ); } + #[test] + fn create_time_override_preserves_selected_runtime_alias() { + // A `claude` persona inherits the primary command `claude-agent-acp`, + // but discovery may select an installed alias such as `claude-code-acp`. + // When UI marks that create-time selection as explicit, preserve the + // alias so the first spawn uses a command known to be installed. + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override( + Some("p1"), + &personas, + Some("claude-code-acp"), + true + ), + Some("claude-code-acp".to_string()) + ); + } + + #[test] + fn create_time_override_inherits_exact_persona_command() { + let personas = vec![persona_with_runtime("p1", Some("claude"))]; + assert_eq!( + create_time_agent_command_override( + Some("p1"), + &personas, + Some("claude-agent-acp"), + true + ), + None + ); + } + #[test] fn create_time_override_preserves_pin_for_persona_less_create() { // The standalone CreateAgentDialog creates persona-LESS agents. With no diff --git a/desktop/src-tauri/src/managed_agents/persona_card.rs b/desktop/src-tauri/src/managed_agents/persona_card.rs index 779adb21f..4668a3636 100644 --- a/desktop/src-tauri/src/managed_agents/persona_card.rs +++ b/desktop/src-tauri/src/managed_agents/persona_card.rs @@ -9,6 +9,7 @@ pub struct ParsedPersonaPreview { pub display_name: String, pub system_prompt: String, pub avatar_data_url: Option, + pub avatar_ref: Option, pub runtime: Option, pub model: Option, pub provider: Option, @@ -69,6 +70,7 @@ pub fn parse_png_persona(png_bytes: &[u8]) -> Result Result Result display_name: config.display_name, system_prompt: config.prompt, avatar_data_url: None, // .persona.md avatars are paths, not data URIs + avatar_ref: config.avatar, runtime: config.runtime, model, provider: None, // .persona.md format does not carry llmProvider @@ -385,6 +389,7 @@ pub fn parse_zip_pack(zip_bytes: &[u8]) -> Result, pub acp_command: Option, pub agent_command: Option, - /// True when `agent_command` is a runtime the user deliberately picked to - /// override the linked persona (a deploy-dialog runtime selector). Distinguishes - /// a real pin from a missing-runtime fallback so a persona-backed create only - /// stores an `agent_command_override` for the former. Defaults `false`: callers - /// that don't set it (persona-less creates, fallback divergence) inherit. + /// True when `agent_command` is a runtime command the user deliberately + /// picked for a linked persona. Distinguishes a real selection, including an + /// installed alias, from a missing-runtime fallback so a persona-backed + /// create only stores an `agent_command_override` for the former. #[serde(default)] pub harness_override: bool, #[serde(default)] diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx new file mode 100644 index 000000000..1cdcde00e --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -0,0 +1,357 @@ +import * as React from "react"; +import { Pencil, Plus } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; + +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { useAvatarUpload } from "@/features/profile/useAvatarUpload"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; + +function isAvatarFileDrag(event: React.DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files"); +} + +const AVATAR_APPLY_MOTION_TRANSITION = { + duration: 0.14, + ease: [0.23, 1, 0.32, 1], +} as const; + +export function AgentCreationPreview({ + avatarUrl, + disabled = false, + label, + onClearAvatar, + onUploadPendingChange, + onSelectAvatar, +}: { + avatarUrl: string | null; + disabled?: boolean; + label: string; + onClearAvatar?: () => void; + onUploadPendingChange?: (isPending: boolean) => void; + onSelectAvatar: (avatarUrl: string) => void; +}) { + const avatarEditClipId = React.useId().replace(/:/g, ""); + const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); + const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); + const [avatarUrlDraft, setAvatarUrlDraft] = React.useState(""); + const [isAvatarUrlInputFocused, setIsAvatarUrlInputFocused] = + React.useState(false); + const avatarDragDepthRef = React.useRef(0); + const shouldReduceMotion = useReducedMotion(); + const { + inputRef: avatarUploadInputRef, + isUploading, + errorMessage: uploadErrorMessage, + clearError: clearUploadError, + openPicker: openUploadPicker, + uploadFile: uploadAvatarFile, + handleFileChange: handleAvatarUploadFileChange, + } = useAvatarUpload({ + onUploadSuccess: onSelectAvatar, + }); + + React.useEffect(() => { + onUploadPendingChange?.(isUploading); + return () => { + onUploadPendingChange?.(false); + }; + }, [isUploading, onUploadPendingChange]); + + React.useEffect(() => { + if (isAvatarMenuOpen) { + setAvatarUrlDraft(""); + setIsAvatarUrlInputFocused(false); + } + }, [isAvatarMenuOpen]); + + function applyAvatarUrl() { + const nextUrl = avatarUrlDraft.trim(); + if (nextUrl.length === 0) { + return; + } + clearUploadError(); + onSelectAvatar(nextUrl); + setIsAvatarMenuOpen(false); + } + + const avatarClipStyle = React.useMemo( + () => ({ + clipPath: `url(#${avatarEditClipId})`, + transform: "translateZ(0)", + }), + [avatarEditClipId], + ); + const hasAvatarUrlDraft = avatarUrlDraft.trim().length > 0; + const hasAvatar = (avatarUrl?.trim().length ?? 0) > 0; + const applyButtonTransition = shouldReduceMotion + ? { duration: 0 } + : AVATAR_APPLY_MOTION_TRANSITION; + + const handleAvatarDragEnter = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + avatarDragDepthRef.current += 1; + event.dataTransfer.dropEffect = "copy"; + setIsDragOverAvatar(true); + }, + [disabled], + ); + + const handleAvatarDragOver = React.useCallback( + (event: React.DragEvent) => { + if (disabled || !isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = "copy"; + setIsDragOverAvatar(true); + }, + [disabled], + ); + + const handleAvatarDragLeave = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + avatarDragDepthRef.current = Math.max(0, avatarDragDepthRef.current - 1); + if (avatarDragDepthRef.current === 0) { + setIsDragOverAvatar(false); + } + }, + [], + ); + + const handleAvatarDrop = React.useCallback( + (event: React.DragEvent) => { + if (!isAvatarFileDrag(event)) { + return; + } + event.preventDefault(); + event.stopPropagation(); + avatarDragDepthRef.current = 0; + setIsDragOverAvatar(false); + + const file = event.dataTransfer.files[0]; + if (!file || disabled || isUploading) { + return; + } + + clearUploadError(); + void uploadAvatarFile(file); + }, + [clearUploadError, disabled, isUploading, uploadAvatarFile], + ); + + const avatarMenuContent = ( + + +
{ + event.preventDefault(); + applyAvatarUrl(); + }} + > + + setIsAvatarUrlInputFocused(false)} + onChange={(event) => setAvatarUrlDraft(event.target.value)} + onFocus={() => setIsAvatarUrlInputFocused(true)} + placeholder={isAvatarUrlInputFocused ? "https://..." : "Use a URL"} + spellCheck={false} + value={avatarUrlDraft} + /> + + {hasAvatarUrlDraft ? ( + + + + ) : null} + +
+ {hasAvatar && onClearAvatar ? ( + + ) : null} +
+ ); + + return ( +
+
+ + +
+ {hasAvatar ? ( + <> + + +
+ +
+ +
+ + + + + {avatarMenuContent} + +
+ + ) : ( + + + + + {avatarMenuContent} + + )} +
+ + {uploadErrorMessage ? ( +

+ {uploadErrorMessage} +

+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 44088d490..69f7a2ab9 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -220,6 +220,16 @@ export function AgentsView() { }} /> ) : null} + {personas.createdAgent ? ( + { + if (!open) { + personas.setCreatedAgent(null); + } + }} + /> + ) : null} {personas.personaDialogState ? ( line.trim().length > 0); + const avatarUrl = importedAvatarUrl(persona); return (
e.stopPropagation()} /> diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index df6d3fcce..a881a14bb 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -1,5 +1,6 @@ import * as React from "react"; -import { RefreshCw, Upload } from "lucide-react"; +import { ChevronDown, RefreshCw, Upload } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { AcpRuntimeCatalogEntry, @@ -9,15 +10,12 @@ 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 { Textarea } from "@/shared/ui/textarea"; +import { AgentCreationPreview } from "./AgentCreationPreview"; +import { PersonaDropdownField } from "./PersonaDropdownField"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { getImportButtonLabel, @@ -25,7 +23,30 @@ import { getImportErrorLabel, IMPORT_ERROR_VISIBILITY_MS, } from "./personaDialogImportState"; -import { canSubmitPersonaDialog } from "./personaDialogState"; +import { + canSubmitPersonaDialog, + formatPersonaNamePoolText, + parsePersonaNamePoolText, +} from "./personaDialogState"; +import { + AUTO_MODEL_DROPDOWN_VALUE, + AUTO_PROVIDER_DROPDOWN_VALUE, + CUSTOM_MODEL_DROPDOWN_VALUE, + CUSTOM_PROVIDER_DROPDOWN_VALUE, + formatRuntimeOptionLabel, + getDefaultPersonaRuntime, + getModelSelectValue, + getPersonaModelOptions, + getPersonaProviderOptions, + getRuntimePersonaModelOptions, + hasPersonaModelOption, + NO_RUNTIME_DROPDOWN_VALUE, + type PersonaDropdownOption, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + shouldClearKnownModelForSelectionScope, +} from "./personaDialogPickers"; +import { shouldClearModelForRuntimeChange } from "./personaRuntimeModel"; type PersonaDialogProps = { open: boolean; @@ -47,6 +68,13 @@ type PersonaDialogProps = { ) => Promise; }; +const PERSONA_LABEL_OPTIONAL_CLASS = + "ml-1 text-xs font-normal text-muted-foreground/50"; +const ADVANCED_FIELDS_MOTION_TRANSITION = { + duration: 0.18, + ease: [0.23, 1, 0.32, 1], +} as const; + export function PersonaDialog({ open, title, @@ -67,9 +95,15 @@ export function PersonaDialog({ const [systemPrompt, setSystemPrompt] = React.useState(""); const [runtime, setRuntime] = React.useState(""); const [model, setModel] = React.useState(""); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const [provider, setProvider] = React.useState(""); + const [isCustomProviderEditing, setIsCustomProviderEditing] = + React.useState(false); const [namePoolText, setNamePoolText] = React.useState(""); const [envVars, setEnvVars] = React.useState({}); + const [showAdvancedFields, setShowAdvancedFields] = React.useState(false); + const [isAvatarUploadPending, setIsAvatarUploadPending] = + React.useState(false); const [isImportingUpdate, setIsImportingUpdate] = React.useState(false); const [importErrorMessage, setImportErrorMessage] = React.useState< string | null @@ -81,6 +115,11 @@ export function PersonaDialog({ ? initialValues.id : null; const canImportPersonaUpdate = isEditMode && Boolean(onImportUpdateFile); + const defaultRuntime = React.useMemo( + () => getDefaultPersonaRuntime(runtimes), + [runtimes], + ); + const shouldReduceMotion = useReducedMotion(); React.useEffect(() => { if (!open || !initialValues) { @@ -92,18 +131,41 @@ export function PersonaDialog({ setSystemPrompt(initialValues.systemPrompt); setRuntime(initialValues.runtime ?? ""); setModel(initialValues.model ?? ""); + setIsCustomModelEditing(false); setProvider(initialValues.provider ?? ""); - setNamePoolText( - ("namePool" in initialValues - ? (initialValues as { namePool?: string[] }).namePool - : undefined - )?.join(", ") ?? "", + setIsCustomProviderEditing(false); + const nextNamePoolText = + "namePool" in initialValues + ? formatPersonaNamePoolText(initialValues.namePool) + : ""; + const nextEnvVars = + "envVars" in initialValues ? (initialValues.envVars ?? {}) : {}; + setNamePoolText(nextNamePoolText); + setEnvVars(nextEnvVars); + setShowAdvancedFields( + nextNamePoolText.trim().length > 0 || Object.keys(nextEnvVars).length > 0, ); - setEnvVars(initialValues.envVars ?? {}); + setIsAvatarUploadPending(false); setImportErrorMessage(null); setIsImportingUpdate(false); }, [initialValues, open]); + React.useEffect(() => { + if ( + !open || + !initialValues || + "id" in initialValues || + initialValues.runtime?.trim() || + runtimesLoading || + runtime.trim().length > 0 || + defaultRuntime === null + ) { + return; + } + + setRuntime(defaultRuntime.id); + }, [defaultRuntime, initialValues, open, runtime, runtimesLoading]); + React.useEffect(() => { if (!open || !canImportPersonaUpdate) { setIsWindowFileDragOver(false); @@ -219,8 +281,13 @@ export function PersonaDialog({ setSystemPrompt(""); setRuntime(""); setModel(""); + setIsCustomModelEditing(false); setProvider(""); + setIsCustomProviderEditing(false); setNamePoolText(""); + setEnvVars({}); + setShowAdvancedFields(false); + setIsAvatarUploadPending(false); setImportErrorMessage(null); setIsImportingUpdate(false); setIsWindowFileDragOver(false); @@ -230,22 +297,43 @@ export function PersonaDialog({ } async function handleSubmit() { - if (!initialValues) { + if ( + !initialValues || + !canSubmitPersonaDialog({ displayName, isPending }) || + isAvatarUploadPending + ) { return; } - const namePool = namePoolText - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const trimmedRuntime = runtime.trim(); + const previousRuntime = initialValues.runtime?.trim() ?? ""; + const shouldPreserveHiddenModelProvider = + "id" in initialValues && + previousRuntime.length === 0 && + trimmedRuntime.length === 0; + const namePool = parsePersonaNamePoolText(namePoolText); + const namePoolInput = + namePool.length > 0 + ? namePool + : "namePool" in initialValues + ? [] + : undefined; const baseInput = { - displayName, + displayName: displayName.trim(), avatarUrl: avatarUrl.trim() || undefined, - systemPrompt, - runtime: runtime.trim() || undefined, - model: model.trim() || undefined, - provider: provider.trim() || undefined, - namePool: namePool.length > 0 ? namePool : undefined, + systemPrompt: systemPrompt.trim(), + runtime: trimmedRuntime || undefined, + model: trimmedRuntime + ? model.trim() || undefined + : shouldPreserveHiddenModelProvider + ? initialValues.model + : undefined, + provider: trimmedRuntime + ? provider.trim() || undefined + : shouldPreserveHiddenModelProvider + ? initialValues.provider + : undefined, + namePool: namePoolInput, envVars, }; @@ -260,6 +348,11 @@ export function PersonaDialog({ await onSubmit(baseInput); } + function handleSubmitForm(event: React.FormEvent) { + event.preventDefault(); + void handleSubmit(); + } + const importButtonTone = getImportButtonTone({ isWindowFileDragOver, isImportDragOver, @@ -272,6 +365,81 @@ export function PersonaDialog({ }); const selectedRuntime = runtimes.find((p) => p.id === runtime); + const llmProviderFieldVisible = runtime.trim().length > 0; + const modelFieldVisible = llmProviderFieldVisible; + const isCreateMode = Boolean(initialValues && !("id" in initialValues)); + const selectedRuntimeIsAvailable = + runtime.trim().length === 0 || + selectedRuntime?.availability === "available"; + const canSubmit = + canSubmitPersonaDialog({ displayName, isPending }) && + (!isCreateMode || runtime.trim().length > 0) && + (!isCreateMode || selectedRuntimeIsAvailable) && + !isAvatarUploadPending; + const modelOptions = getPersonaModelOptions(runtime, provider); + const runtimeModelOptions = getRuntimePersonaModelOptions(runtime); + const isModelCustom = !hasPersonaModelOption(runtimeModelOptions, model); + const modelSelectValue = getModelSelectValue({ + isCustomModelEditing, + isModelCustom, + model, + }); + const showCustomModelInput = + modelFieldVisible && (isCustomModelEditing || isModelCustom); + const providerOptions = getPersonaProviderOptions(provider); + const providerSelectValue = isCustomProviderEditing + ? CUSTOM_PROVIDER_DROPDOWN_VALUE + : provider.trim() || AUTO_PROVIDER_DROPDOWN_VALUE; + const showCustomProviderInput = + llmProviderFieldVisible && isCustomProviderEditing; + const runtimeDropdownValue = runtime.trim() || NO_RUNTIME_DROPDOWN_VALUE; + const blankRuntimeOptionLabel = runtimesLoading + ? "Loading providers..." + : isCreateMode + ? "Choose a provider" + : "No preference (use app default)"; + const runtimeDropdownOptions: PersonaDropdownOption[] = [ + ...(!isCreateMode + ? [ + { + label: blankRuntimeOptionLabel, + value: NO_RUNTIME_DROPDOWN_VALUE, + }, + ] + : []), + ...runtimes.map((candidate) => ({ + disabled: isCreateMode && candidate.availability !== "available", + label: `${formatRuntimeOptionLabel(candidate)}${ + isCreateMode && candidate.id === defaultRuntime?.id ? " (default)" : "" + }`, + value: candidate.id, + })), + ]; + if ( + runtime.trim().length > 0 && + !runtimeDropdownOptions.some((option) => option.value === runtime) + ) { + runtimeDropdownOptions.push({ + label: `${runtime.trim()} (current)`, + value: runtime.trim(), + }); + } + const providerDropdownOptions: PersonaDropdownOption[] = [ + ...providerOptions.map((option) => ({ + label: option.label, + value: option.id || AUTO_PROVIDER_DROPDOWN_VALUE, + })), + { label: "Custom provider...", value: CUSTOM_PROVIDER_DROPDOWN_VALUE }, + ]; + const modelDropdownOptions: PersonaDropdownOption[] = [ + ...modelOptions.map((option) => ({ + label: option.label, + value: option.id || AUTO_MODEL_DROPDOWN_VALUE, + })), + { label: "Custom model...", value: CUSTOM_MODEL_DROPDOWN_VALUE }, + ]; + const previewLabel = displayName.trim() || "Agent name"; + const previewAvatarUrl = avatarUrl.trim() || null; const runtimeWarning = selectedRuntime && selectedRuntime.availability !== "available" ? (

@@ -283,193 +451,103 @@ export function PersonaDialog({ Visit Settings > Doctor to set it up.

) : null; + const advancedFieldsTransition = shouldReduceMotion + ? { duration: 0 } + : ADVANCED_FIELDS_MOTION_TRANSITION; - 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. -

-
- -
- -