Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions desktop/src-tauri/src/commands/agent_models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
}
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/managed_agents/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,10 @@ pub struct UpdateManagedAgentRequest {
pub system_prompt: Option<Option<String>>,
#[serde(default)]
pub mcp_toolsets: Option<Option<String>>,
/// 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<Option<String>>,
/// Absent = don't touch. Present = replace the env_vars map entirely.
#[serde(default)]
pub env_vars: Option<BTreeMap<String, String>>,
Expand Down
38 changes: 38 additions & 0 deletions desktop/src-tauri/src/managed_agents/types/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -548,6 +565,7 @@ export function AgentInstanceEditDialog({
(systemPrompt.trim() || null) !== agent.systemPrompt
? systemPrompt.trim() || null
: undefined,
avatarUrl: avatarUrlUpdate,
model:
normalizedModel !== (agent.model ?? null)
? normalizedModel
Expand Down Expand Up @@ -690,12 +708,11 @@ export function AgentInstanceEditDialog({
}
>
<div className="grid gap-5 lg:grid-cols-[220px_minmax(0,1fr)]">
{/* 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. */}
<AgentCreationPreview
avatarUrl={previewAvatarUrl}
disabled
label={previewLabel}
onClearAvatar={() => setAvatarUrl("")}
onUploadPendingChange={setIsAvatarUploadPending}
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/shared/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
parallelism?: number;
Expand Down