mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): improve config bridge UX
Resolve persona system prompts in the config surface so all agents with a linked persona display their system prompt (fixes the inconsistency where Paul showed a prompt but Alia did not). Surface the AgentConfigPanel in the profile pop-out sidebar for owned bot agents, matching the existing dropdown view. Remove the cryptic SourcesFooter (✓/⏳/— icons) and add descriptive tooltips to per-field origin badges so users understand where each config value comes from without documentation. Give PersonaDefault origin a distinct violet badge color to differentiate it from ConfigFile (gray). Co-authored-by: Will Pfleger <wpfleger@block.xyz> Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
co-authored by
Will Pfleger
parent
fba912fc48
commit
9baa3cd198
@@ -12,7 +12,8 @@ use crate::{
|
||||
},
|
||||
writer::plan_config_write,
|
||||
},
|
||||
known_acp_runtime, load_managed_agents, save_managed_agents, sync_managed_agent_processes,
|
||||
known_acp_runtime, load_managed_agents, load_personas, save_managed_agents,
|
||||
sync_managed_agent_processes,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -47,11 +48,13 @@ pub async fn get_agent_config_surface(
|
||||
|
||||
let runtime_meta = known_acp_runtime(&record.agent_command);
|
||||
let session_cache = state.get_session_cache(&pubkey);
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
|
||||
Ok(read_config_surface(
|
||||
&record,
|
||||
runtime_meta,
|
||||
session_cache.as_ref(),
|
||||
&personas,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -80,7 +83,8 @@ pub async fn write_agent_config_field(
|
||||
|
||||
let runtime_meta = known_acp_runtime(&record.agent_command);
|
||||
let session_cache = state.get_session_cache(&request.pubkey);
|
||||
let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref());
|
||||
let personas = load_personas(&app).unwrap_or_default();
|
||||
let surface = read_config_surface(&record, runtime_meta, session_cache.as_ref(), &personas);
|
||||
|
||||
let mut result = plan_config_write(&surface, &request.field);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::managed_agents::discovery::KnownAcpRuntime;
|
||||
use crate::managed_agents::types::ManagedAgentRecord;
|
||||
use crate::managed_agents::types::{ManagedAgentRecord, PersonaRecord};
|
||||
|
||||
use super::types::*;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) fn read_config_surface(
|
||||
record: &ManagedAgentRecord,
|
||||
runtime_meta: Option<&KnownAcpRuntime>,
|
||||
session_cache: Option<&SessionConfigCache>,
|
||||
personas: &[PersonaRecord],
|
||||
) -> RuntimeConfigSurface {
|
||||
let is_pre_spawn = session_cache.is_none();
|
||||
|
||||
@@ -97,19 +98,40 @@ pub(crate) fn read_config_surface(
|
||||
.system_prompt
|
||||
.clone()
|
||||
.or_else(|| record.env_vars.get("BUZZ_ACP_SYSTEM_PROMPT").cloned());
|
||||
record_system_prompt.as_ref().map(|v| NormalizedField {
|
||||
value: Some(v.clone()),
|
||||
origin: ConfigOrigin::BuzzExplicit,
|
||||
is_writable: true,
|
||||
write_via: ConfigWriteMechanism::RespawnWithEnvVar {
|
||||
env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(),
|
||||
},
|
||||
overridden_value: file_config.system_prompt.clone(),
|
||||
overridden_origin: file_config
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|_| ConfigOrigin::ConfigFile),
|
||||
})
|
||||
if let Some(ref v) = record_system_prompt {
|
||||
Some(NormalizedField {
|
||||
value: Some(v.clone()),
|
||||
origin: ConfigOrigin::BuzzExplicit,
|
||||
is_writable: true,
|
||||
write_via: ConfigWriteMechanism::RespawnWithEnvVar {
|
||||
env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(),
|
||||
},
|
||||
overridden_value: file_config.system_prompt.clone(),
|
||||
overridden_origin: file_config
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|_| ConfigOrigin::ConfigFile),
|
||||
})
|
||||
} else {
|
||||
// Fall back to the linked persona's system prompt.
|
||||
record
|
||||
.persona_id
|
||||
.as_deref()
|
||||
.and_then(|pid| personas.iter().find(|p| p.id == pid))
|
||||
.map(|p| NormalizedField {
|
||||
value: Some(p.system_prompt.clone()),
|
||||
origin: ConfigOrigin::PersonaDefault,
|
||||
is_writable: true,
|
||||
write_via: ConfigWriteMechanism::RespawnWithEnvVar {
|
||||
env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(),
|
||||
},
|
||||
overridden_value: file_config.system_prompt.clone(),
|
||||
overridden_origin: file_config
|
||||
.system_prompt
|
||||
.as_ref()
|
||||
.map(|_| ConfigOrigin::ConfigFile),
|
||||
})
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -493,7 +515,7 @@ mod tests {
|
||||
fn pre_spawn_surface_reports_pending_acp_tiers() {
|
||||
let record = test_record();
|
||||
let runtime = test_runtime();
|
||||
let surface = read_config_surface(&record, Some(runtime), None);
|
||||
let surface = read_config_surface(&record, Some(runtime), None, &[]);
|
||||
|
||||
assert!(surface.is_pre_spawn);
|
||||
assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending);
|
||||
@@ -510,7 +532,7 @@ mod tests {
|
||||
record.model = Some("explicit-model".to_string());
|
||||
let runtime = test_runtime();
|
||||
|
||||
let surface = read_config_surface(&record, Some(runtime), None);
|
||||
let surface = read_config_surface(&record, Some(runtime), None, &[]);
|
||||
let model = surface.normalized.model.unwrap();
|
||||
assert_eq!(model.value.as_deref(), Some("explicit-model"));
|
||||
assert_eq!(model.origin, ConfigOrigin::BuzzExplicit);
|
||||
@@ -523,7 +545,7 @@ mod tests {
|
||||
provider_locked: true,
|
||||
..*test_runtime()
|
||||
};
|
||||
let surface = read_config_surface(&record, Some(runtime), None);
|
||||
let surface = read_config_surface(&record, Some(runtime), None, &[]);
|
||||
let provider = surface.normalized.provider.unwrap();
|
||||
assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)"));
|
||||
assert!(!provider.is_writable);
|
||||
@@ -548,7 +570,7 @@ mod tests {
|
||||
captured_at: "".to_string(),
|
||||
};
|
||||
|
||||
let surface = read_config_surface(&record, Some(runtime), Some(&cache));
|
||||
let surface = read_config_surface(&record, Some(runtime), Some(&cache), &[]);
|
||||
assert!(!surface.is_pre_spawn);
|
||||
let model = surface.normalized.model.unwrap();
|
||||
assert_eq!(model.value.as_deref(), Some("claude-opus-4"));
|
||||
@@ -571,7 +593,7 @@ mod tests {
|
||||
captured_at: "".to_string(),
|
||||
};
|
||||
|
||||
let surface = read_config_surface(&record, Some(runtime), Some(&cache));
|
||||
let surface = read_config_surface(&record, Some(runtime), Some(&cache), &[]);
|
||||
let model = surface.normalized.model.unwrap();
|
||||
assert_eq!(model.value.as_deref(), Some("acp-model"));
|
||||
assert_eq!(model.origin, ConfigOrigin::AcpConfigOption);
|
||||
@@ -579,4 +601,40 @@ mod tests {
|
||||
// the actual file in a unit test, just verify the override fields are populated
|
||||
// when we manually construct the scenario via build_model_field.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persona_system_prompt_used_when_record_has_none() {
|
||||
use crate::managed_agents::types::PersonaRecord;
|
||||
|
||||
let mut record = test_record();
|
||||
record.persona_id = Some("persona-1".to_string());
|
||||
let runtime = test_runtime();
|
||||
|
||||
let personas = vec![PersonaRecord {
|
||||
id: "persona-1".to_string(),
|
||||
display_name: "Test Persona".to_string(),
|
||||
avatar_url: None,
|
||||
system_prompt: "You are a helpful assistant.".to_string(),
|
||||
runtime: None,
|
||||
model: Some("test-model".to_string()),
|
||||
provider: None,
|
||||
name_pool: vec![],
|
||||
is_builtin: false,
|
||||
is_active: true,
|
||||
source_team: None,
|
||||
source_team_persona_slug: None,
|
||||
env_vars: BTreeMap::new(),
|
||||
created_at: "".to_string(),
|
||||
updated_at: "".to_string(),
|
||||
}];
|
||||
|
||||
let surface = read_config_surface(&record, Some(runtime), None, &personas);
|
||||
let prompt = surface.normalized.system_prompt.unwrap();
|
||||
assert_eq!(
|
||||
prompt.value.as_deref(),
|
||||
Some("You are a helpful assistant.")
|
||||
);
|
||||
assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault);
|
||||
assert!(prompt.is_writable);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { ChevronDown, ChevronRight, Info } from "lucide-react";
|
||||
import { useAgentConfigSurface } from "../hooks";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Spinner } from "@/shared/ui/spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/shared/ui/tooltip";
|
||||
import type {
|
||||
ConfigField,
|
||||
ConfigOrigin,
|
||||
NormalizedConfig,
|
||||
NormalizedField,
|
||||
ConfigSourceReport,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type Props = {
|
||||
@@ -44,6 +48,28 @@ function originLabel(
|
||||
}
|
||||
}
|
||||
|
||||
function originTooltip(
|
||||
origin: ConfigOrigin,
|
||||
configFilePath: string | null,
|
||||
): string {
|
||||
switch (origin) {
|
||||
case "buzzExplicit":
|
||||
return "Set in Buzz UI";
|
||||
case "acpConfigOption":
|
||||
return "Set via ACP session";
|
||||
case "acpNativeRead":
|
||||
return "Read from ACP runtime";
|
||||
case "envVar":
|
||||
return "From environment variable";
|
||||
case "configFile":
|
||||
return configFilePath
|
||||
? `From config file (${configFilePath})`
|
||||
: "From config file";
|
||||
case "personaDefault":
|
||||
return "From persona defaults";
|
||||
}
|
||||
}
|
||||
|
||||
function originColorClass(origin: ConfigOrigin): string {
|
||||
switch (origin) {
|
||||
case "buzzExplicit":
|
||||
@@ -52,8 +78,9 @@ function originColorClass(origin: ConfigOrigin): string {
|
||||
case "acpNativeRead":
|
||||
return "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300";
|
||||
case "configFile":
|
||||
case "personaDefault":
|
||||
return "bg-muted text-muted-foreground";
|
||||
case "personaDefault":
|
||||
return "bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300";
|
||||
case "envVar":
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
|
||||
}
|
||||
@@ -67,15 +94,21 @@ function OriginBadge({
|
||||
configFilePath: string | null;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block max-w-[120px] truncate rounded px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
originColorClass(origin),
|
||||
)}
|
||||
title={originLabel(origin, configFilePath)}
|
||||
>
|
||||
{originLabel(origin, configFilePath)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block max-w-[120px] truncate rounded px-1.5 py-0.5 text-[10px] font-medium leading-none",
|
||||
originColorClass(origin),
|
||||
)}
|
||||
>
|
||||
{originLabel(origin, configFilePath)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{originTooltip(origin, configFilePath)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,34 +216,6 @@ function AdvancedRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sources footer ────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_ICON: Record<string, string> = {
|
||||
available: "✓",
|
||||
pending: "⏳",
|
||||
notApplicable: "—",
|
||||
};
|
||||
|
||||
function SourcesFooter({ sources }: { sources: ConfigSourceReport }) {
|
||||
const tiers = [
|
||||
{ label: "Config file", status: sources.configFile },
|
||||
{ label: "ACP native", status: sources.acpNative },
|
||||
{ label: "ACP config", status: sources.acpConfigOptions },
|
||||
{ label: "Env vars", status: sources.envVars },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<p className="mt-3 text-[10px] text-muted-foreground/70">
|
||||
{tiers.map((tier, i) => (
|
||||
<React.Fragment key={tier.label}>
|
||||
{i > 0 && <span className="mx-1.5">|</span>}
|
||||
{tier.label} <span>{STATUS_ICON[tier.status] ?? tier.status}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) {
|
||||
@@ -302,8 +307,6 @@ export function AgentConfigPanel({ pubkey, isRunning: _isRunning }: Props) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SourcesFooter sources={sources} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MemorySection } from "@/features/agent-memory/ui/MemorySection";
|
||||
import { AgentConfigPanel } from "@/features/agents/ui/AgentConfigPanel";
|
||||
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
@@ -231,6 +233,23 @@ export function ProfileSummaryView({
|
||||
{metadataFields.length > 0 ? (
|
||||
<ProfileFieldGroup fields={metadataFields} />
|
||||
) : null}
|
||||
|
||||
{isBot && isOwner === true && managedAgent !== undefined ? (
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Settings className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
Configuration
|
||||
</h4>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-muted/20 px-4 py-3">
|
||||
<AgentConfigPanel
|
||||
pubkey={managedAgent.pubkey}
|
||||
isRunning={managedAgent.status === "running"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user