From a978dd63107bf1040f4405961d405e4aef169945 Mon Sep 17 00:00:00 2001 From: Andres Uribe Date: Wed, 8 Jul 2026 21:13:50 -0400 Subject: [PATCH] fix(desktop): persist agent avatar edits The edit-agent dialog dropped avatar changes silently: `avatarUrl` was absent from the entire update path (UI submit -> UpdateManagedAgentInput -> UpdateManagedAgentRequest -> handler), and the avatar picker was hard-disabled with a comment noting the backend couldn't persist it. Creating an agent set the image fine, so the bug was specific to updating an existing agent. Wire `avatarUrl` end to end: - add `avatarUrl?: string | null` to UpdateManagedAgentInput (tri-state) - resolve + send it from the edit dialog (base64 data URIs upload to a hosted URL, emoji SVGs pass through, mirroring the create path) and enable the picker - add `avatar_url: Option>` to UpdateManagedAgentRequest - assign it to the record and re-publish the agent's kind:0 profile when the avatar changes (previously only on rename) Tri-state semantics: absent = don't touch, null = clear to the harness default avatar, url = set. Tests: add avatar_url deserialization tri-state tests (mirroring provider). Verified desktop typecheck, biome, JS unit tests, tauri crate tests, clippy, and rustfmt. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src-tauri/src/commands/agent_models.rs | 25 ++++++++++-- desktop/src-tauri/src/managed_agents/types.rs | 4 ++ .../src/managed_agents/types/tests.rs | 38 +++++++++++++++++++ .../agents/ui/AgentInstanceEditDialog.tsx | 25 ++++++++++-- desktop/src/shared/api/types.ts | 6 +++ 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ae10d92b13..d0f176709c 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -827,6 +827,22 @@ pub async fn update_managed_agent( if let Some(prompt_update) = input.system_prompt { record.system_prompt = prompt_update; } + // Avatar edit: normalize (trim + treat empty as cleared), then track + // whether it actually changed so an avatar-only edit still re-publishes + // the kind:0 profile below. A cleared avatar (None) falls back to the + // harness default avatar at sync time. + let mut avatar_changed = false; + if let Some(avatar_update) = input.avatar_url { + let normalized = avatar_update + .as_deref() + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string); + if normalized != record.avatar_url { + record.avatar_url = normalized; + avatar_changed = true; + } + } if let Some(toolsets_update) = input.mcp_toolsets { record.mcp_toolsets = toolsets_update .as_deref() @@ -918,11 +934,12 @@ pub async fn update_managed_agent( // update that touched only runtime/local fields is a no-op publish. super::agents::retain_managed_agent_pending(&app, &state, record); - let sync_params = if name_changed { + let sync_params = if name_changed || avatar_changed { let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; - // Re-publish the renamed profile to the agent's effective relay: - // an explicit per-agent relay wins; empty falls back to workspace. + // Re-publish the profile to the agent's effective relay when the + // display name or avatar changed. Relay selection: an explicit + // per-agent relay wins; empty falls back to the workspace relay. let relay_url = crate::relay::effective_agent_relay_url( &record.relay_url, &relay_ws_url_with_override(&state), @@ -967,7 +984,7 @@ pub async fn update_managed_agent( { Ok(()) => None, Err(e) => { - eprintln!("buzz-desktop: relay profile sync failed after rename: {e}"); + eprintln!("buzz-desktop: relay profile sync failed after profile edit: {e}"); Some(e) } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 33e715a207..6784936273 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -639,6 +639,10 @@ pub struct UpdateManagedAgentRequest { pub system_prompt: Option>, #[serde(default)] pub mcp_toolsets: Option>, + /// Absent = don't touch. null = clear to the harness default avatar. + /// "url" = set. Data URIs are resolved to a hosted URL client-side. + #[serde(default, deserialize_with = "crate::util::double_option")] + pub avatar_url: Option>, /// Absent = don't touch. Present = replace the env_vars map entirely. #[serde(default)] pub env_vars: Option>, diff --git a/desktop/src-tauri/src/managed_agents/types/tests.rs b/desktop/src-tauri/src/managed_agents/types/tests.rs index 752a7a04df..bac77d6aa9 100644 --- a/desktop/src-tauri/src/managed_agents/types/tests.rs +++ b/desktop/src-tauri/src/managed_agents/types/tests.rs @@ -249,6 +249,44 @@ fn update_request_provider_tristate_value_means_set() { ); } +#[test] +fn update_request_avatar_url_tristate_absent_means_no_touch() { + // No "avatarUrl" key → None → leave the record's existing avatar unchanged. + let request: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey": "abcd1234"}"#) + .expect("minimal update request should deserialize"); + assert!( + request.avatar_url.is_none(), + "absent avatarUrl must deserialize to None (don't touch)" + ); +} + +#[test] +fn update_request_avatar_url_tristate_null_means_clear() { + // `"avatarUrl": null` → Some(None) → clear to the harness default avatar. + let request: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey": "abcd1234", "avatarUrl": null}"#) + .expect("null avatarUrl request should deserialize"); + assert_eq!( + request.avatar_url, + Some(None), + "explicit null must deserialize to Some(None) (clear)" + ); +} + +#[test] +fn update_request_avatar_url_tristate_value_means_set() { + // A hosted URL → Some(Some(url)) → set the avatar. + let request: super::UpdateManagedAgentRequest = + serde_json::from_str(r#"{"pubkey": "abcd1234", "avatarUrl": "https://cdn.example/a.png"}"#) + .expect("avatarUrl value request should deserialize"); + assert_eq!( + request.avatar_url, + Some(Some("https://cdn.example/a.png".to_string())), + "avatarUrl value must deserialize to Some(Some(value)) (set)" + ); +} + use super::{CreateManagedAgentRequest, RelayMeshConfig}; /// Wire-shape test: the create request arrives from TS as camelCase diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 584212f077..b3bf11b6a7 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -20,6 +20,7 @@ import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; +import { resolveManagedAgentAvatarUrl } from "./managedAgentAvatar"; import { AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, @@ -507,6 +508,22 @@ export function AgentInstanceEditDialog({ // all agree. See resolveInheritedRuntimeSubmission. const normalizedSubmitProvider = inheritedSubmission.provider; const submitEnvVars = inheritedSubmission.envVars; + + // Resolve the avatar to a hosted URL (base64 data URIs are uploaded; + // emoji SVG data URIs pass through unchanged), mirroring the create path. + // On upload failure the helper falls back to the existing avatar, so a + // transient failure never clobbers the current image. Tri-state on the + // wire: null clears to the harness default, a URL sets it, undefined + // (unchanged) is omitted. + const resolvedAvatarUrl = await resolveManagedAgentAvatarUrl( + avatarUrl, + undefined, + agent.avatarUrl, + ); + const nextAvatarUrl = resolvedAvatarUrl ?? null; + const avatarUrlUpdate = + nextAvatarUrl !== (agent.avatarUrl ?? null) ? nextAvatarUrl : undefined; + const input: UpdateManagedAgentInput = { pubkey: agent.pubkey, name: name.trim() !== agent.name ? name.trim() : undefined, @@ -548,6 +565,7 @@ export function AgentInstanceEditDialog({ (systemPrompt.trim() || null) !== agent.systemPrompt ? systemPrompt.trim() || null : undefined, + avatarUrl: avatarUrlUpdate, model: normalizedModel !== (agent.model ?? null) ? normalizedModel @@ -690,12 +708,11 @@ export function AgentInstanceEditDialog({ } >
- {/* Avatar is display-only — UpdateManagedAgentInput has no - avatarUrl field, so edits can't be persisted yet. Keep the - preview disabled until the backend supports avatar updates. */} + {/* Avatar edits persist via UpdateManagedAgentInput.avatarUrl — + resolved to a hosted URL on submit and re-published to the + agent's kind:0 profile when it changes. */} setAvatarUrl("")} onUploadPendingChange={setIsAvatarUploadPending} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d42097b383..3a7ad1f089 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -675,6 +675,12 @@ export type UpdateManagedAgentInput = { provider?: string | null; systemPrompt?: string | null; mcpToolsets?: string | null; + /** + * Absent = don't touch. null = clear to the harness default avatar. + * A hosted URL sets it. Data URIs must be resolved to a hosted URL + * (via resolveManagedAgentAvatarUrl) before being sent. + */ + avatarUrl?: string | null; /** Absent = don't touch. Present = replace the env_vars map entirely. */ envVars?: Record; parallelism?: number;