Restore chooser-based persona catalog flow (#275)

This commit is contained in:
Wes
2026-04-08 21:04:09 -07:00
committed by GitHub
parent 68a3d16d9e
commit 4d44f4741f
46 changed files with 3259 additions and 681 deletions

View File

@@ -33,6 +33,7 @@ export default defineConfig({
{
name: "integration",
testMatch: [
"**/agents.spec.ts",
"**/stream.spec.ts",
"**/integration.spec.ts",
"**/profile.spec.ts",

View File

@@ -30,7 +30,7 @@ const rules = [
// Exceptions should stay rare and temporary. Prefer splitting files instead.
const overrides = new Map([
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Strategist) are long string literals
["src-tauri/src/managed_agents/personas.rs", 600], // built-in persona system prompts (Solo, Ralph, Scout, Reviewer) keep this file slightly above the default cap even with tests extracted
["src-tauri/src/managed_agents/persona_card.rs", 800], // PNG/ZIP persona card codec + provider/model/namePool fields + 27 unit tests (~350 lines of tests); rustfmt adds line breaks around long literals/builders
["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards
["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation
@@ -45,7 +45,8 @@ const overrides = new Map([
["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks
["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
["src/features/agents/ui/AgentsView.tsx", 790], // remote agent stop/delete + channel UUID resolution + presence-aware delete guard + persona/team import + provider/model fields
["src/features/agents/hooks.ts", 520], // agent query/mutation surface now includes built-in persona library activation
["src/features/agents/ui/AgentsView.tsx", 850], // remote agent lifecycle controls + persona/team management + built-in catalog/library state orchestration
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/shared/api/types.ts", 530], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields

View File

@@ -5,15 +5,15 @@ use crate::{
app_state::AppState,
managed_agents::{
build_managed_agent_summary, default_token_scopes, discover_provider_candidates,
find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas,
managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api, normalize_agent_args,
provider_deploy, read_log_tail, resolve_provider_binary, save_managed_agents,
start_managed_agent_process, stop_managed_agent_process, sync_managed_agent_processes,
validate_provider_config, BackendKind, BackendProviderInfo, CreateManagedAgentRequest,
CreateManagedAgentResponse, ManagedAgentLogResponse, ManagedAgentRecord,
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents,
load_personas, managed_agent_avatar_url, managed_agent_log_path, mint_token_via_api,
normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary,
save_managed_agents, start_managed_agent_process, stop_managed_agent_process,
sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo,
CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse,
ManagedAgentRecord, ManagedAgentSummary, MintManagedAgentTokenRequest,
MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{relay_ws_url, sync_managed_agent_profile},
util::now_iso,
@@ -178,9 +178,7 @@ pub async fn create_managed_agent(
}
if let Some(persona_id) = requested_persona_id.as_deref() {
let personas = load_personas(&app)?;
if !personas.iter().any(|persona| persona.id == persona_id) {
return Err(format!("persona {persona_id} not found"));
}
ensure_persona_is_active(&personas, persona_id)?;
}
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();

View File

@@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_persona_json, load_managed_agents, load_personas, parse_json_persona,
encode_persona_json, load_managed_agents, load_personas, load_teams, parse_json_persona,
parse_png_persona, parse_zip_personas, save_managed_agents, save_personas,
CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
validate_persona_activation_change, validate_persona_deletion, CreatePersonaRequest,
ParsePersonaFilesResult, PersonaRecord, UpdatePersonaRequest,
},
util::now_iso,
};
@@ -72,6 +73,7 @@ pub fn create_persona(
model,
name_pool,
is_builtin: false,
is_active: true,
created_at: now.clone(),
updated_at: now,
};
@@ -141,9 +143,12 @@ pub fn delete_persona(
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
if persona.is_builtin {
return Err("Built-in personas cannot be deleted.".to_string());
}
let referenced_by_team = load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_deletion(persona, referenced_by_team)?;
let original_len = personas.len();
personas.retain(|record| record.id != id);
@@ -169,6 +174,53 @@ pub fn delete_persona(
Ok(())
}
#[tauri::command]
pub fn set_persona_active(
id: String,
active: bool,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<PersonaRecord, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut personas = load_personas(&app)?;
let persona = personas
.iter_mut()
.find(|record| record.id == id)
.ok_or_else(|| format!("persona {id} not found"))?;
let referenced_by_managed_agent = !active
&& load_managed_agents(&app)?
.iter()
.any(|agent| agent.persona_id.as_deref() == Some(id.as_str()));
let referenced_by_team = !active
&& load_teams(&app)?.iter().any(|team| {
team.persona_ids
.iter()
.any(|persona_id| persona_id == id.as_str())
});
validate_persona_activation_change(
persona,
active,
referenced_by_managed_agent,
referenced_by_team,
)?;
if persona.is_active == active {
return Ok(persona.clone());
}
persona.is_active = active;
persona.updated_at = now_iso();
let updated = persona.clone();
save_personas(&app, &personas)?;
Ok(updated)
}
// ---------------------------------------------------------------------------
// Import / Export
// ---------------------------------------------------------------------------

View File

@@ -5,8 +5,9 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_team_json, load_personas, load_teams, parse_team_json, save_teams,
CreateTeamRequest, ParsedTeamPreview, TeamRecord, UpdateTeamRequest,
encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams,
parse_team_json, save_teams, CreateTeamRequest, ParsedTeamPreview, TeamRecord,
UpdateTeamRequest,
},
util::now_iso,
};
@@ -49,6 +50,8 @@ pub fn create_team(
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = TeamRecord {
id: Uuid::new_v4().to_string(),
@@ -76,6 +79,8 @@ pub fn update_team(
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let personas = load_personas(&app)?;
ensure_persona_ids_are_active(&personas, &input.persona_ids)?;
let mut teams = load_teams(&app)?;
let team = teams
.iter_mut()

View File

@@ -409,6 +409,7 @@ pub fn run() {
create_persona,
update_persona,
delete_persona,
set_persona_active,
list_teams,
create_team,
update_team,

View File

@@ -403,6 +403,7 @@ fn built_in_persona_records(now: &str) -> Vec<PersonaRecord> {
model: None,
name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(),
is_builtin: true,
is_active: false,
created_at: now.to_string(),
updated_at: now.to_string(),
})
@@ -444,6 +445,7 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) {
let created_at = existing.created_at.clone();
let updated_at = existing.updated_at.clone();
let is_active = existing.is_active;
if existing.display_name != built_in.display_name
|| existing.avatar_url != built_in.avatar_url
|| existing.system_prompt != built_in.system_prompt
@@ -455,6 +457,7 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
*existing = PersonaRecord {
created_at,
updated_at,
is_active,
..built_in
};
changed = true;
@@ -469,6 +472,83 @@ fn merge_personas(mut stored: Vec<PersonaRecord>, now: &str) -> (Vec<PersonaReco
(stored, changed)
}
pub fn ensure_persona_is_active(
personas: &[PersonaRecord],
persona_id: &str,
) -> Result<(), String> {
let persona = personas
.iter()
.find(|candidate| candidate.id == persona_id)
.ok_or_else(|| format!("persona {persona_id} not found"))?;
if !persona.is_active {
return Err(format!(
"{} is not in My Agents. Choose it from Persona Catalog first.",
persona.display_name
));
}
Ok(())
}
pub fn ensure_persona_ids_are_active(
personas: &[PersonaRecord],
persona_ids: &[String],
) -> Result<(), String> {
for persona_id in persona_ids {
ensure_persona_is_active(personas, persona_id)?;
}
Ok(())
}
pub fn validate_persona_deletion(
persona: &PersonaRecord,
referenced_by_team: bool,
) -> Result<(), String> {
if persona.is_builtin {
return Err("Built-in personas cannot be deleted.".to_string());
}
if referenced_by_team {
return Err(format!(
"{} is still referenced by a team. Remove it from those teams first.",
persona.display_name
));
}
Ok(())
}
pub fn validate_persona_activation_change(
persona: &PersonaRecord,
active: bool,
referenced_by_managed_agent: bool,
referenced_by_team: bool,
) -> Result<(), String> {
if !persona.is_builtin {
return Err(
"Only built-in personas can be added to or removed from My Agents.".to_string(),
);
}
if !active && referenced_by_managed_agent {
return Err(format!(
"{} is still assigned to a managed agent. Remove or reassign those agents first.",
persona.display_name
));
}
if !active && referenced_by_team {
return Err(format!(
"{} is still referenced by a team. Remove it from those teams first.",
persona.display_name
));
}
Ok(())
}
pub fn load_personas(app: &AppHandle) -> Result<Vec<PersonaRecord>, String> {
let path = personas_store_path(app)?;
let now = now_iso();
@@ -501,82 +581,4 @@ pub fn save_personas(app: &AppHandle, records: &[PersonaRecord]) -> Result<(), S
}
#[cfg(test)]
mod tests {
use super::{merge_personas, BUILT_IN_PERSONAS};
use crate::managed_agents::PersonaRecord;
fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
PersonaRecord {
id: id.to_string(),
display_name: display_name.to_string(),
avatar_url: Some("https://example.com/avatar.png".to_string()),
system_prompt: "Custom prompt".to_string(),
provider: None,
model: None,
name_pool: Vec::new(),
is_builtin: false,
created_at: "2026-03-19T00:00:00Z".to_string(),
updated_at: "2026-03-19T00:00:00Z".to_string(),
}
}
#[test]
fn merge_personas_adds_missing_built_ins() {
let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z");
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(display_names, vec!["Solo", "Ralph", "Scout", "Reviewer"]);
}
#[test]
fn merge_personas_preserves_custom_records() {
let custom = custom_persona("custom:test", "Custom");
let (records, changed) = merge_personas(vec![custom.clone()], "2026-03-19T00:00:00Z");
assert!(changed);
assert!(records.iter().any(|record| record.id == custom.id));
}
#[test]
fn merge_personas_restores_builtin_defaults() {
let mut edited_builtin = custom_persona("builtin:reviewer", "My Reviewer");
edited_builtin.is_builtin = true;
let original_created_at = edited_builtin.created_at.clone();
let original_updated_at = edited_builtin.updated_at.clone();
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
assert!(changed);
let reviewer = records
.iter()
.find(|record| record.id == "builtin:reviewer")
.expect("reviewer built-in should exist");
assert_eq!(reviewer.display_name, "Reviewer");
assert_eq!(reviewer.avatar_url, None);
assert_eq!(reviewer.created_at, original_created_at);
assert_eq!(reviewer.updated_at, original_updated_at);
}
#[test]
fn merge_personas_backfills_new_builtins_for_existing_store() {
let mut legacy_builtins = vec![custom_persona("builtin:reviewer", "Reviewer")];
for persona in &mut legacy_builtins {
persona.is_builtin = true;
persona.avatar_url = None;
}
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
assert!(changed);
assert!(records.iter().any(|record| record.id == "builtin:reviewer"));
assert!(records.iter().any(|record| record.id == "builtin:ralph"));
assert!(records.iter().any(|record| record.id == "builtin:scout"));
assert!(records.iter().any(|record| record.id == "builtin:solo"));
}
}
mod tests;

View File

@@ -0,0 +1,236 @@
use super::{
ensure_persona_ids_are_active, ensure_persona_is_active, merge_personas,
validate_persona_activation_change, validate_persona_deletion, BUILT_IN_PERSONAS,
};
use crate::managed_agents::PersonaRecord;
fn custom_persona(id: &str, display_name: &str) -> PersonaRecord {
PersonaRecord {
id: id.to_string(),
display_name: display_name.to_string(),
avatar_url: Some("https://example.com/avatar.png".to_string()),
system_prompt: "Custom prompt".to_string(),
provider: None,
model: None,
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
created_at: "2026-03-19T00:00:00Z".to_string(),
updated_at: "2026-03-19T00:00:00Z".to_string(),
}
}
#[test]
fn merge_personas_adds_missing_built_ins() {
let (records, changed) = merge_personas(Vec::new(), "2026-03-19T00:00:00Z");
assert!(changed);
assert_eq!(records.len(), BUILT_IN_PERSONAS.len());
assert!(records.iter().all(|record| record.is_builtin));
assert!(records.iter().all(|record| !record.is_active));
let display_names: Vec<&str> = records
.iter()
.map(|record| record.display_name.as_str())
.collect();
assert_eq!(display_names, vec!["Solo", "Ralph", "Scout", "Reviewer"]);
}
#[test]
fn merge_personas_preserves_custom_records() {
let custom = custom_persona("custom:test", "Custom");
let (records, changed) = merge_personas(vec![custom.clone()], "2026-03-19T00:00:00Z");
assert!(changed);
assert!(records.iter().any(|record| record.id == custom.id));
}
#[test]
fn merge_personas_restores_builtin_defaults() {
let mut edited_builtin = custom_persona("builtin:reviewer", "My Reviewer");
edited_builtin.is_builtin = true;
edited_builtin.is_active = true;
let original_created_at = edited_builtin.created_at.clone();
let original_updated_at = edited_builtin.updated_at.clone();
let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z");
assert!(changed);
let reviewer = records
.iter()
.find(|record| record.id == "builtin:reviewer")
.expect("reviewer built-in should exist");
assert_eq!(reviewer.display_name, "Reviewer");
assert_eq!(reviewer.avatar_url, None);
assert_eq!(reviewer.created_at, original_created_at);
assert_eq!(reviewer.updated_at, original_updated_at);
assert!(reviewer.is_active);
}
#[test]
fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() {
let mut solo = custom_persona("builtin:solo", "Solo");
solo.is_builtin = true;
solo.avatar_url = None;
solo.is_active = true;
solo.name_pool = vec!["Definitely Not Solo".to_string()];
let (records, changed) = merge_personas(vec![solo], "2026-03-19T00:00:00Z");
assert!(changed);
let solo = records
.iter()
.find(|record| record.id == "builtin:solo")
.expect("solo built-in should exist");
let expected_name_pool = BUILT_IN_PERSONAS
.iter()
.find(|persona| persona.id == "builtin:solo")
.expect("solo built-in definition should exist")
.name_pool
.iter()
.map(|name| (*name).to_string())
.collect::<Vec<_>>();
assert_eq!(solo.name_pool, expected_name_pool);
assert!(solo.is_active);
}
#[test]
fn merge_personas_backfills_new_builtins_for_existing_store() {
let mut legacy_builtins = vec![custom_persona("builtin:reviewer", "Reviewer")];
for persona in &mut legacy_builtins {
persona.is_builtin = true;
persona.avatar_url = None;
}
let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z");
assert!(changed);
assert!(records.iter().any(|record| record.id == "builtin:reviewer"));
assert!(records.iter().any(|record| record.id == "builtin:ralph"));
assert!(records.iter().any(|record| record.id == "builtin:scout"));
assert!(records.iter().any(|record| record.id == "builtin:solo"));
assert!(
records
.iter()
.find(|record| record.id == "builtin:reviewer")
.expect("reviewer built-in should exist")
.is_active
);
assert!(
!records
.iter()
.find(|record| record.id == "builtin:solo")
.expect("solo built-in should exist")
.is_active
);
}
#[test]
fn ensure_persona_is_active_rejects_missing_personas() {
let err = ensure_persona_is_active(&[], "missing").unwrap_err();
assert_eq!(err, "persona missing not found");
}
#[test]
fn ensure_persona_is_active_rejects_inactive_personas() {
let mut persona = custom_persona("builtin:solo", "Solo");
persona.is_builtin = true;
persona.is_active = false;
let err = ensure_persona_is_active(&[persona], "builtin:solo").unwrap_err();
assert_eq!(
err,
"Solo is not in My Agents. Choose it from Persona Catalog first."
);
}
#[test]
fn ensure_persona_ids_are_active_checks_each_requested_id() {
let personas = vec![
custom_persona("custom:alpha", "Alpha"),
custom_persona("custom:beta", "Beta"),
];
assert!(ensure_persona_ids_are_active(
&personas,
&["custom:alpha".to_string(), "custom:beta".to_string()],
)
.is_ok());
}
#[test]
fn validate_persona_activation_change_rejects_non_builtins() {
let persona = custom_persona("custom:alpha", "Alpha");
let err = validate_persona_activation_change(&persona, false, false, false).unwrap_err();
assert_eq!(
err,
"Only built-in personas can be added to or removed from My Agents."
);
}
#[test]
fn validate_persona_activation_change_rejects_managed_agent_references() {
let mut persona = custom_persona("builtin:solo", "Solo");
persona.is_builtin = true;
let err = validate_persona_activation_change(&persona, false, true, false).unwrap_err();
assert_eq!(
err,
"Solo is still assigned to a managed agent. Remove or reassign those agents first."
);
}
#[test]
fn validate_persona_activation_change_rejects_team_references() {
let mut persona = custom_persona("builtin:solo", "Solo");
persona.is_builtin = true;
let err = validate_persona_activation_change(&persona, false, false, true).unwrap_err();
assert_eq!(
err,
"Solo is still referenced by a team. Remove it from those teams first."
);
}
#[test]
fn validate_persona_activation_change_allows_safe_builtin_updates() {
let mut persona = custom_persona("builtin:solo", "Solo");
persona.is_builtin = true;
assert!(validate_persona_activation_change(&persona, true, false, false).is_ok());
assert!(validate_persona_activation_change(&persona, false, false, false).is_ok());
}
#[test]
fn validate_persona_deletion_rejects_builtins() {
let mut persona = custom_persona("builtin:solo", "Solo");
persona.is_builtin = true;
let err = validate_persona_deletion(&persona, false).unwrap_err();
assert_eq!(err, "Built-in personas cannot be deleted.");
}
#[test]
fn validate_persona_deletion_rejects_team_references() {
let persona = custom_persona("custom:alpha", "Alpha");
let err = validate_persona_deletion(&persona, true).unwrap_err();
assert_eq!(
err,
"Alpha is still referenced by a team. Remove it from those teams first."
);
}
#[test]
fn validate_persona_deletion_allows_safe_custom_personas() {
let persona = custom_persona("custom:alpha", "Alpha");
assert!(validate_persona_deletion(&persona, false).is_ok());
}

View File

@@ -67,19 +67,32 @@ pub fn save_teams(app: &AppHandle, records: &[TeamRecord]) -> Result<(), String>
/// name, description, and the full persona data for each member (so the
/// import side can recreate personas that don't exist locally).
pub fn encode_team_json(team: &TeamRecord, personas: &[PersonaRecord]) -> Result<Vec<u8>, String> {
let resolved_personas: Vec<serde_json::Value> = team
.persona_ids
.iter()
.filter_map(|id| {
personas.iter().find(|p| p.id == *id).map(|p| {
serde_json::json!({
"displayName": p.display_name,
"systemPrompt": p.system_prompt,
"avatarUrl": p.avatar_url,
})
})
})
.collect();
let mut missing_persona_ids = Vec::new();
let mut resolved_personas = Vec::with_capacity(team.persona_ids.len());
for persona_id in &team.persona_ids {
let Some(persona) = personas
.iter()
.find(|candidate| candidate.id == *persona_id)
else {
missing_persona_ids.push(persona_id.clone());
continue;
};
resolved_personas.push(serde_json::json!({
"displayName": persona.display_name,
"systemPrompt": persona.system_prompt,
"avatarUrl": persona.avatar_url,
}));
}
if !missing_persona_ids.is_empty() {
return Err(format!(
"Team {} references missing personas: {}. Repair the team before exporting.",
team.name,
missing_persona_ids.join(", ")
));
}
let map = serde_json::json!({
"version": 1,
@@ -219,6 +232,7 @@ mod tests {
model: None,
name_pool: Vec::new(),
is_builtin: false,
is_active: true,
created_at: "2026-03-20T00:00:00Z".to_string(),
updated_at: "2026-03-20T00:00:00Z".to_string(),
}
@@ -250,18 +264,19 @@ mod tests {
}
#[test]
fn encode_skips_missing_personas() {
fn encode_errors_for_missing_personas() {
let t = TeamRecord {
persona_ids: vec!["p1".to_string(), "missing".to_string()],
..team("t1", "Team")
};
let personas = vec![persona("p1", "Alice", "prompt")];
let bytes = encode_team_json(&t, &personas).unwrap();
let parsed = parse_team_json(&bytes).unwrap();
let err = encode_team_json(&t, &personas).unwrap_err();
assert_eq!(parsed.personas.len(), 1);
assert_eq!(parsed.personas[0].display_name, "Alice");
assert_eq!(
err,
"Team Team references missing personas: missing. Repair the team before exporting."
);
}
#[test]

View File

@@ -33,6 +33,8 @@ pub struct PersonaRecord {
pub name_pool: Vec<String>,
#[serde(default)]
pub is_builtin: bool,
#[serde(default = "default_record_active")]
pub is_active: bool,
pub created_at: String,
pub updated_at: String,
}
@@ -343,3 +345,33 @@ fn default_agent_parallelism() -> u32 {
fn default_start_on_app_launch() -> bool {
true
}
fn default_record_active() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::PersonaRecord;
#[test]
fn persona_record_defaults_active_when_field_is_missing() {
let record: PersonaRecord = serde_json::from_str(
r#"{
"id": "builtin:solo",
"display_name": "Solo",
"avatar_url": null,
"system_prompt": "Prompt",
"created_at": "2026-03-19T00:00:00Z",
"updated_at": "2026-03-19T00:00:00Z"
}"#,
)
.expect("legacy persona payload should deserialize");
assert!(record.is_active);
assert!(!record.is_builtin);
assert_eq!(record.provider, None);
assert_eq!(record.model, None);
assert!(record.name_pool.is_empty());
}
}

View File

@@ -25,6 +25,7 @@ import {
deletePersona,
exportPersonaToJson,
listPersonas,
setPersonaActive,
updatePersona,
} from "@/shared/api/tauriPersonas";
import { setManagedAgentStartOnAppLaunch } from "@/shared/api/tauriManagedAgents";
@@ -242,6 +243,21 @@ export function useDeletePersonaMutation() {
});
}
export function useSetPersonaActiveMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, active }: { id: string; active: boolean }) =>
setPersonaActive(id, active),
onSettled: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: personasQueryKey }),
queryClient.invalidateQueries({ queryKey: teamsQueryKey }),
]);
},
});
}
export function useStartManagedAgentMutation() {
const queryClient = useQueryClient();

View File

@@ -0,0 +1,157 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getCatalogPersonas,
getCatalogSelectionState,
getPersonaLabelsById,
getPersonaLibraryState,
isCatalogPersonaSelected,
} from "./catalog.ts";
function createPersona(id, displayName, overrides = {}) {
return {
id,
displayName,
avatarUrl: overrides.avatarUrl ?? null,
systemPrompt: overrides.systemPrompt ?? `${displayName} prompt`,
provider: overrides.provider ?? null,
model: overrides.model ?? null,
isBuiltIn: overrides.isBuiltIn ?? false,
isActive: overrides.isActive ?? true,
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00Z",
};
}
test("getCatalogPersonas keeps built-ins visible whether selected or not", () => {
const personas = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
createPersona("builtin:ralph", "Ralph", {
isBuiltIn: true,
isActive: true,
}),
createPersona("custom:builder", "Builder"),
];
assert.deepEqual(
getCatalogPersonas(personas).map((persona) => persona.id),
["builtin:ralph", "builtin:solo"],
);
});
test("getCatalogSelectionState keeps built-in selection rules in one place", () => {
const personas = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
createPersona("builtin:ralph", "Ralph", {
isBuiltIn: true,
isActive: true,
}),
createPersona("custom:builder", "Builder"),
];
const state = getCatalogSelectionState(personas);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:ralph", "builtin:solo"],
);
assert.deepEqual(
state.selectedCatalogPersonas.map((persona) => persona.id),
["builtin:ralph"],
);
assert.deepEqual(
state.unselectedCatalogPersonas.map((persona) => persona.id),
["builtin:solo"],
);
});
test("getCatalogPersonas keeps chooser order stable when selection changes", () => {
const inactiveFirst = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
createPersona("builtin:ralph", "Ralph", {
isBuiltIn: true,
isActive: true,
}),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: false,
}),
];
const activeFirst = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: true }),
createPersona("builtin:ralph", "Ralph", {
isBuiltIn: true,
isActive: false,
}),
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: true,
}),
];
assert.deepEqual(
getCatalogPersonas(inactiveFirst).map((persona) => persona.id),
getCatalogPersonas(activeFirst).map((persona) => persona.id),
);
});
test("isCatalogPersonaSelected only treats active built-ins as selected", () => {
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:reviewer", "Reviewer", {
isBuiltIn: true,
isActive: true,
}),
),
true,
);
assert.equal(
isCatalogPersonaSelected(
createPersona("builtin:solo", "Solo", {
isBuiltIn: true,
isActive: false,
}),
),
false,
);
assert.equal(
isCatalogPersonaSelected(createPersona("custom:builder", "Builder")),
false,
);
});
test("getPersonaLabelsById keeps every returned persona addressable", () => {
const personas = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
createPersona("custom:builder", "Builder"),
];
assert.deepEqual(getPersonaLabelsById(personas), {
"builtin:solo": "Solo",
"custom:builder": "Builder",
});
});
test("getPersonaLibraryState keeps the working library and full catalog in one place", () => {
const personas = [
createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }),
createPersona("builtin:ralph", "Ralph", {
isBuiltIn: true,
isActive: true,
}),
createPersona("custom:builder", "Builder"),
];
const state = getPersonaLibraryState(personas);
assert.deepEqual(
state.libraryPersonas.map((persona) => persona.id),
["builtin:ralph", "custom:builder"],
);
assert.deepEqual(
state.catalogPersonas.map((persona) => persona.id),
["builtin:ralph", "builtin:solo"],
);
assert.equal(state.personaLabelsById["builtin:solo"], "Solo");
});

View File

@@ -0,0 +1,64 @@
import type { AgentPersona } from "@/shared/api/types";
export type CatalogSelectionState = {
catalogPersonas: AgentPersona[];
selectedCatalogPersonas: AgentPersona[];
unselectedCatalogPersonas: AgentPersona[];
};
export type PersonaLibraryState = {
catalogPersonas: AgentPersona[];
libraryPersonas: AgentPersona[];
personaLabelsById: Record<string, string>;
};
export function isPersonaActive(persona: AgentPersona) {
return !persona.isBuiltIn || persona.isActive;
}
export function getActivePersonas(personas: readonly AgentPersona[]) {
return personas.filter(isPersonaActive);
}
export function getCatalogPersonas(personas: readonly AgentPersona[]) {
return personas
.filter((persona) => persona.isBuiltIn)
.sort((left, right) => left.displayName.localeCompare(right.displayName));
}
export function isCatalogPersonaSelected(persona: AgentPersona) {
return persona.isBuiltIn && persona.isActive;
}
export function getCatalogSelectionState(
personas: readonly AgentPersona[],
): CatalogSelectionState {
const catalogPersonas = getCatalogPersonas(personas);
return {
catalogPersonas,
selectedCatalogPersonas: catalogPersonas.filter(isCatalogPersonaSelected),
unselectedCatalogPersonas: catalogPersonas.filter(
(persona) => !isCatalogPersonaSelected(persona),
),
};
}
export function getPersonaLabelsById(personas: readonly AgentPersona[]) {
return Object.fromEntries(
personas.map((persona) => [persona.id, persona.displayName]),
);
}
export function getPersonaLibraryState(
personas: readonly AgentPersona[],
): PersonaLibraryState {
const libraryPersonas = getActivePersonas(personas);
const { catalogPersonas } = getCatalogSelectionState(personas);
return {
catalogPersonas,
libraryPersonas,
personaLabelsById: getPersonaLabelsById(personas),
};
}

View File

@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
emptyResolvedTeamPersonas,
getUsableTeams,
resolveTeamPersonas,
} from "./teamPersonas.ts";
function createPersona(id, displayName) {
return {
id,
displayName,
avatarUrl: null,
systemPrompt: `${displayName} prompt`,
provider: null,
model: null,
isBuiltIn: false,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
function createTeam(id, personaIds) {
return {
id,
name: `Team ${id}`,
description: null,
personaIds,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("emptyResolvedTeamPersonas starts empty but complete", () => {
assert.deepEqual(emptyResolvedTeamPersonas(), {
hasMissingPersonas: false,
isComplete: true,
isUsable: false,
missingPersonaCount: 0,
missingPersonaIds: [],
resolvedPersonaIds: [],
resolvedPersonas: [],
});
});
test("resolveTeamPersonas preserves team order and marks complete teams usable", () => {
const personas = [
createPersona("persona-1", "Solo"),
createPersona("persona-2", "Ralph"),
];
const resolution = resolveTeamPersonas(
createTeam("team-1", ["persona-2", "persona-1"]),
personas,
);
assert.equal(resolution.isComplete, true);
assert.equal(resolution.isUsable, true);
assert.deepEqual(resolution.resolvedPersonaIds, ["persona-2", "persona-1"]);
assert.deepEqual(
resolution.resolvedPersonas.map((persona) => persona.displayName),
["Ralph", "Solo"],
);
});
test("resolveTeamPersonas surfaces missing persona ids without dropping the resolved ones", () => {
const personas = [createPersona("persona-1", "Solo")];
const resolution = resolveTeamPersonas(
createTeam("team-2", ["persona-1", "missing"]),
personas,
);
assert.equal(resolution.hasMissingPersonas, true);
assert.equal(resolution.isComplete, false);
assert.equal(resolution.isUsable, false);
assert.equal(resolution.missingPersonaCount, 1);
assert.deepEqual(resolution.missingPersonaIds, ["missing"]);
assert.deepEqual(resolution.resolvedPersonaIds, ["persona-1"]);
});
test("getUsableTeams keeps only fully-resolved teams with at least one persona", () => {
const personas = [createPersona("persona-1", "Solo")];
const teams = [
createTeam("team-empty", []),
createTeam("team-missing", ["missing"]),
createTeam("team-ready", ["persona-1"]),
];
assert.deepEqual(
getUsableTeams(teams, personas).map((team) => team.id),
["team-ready"],
);
});

View File

@@ -0,0 +1,74 @@
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
export type ResolvedTeamPersonas = {
hasMissingPersonas: boolean;
isComplete: boolean;
isUsable: boolean;
missingPersonaCount: number;
missingPersonaIds: string[];
resolvedPersonaIds: string[];
resolvedPersonas: AgentPersona[];
};
export function emptyResolvedTeamPersonas(): ResolvedTeamPersonas {
return {
hasMissingPersonas: false,
isComplete: true,
isUsable: false,
missingPersonaCount: 0,
missingPersonaIds: [],
resolvedPersonaIds: [],
resolvedPersonas: [],
};
}
export function isResolvedTeamUsable(
resolution: Pick<ResolvedTeamPersonas, "isComplete" | "resolvedPersonaIds">,
) {
return resolution.isComplete && resolution.resolvedPersonaIds.length > 0;
}
export function getUsableTeams(
teams: readonly AgentTeam[],
personas: AgentPersona[],
) {
return teams.filter((team) =>
isResolvedTeamUsable(resolveTeamPersonas(team, personas)),
);
}
export function resolveTeamPersonas(
team: Pick<AgentTeam, "personaIds">,
personas: AgentPersona[],
): ResolvedTeamPersonas {
const personasById = new Map(
personas.map((persona) => [persona.id, persona]),
);
const resolvedPersonas: AgentPersona[] = [];
const resolvedPersonaIds: string[] = [];
const missingPersonaIds: string[] = [];
for (const personaId of team.personaIds) {
const persona = personasById.get(personaId);
if (persona) {
resolvedPersonas.push(persona);
resolvedPersonaIds.push(persona.id);
continue;
}
missingPersonaIds.push(personaId);
}
const missingPersonaCount = missingPersonaIds.length;
return {
hasMissingPersonas: missingPersonaCount > 0,
isComplete: missingPersonaCount === 0,
isUsable: missingPersonaCount === 0 && resolvedPersonaIds.length > 0,
missingPersonaCount,
missingPersonaIds,
resolvedPersonaIds,
resolvedPersonas,
};
}

View File

@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import test from "node:test";
import { pickQuickBotPersonas } from "./useBotRecents.ts";
function createPersona(id, displayName) {
return {
id,
displayName,
avatarUrl: null,
systemPrompt: `${displayName} prompt`,
provider: null,
model: null,
isBuiltIn: true,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("pickQuickBotPersonas prefers recents before defaults", () => {
const personas = [
createPersona("builtin:solo", "Solo"),
createPersona("builtin:ralph", "Ralph"),
createPersona("builtin:reviewer", "Reviewer"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, ["builtin:reviewer"]).map(
(persona) => persona.id,
),
["builtin:reviewer", "builtin:solo", "builtin:ralph"],
);
});
test("pickQuickBotPersonas falls back to any active personas when defaults are missing", () => {
const personas = [
createPersona("builtin:reviewer", "Reviewer"),
createPersona("custom:planner", "Planner"),
createPersona("custom:writer", "Writer"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, []).map((persona) => persona.id),
["builtin:reviewer", "custom:planner", "custom:writer"],
);
});
test("pickQuickBotPersonas skips duplicate and missing recents", () => {
const personas = [
createPersona("builtin:solo", "Solo"),
createPersona("builtin:ralph", "Ralph"),
createPersona("builtin:scout", "Scout"),
];
assert.deepEqual(
pickQuickBotPersonas(personas, [
"builtin:solo",
"missing",
"builtin:solo",
"builtin:ralph",
]).map((persona) => persona.id),
["builtin:solo", "builtin:ralph", "builtin:scout"],
);
});

View File

@@ -1,5 +1,7 @@
import * as React from "react";
import type { AgentPersona } from "@/shared/api/types";
const STORAGE_KEY = "sprout:bot-recents";
const MAX_RECENTS = 8;
@@ -7,6 +9,56 @@ const MAX_RECENTS = 8;
// These are resolved to IDs by the consumer.
export const DEFAULT_PERSONA_NAMES = ["Solo", "Ralph", "Scout"] as const;
export function pickQuickBotPersonas(
personas: readonly AgentPersona[],
recentIds: readonly string[],
maxCount = 3,
) {
if (personas.length === 0) {
return [];
}
const resolved: AgentPersona[] = [];
const addPersona = (persona: AgentPersona | undefined) => {
if (!persona || resolved.some((candidate) => candidate.id === persona.id)) {
return;
}
resolved.push(persona);
};
for (const id of recentIds) {
if (resolved.length >= maxCount) {
break;
}
addPersona(personas.find((persona) => persona.id === id));
}
for (const name of DEFAULT_PERSONA_NAMES) {
if (resolved.length >= maxCount) {
break;
}
addPersona(
personas.find(
(persona) => persona.displayName.toLowerCase() === name.toLowerCase(),
),
);
}
for (const persona of personas) {
if (resolved.length >= maxCount) {
break;
}
addPersona(persona);
}
return resolved;
}
export function useBotRecents(): {
recentIds: string[];
pushRecent: (personaId: string) => void;

View File

@@ -6,6 +6,10 @@ import {
useCreateChannelManagedAgentsMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
import {
emptyResolvedTeamPersonas,
resolveTeamPersonas,
} from "@/features/agents/lib/teamPersonas";
import {
collectProviderWarnings,
resolvePersonaProvider,
@@ -38,15 +42,6 @@ type AddTeamToChannelDialogProps = {
) => void;
};
function resolvePersonas(
personaIds: string[],
personas: AgentPersona[],
): AgentPersona[] {
return personaIds
.map((id) => personas.find((p) => p.id === id))
.filter((p): p is AgentPersona => p !== undefined);
}
export function AddTeamToChannelDialog({
team,
personas,
@@ -73,7 +68,13 @@ export function AddTeamToChannelDialog({
const providers = providersQuery.data ?? [];
const defaultProvider = providers[0] ?? null;
const resolved = team ? resolvePersonas(team.personaIds, personas) : [];
const teamPersonaResolution = React.useMemo(
() =>
team ? resolveTeamPersonas(team, personas) : emptyResolvedTeamPersonas(),
[team, personas],
);
const resolved = teamPersonaResolution.resolvedPersonas;
const missingPersonaCount = teamPersonaResolution.missingPersonaCount;
// Surface warnings when a persona's preferred provider is unavailable.
// This dialog has no provider selector, so the fallback is always
@@ -233,6 +234,15 @@ export function AddTeamToChannelDialog({
</select>
</div>
{missingPersonaCount > 0 ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
This team references {missingPersonaCount} persona
{missingPersonaCount === 1 ? "" : "s"} that{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in My
Agents. Add them back or edit the team before deploying.
</p>
) : null}
{!defaultProvider && !providersQuery.isLoading ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
No ACP providers found. Make sure an agent runtime (e.g. Goose)
@@ -280,6 +290,7 @@ export function AddTeamToChannelDialog({
!selectedChannel ||
!defaultProvider ||
resolved.length === 0 ||
missingPersonaCount > 0 ||
channelsQuery.isLoading ||
providersQuery.isLoading ||
deployMutation.isPending

View File

@@ -12,7 +12,7 @@ export function AgentsScreen() {
return (
<>
<ChatHeader
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
description="Choose personas from Persona Catalog, create local ACP workers, and monitor the relay-visible agent directory."
mode="agents"
title="Agents"
/>

View File

@@ -15,10 +15,12 @@ import {
usePersonasQuery,
useRelayAgentsQuery,
useSetManagedAgentStartOnAppLaunchMutation,
useSetPersonaActiveMutation,
useStartManagedAgentMutation,
useStopManagedAgentMutation,
useUpdatePersonaMutation,
} from "@/features/agents/hooks";
import { getPersonaLibraryState } from "@/features/agents/lib/catalog";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { sendChannelMessage } from "@/shared/api/tauri";
@@ -41,6 +43,7 @@ import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { BatchImportDialog } from "./BatchImportDialog";
import { CreateAgentDialog } from "./CreateAgentDialog";
import { ManagedAgentsSection } from "./ManagedAgentsSection";
import { PersonaCatalogDialog } from "./PersonaCatalogDialog";
import { PersonaDialog } from "./PersonaDialog";
import { PersonaDeleteDialog } from "./PersonaDeleteDialog";
import { PersonasSection } from "./PersonasSection";
@@ -51,14 +54,16 @@ import { TeamDialog } from "./TeamDialog";
import { TeamImportDialog } from "./TeamImportDialog";
import { TeamsSection } from "./TeamsSection";
import { TokenRevealDialog } from "./TokenRevealDialog";
import {
createPersonaDialogState,
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
type PersonaDialogState,
} from "./personaDialogState";
import { useTeamActions } from "./useTeamActions";
type PersonaDialogState = {
description: string;
initialValues: CreatePersonaInput | UpdatePersonaInput;
submitLabel: string;
title: string;
} | null;
type PersonaFeedbackSurface = "catalog" | "library";
export function AgentsView() {
const queryClient = useQueryClient();
@@ -75,10 +80,11 @@ export function AgentsView() {
const createPersonaMutation = useCreatePersonaMutation();
const updatePersonaMutation = useUpdatePersonaMutation();
const deletePersonaMutation = useDeletePersonaMutation();
const setPersonaActiveMutation = useSetPersonaActiveMutation();
const exportPersonaJsonMutation = useExportPersonaJsonMutation();
const [isCreateOpen, setIsCreateOpen] = React.useState(false);
const [personaDialogState, setPersonaDialogState] =
React.useState<PersonaDialogState>(null);
React.useState<PersonaDialogState | null>(null);
const [personaToDelete, setPersonaToDelete] =
React.useState<AgentPersona | null>(null);
const [agentToAddToChannel, setAgentToAddToChannel] =
@@ -95,6 +101,14 @@ export function AgentsView() {
const [actionErrorMessage, setActionErrorMessage] = React.useState<
string | null
>(null);
const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState<
string | null
>(null);
const [personaErrorMessage, setPersonaErrorMessage] = React.useState<
string | null
>(null);
const [personaFeedbackSurface, setPersonaFeedbackSurface] =
React.useState<PersonaFeedbackSurface>("library");
const teamActions = useTeamActions(
{ setActionNoticeMessage, setActionErrorMessage },
@@ -106,6 +120,7 @@ export function AgentsView() {
const [batchImportResult, setBatchImportResult] =
React.useState<ParsePersonaFilesResult | null>(null);
const [batchImportFileName, setBatchImportFileName] = React.useState("");
const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false);
const managedAgents = React.useMemo(
() =>
[...(managedAgentsQuery.data ?? [])].sort((left, right) => {
@@ -121,11 +136,8 @@ export function AgentsView() {
[managedAgentsQuery.data],
);
const personas = personasQuery.data ?? [];
const personaLabelsById = React.useMemo(
() =>
Object.fromEntries(
personas.map((persona) => [persona.id, persona.displayName]),
),
const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo(
() => getPersonaLibraryState(personas),
[personas],
);
const [logAgentPubkey, setLogAgentPubkey] = React.useState<string | null>(
@@ -173,9 +185,21 @@ export function AgentsView() {
}
}, [managedAgents, logAgentPubkey]);
async function handleStart(pubkey: string) {
function clearActionFeedback() {
setActionNoticeMessage(null);
setActionErrorMessage(null);
}
function clearPersonaFeedback(
surface: PersonaFeedbackSurface = personaFeedbackSurface,
) {
setPersonaFeedbackSurface(surface);
setPersonaNoticeMessage(null);
setPersonaErrorMessage(null);
}
async function handleStart(pubkey: string) {
clearActionFeedback();
try {
await startMutation.mutateAsync(pubkey);
@@ -187,8 +211,7 @@ export function AgentsView() {
}
async function handleStop(pubkey: string) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearActionFeedback();
try {
const agent = managedAgents.find((a) => a.pubkey === pubkey);
@@ -219,8 +242,7 @@ export function AgentsView() {
}
async function handleDelete(pubkey: string) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearActionFeedback();
try {
// For remote agents, send !shutdown before deleting to avoid orphaning.
@@ -290,8 +312,7 @@ export function AgentsView() {
pubkey: string,
startOnAppLaunch: boolean,
) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearActionFeedback();
try {
const updated = await startOnLaunchMutation.mutateAsync({
@@ -313,8 +334,7 @@ export function AgentsView() {
}
async function handleMintToken(pubkey: string, name: string) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearActionFeedback();
try {
const result = await mintTokenMutation.mutateAsync({
@@ -335,40 +355,66 @@ export function AgentsView() {
async function handlePersonaSubmit(
input: CreatePersonaInput | UpdatePersonaInput,
) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearPersonaFeedback("library");
try {
if ("id" in input) {
await updatePersonaMutation.mutateAsync(input);
setActionNoticeMessage(`Updated ${input.displayName}.`);
setPersonaNoticeMessage(`Updated ${input.displayName}.`);
} else {
await createPersonaMutation.mutateAsync(input);
setActionNoticeMessage(`Created ${input.displayName}.`);
setPersonaNoticeMessage(`Created ${input.displayName}.`);
}
setPersonaDialogState(null);
} catch (error) {
setActionErrorMessage(
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to save persona.",
);
}
}
async function handleDeletePersona(persona: AgentPersona) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearPersonaFeedback("library");
try {
await deletePersonaMutation.mutateAsync(persona.id);
setActionNoticeMessage(`Deleted ${persona.displayName}.`);
setPersonaNoticeMessage(`Deleted ${persona.displayName}.`);
setPersonaToDelete(null);
} catch (error) {
setActionErrorMessage(
setPersonaErrorMessage(
error instanceof Error ? error.message : "Failed to delete persona.",
);
}
}
async function handleSetPersonaActive(
persona: AgentPersona,
active: boolean,
surface: PersonaFeedbackSurface,
) {
clearPersonaFeedback(surface);
try {
await setPersonaActiveMutation.mutateAsync({
id: persona.id,
active,
});
setPersonaNoticeMessage(
active
? `Selected ${persona.displayName} for My Agents.`
: `Deselected ${persona.displayName} from My Agents.`,
);
} catch (error) {
setPersonaErrorMessage(
error instanceof Error
? error.message
: active
? "Failed to select persona for My Agents."
: "Failed to deselect persona from My Agents.",
);
}
}
function handleAddedToChannel(
channel: Channel,
result: AttachManagedAgentToChannelResult,
@@ -397,32 +443,19 @@ export function AgentsView() {
fileBytes: number[],
fileName: string,
) {
setActionNoticeMessage(null);
setActionErrorMessage(null);
clearPersonaFeedback("library");
try {
const result = await parsePersonaFiles(fileBytes, fileName);
if (isSingleItemFile(fileBytes) && result.personas.length === 1) {
const p = result.personas[0];
setPersonaDialogState({
title: `Import ${p.displayName}`,
description: "Review and save this imported persona.",
submitLabel: "Create persona",
initialValues: {
displayName: p.displayName,
avatarUrl: p.avatarDataUrl ?? "",
systemPrompt: p.systemPrompt,
provider: p.provider ?? undefined,
model: p.model ?? undefined,
},
});
setPersonaDialogState(importPersonaDialogState(result.personas[0]));
} else if (result.personas.length > 0) {
setBatchImportResult(result);
setBatchImportFileName(fileName);
} else {
setActionErrorMessage("No valid personas found in file.");
setPersonaErrorMessage("No valid personas found in file.");
}
} catch (err) {
setActionErrorMessage(
setPersonaErrorMessage(
err instanceof Error ? err.message : "Failed to parse persona file.",
);
}
@@ -437,6 +470,7 @@ export function AgentsView() {
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
deletePersonaMutation.isPending ||
setPersonaActiveMutation.isPending ||
exportPersonaJsonMutation.isPending ||
teamActions.exportTeamJsonMutation.isPending ||
teamActions.createTeamMutation.isPending ||
@@ -449,84 +483,68 @@ export function AgentsView() {
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6">
<div className="flex flex-col gap-6">
<PersonasSection
canChooseCatalog={catalogPersonas.length > 0}
error={
personasQuery.error instanceof Error
? personasQuery.error
: null
}
feedbackErrorMessage={
personaFeedbackSurface === "library"
? personaErrorMessage
: null
}
feedbackNoticeMessage={
personaFeedbackSurface === "library"
? personaNoticeMessage
: null
}
isLoading={personasQuery.isLoading}
isPending={
createPersonaMutation.isPending ||
updatePersonaMutation.isPending ||
deletePersonaMutation.isPending
deletePersonaMutation.isPending ||
setPersonaActiveMutation.isPending ||
exportPersonaJsonMutation.isPending
}
onCreate={() => {
setActionNoticeMessage(null);
setActionErrorMessage(null);
setPersonaDialogState({
title: "Create persona",
description:
"Save a reusable role, prompt, and optional avatar for future agent deployments.",
submitLabel: "Create persona",
initialValues: {
displayName: "",
avatarUrl: "",
systemPrompt: "",
provider: undefined,
model: undefined,
},
});
onChooseCatalog={() => {
clearPersonaFeedback("catalog");
setIsCatalogDialogOpen(true);
}}
onCreate={() => {
clearPersonaFeedback("library");
setPersonaDialogState(createPersonaDialogState());
}}
onDelete={(persona) => {
clearPersonaFeedback("library");
setPersonaToDelete(persona);
}}
onDeactivate={(persona) => {
void handleSetPersonaActive(persona, false, "library");
}}
onDelete={setPersonaToDelete}
onDuplicate={(persona) => {
setActionNoticeMessage(null);
setActionErrorMessage(null);
setPersonaDialogState({
title: `Duplicate ${persona.displayName}`,
description:
"Create a new persona by copying this template and adjusting it as needed.",
submitLabel: "Create persona",
initialValues: {
displayName: `${persona.displayName} copy`,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
provider: persona.provider ?? undefined,
model: persona.model ?? undefined,
},
});
clearPersonaFeedback("library");
setPersonaDialogState(duplicatePersonaDialogState(persona));
}}
onEdit={(persona) => {
setActionNoticeMessage(null);
setActionErrorMessage(null);
setPersonaDialogState({
title: `Edit ${persona.displayName}`,
description:
"Update this saved persona. New deployments will use the updated values.",
submitLabel: "Save changes",
initialValues: {
id: persona.id,
displayName: persona.displayName,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
provider: persona.provider ?? undefined,
model: persona.model ?? undefined,
},
});
clearPersonaFeedback("library");
setPersonaDialogState(editPersonaDialogState(persona));
}}
onImportFile={(fileBytes, fileName) => {
void handlePersonaImportFile(fileBytes, fileName);
}}
onExport={(persona) => {
clearPersonaFeedback("library");
exportPersonaJsonMutation.mutate(persona.id, {
onSuccess: (saved) => {
if (saved) {
setActionNoticeMessage(
setPersonaNoticeMessage(
`Exported ${persona.displayName}.`,
);
}
},
onError: (error) => {
setActionErrorMessage(
setPersonaErrorMessage(
error instanceof Error
? error.message
: "Failed to export persona.",
@@ -534,7 +552,7 @@ export function AgentsView() {
},
});
}}
personas={personas}
personas={libraryPersonas}
/>
<TeamsSection
@@ -556,7 +574,7 @@ export function AgentsView() {
onExport={teamActions.handleExportTeam}
onImportFile={teamActions.handleImportFile}
onAddToChannel={teamActions.setTeamToAddToChannel}
personas={personas}
personas={libraryPersonas}
teams={teamActions.teams}
/>
@@ -693,6 +711,28 @@ export function AgentsView() {
open={personaToDelete !== null}
persona={personaToDelete}
/>
<PersonaCatalogDialog
error={
personasQuery.error instanceof Error ? personasQuery.error : null
}
feedbackErrorMessage={
personaFeedbackSurface === "catalog" ? personaErrorMessage : null
}
feedbackNoticeMessage={
personaFeedbackSurface === "catalog" ? personaNoticeMessage : null
}
isLoading={personasQuery.isLoading}
isPending={setPersonaActiveMutation.isPending}
onClearFeedback={() => {
clearPersonaFeedback("catalog");
}}
onOpenChange={setIsCatalogDialogOpen}
onSelectPersona={(persona, active) => {
void handleSetPersonaActive(persona, active, "catalog");
}}
open={isCatalogDialogOpen}
personas={catalogPersonas}
/>
<TeamDialog
description={teamActions.teamDialogState?.description ?? ""}
error={
@@ -714,7 +754,7 @@ export function AgentsView() {
}}
onSubmit={teamActions.handleTeamSubmit}
open={teamActions.teamDialogState !== null}
personas={personas}
personas={libraryPersonas}
submitLabel={teamActions.teamDialogState?.submitLabel ?? "Save"}
title={teamActions.teamDialogState?.title ?? "Team"}
/>
@@ -738,14 +778,15 @@ export function AgentsView() {
}
}}
open={teamActions.teamToAddToChannel !== null}
personas={personas}
personas={libraryPersonas}
team={teamActions.teamToAddToChannel}
/>
<BatchImportDialog
fileName={batchImportFileName}
onComplete={(count) => {
clearPersonaFeedback("library");
setBatchImportResult(null);
setActionNoticeMessage(
setPersonaNoticeMessage(
`Imported ${count} persona${count !== 1 ? "s" : ""}.`,
);
void queryClient.invalidateQueries({ queryKey: personasQueryKey });

View File

@@ -0,0 +1,33 @@
import { Plus } from "lucide-react";
import { Button } from "@/shared/ui/button";
type CreateNewButtonProps = {
ariaLabel?: string;
disabled?: boolean;
label?: string;
onClick: () => void;
variant?: "default" | "outline";
};
export function CreateNewButton({
ariaLabel,
disabled = false,
label = "New",
onClick,
variant = "default",
}: CreateNewButtonProps) {
return (
<Button
aria-label={ariaLabel}
disabled={disabled}
onClick={onClick}
size="sm"
type="button"
variant={variant}
>
<Plus className="h-4 w-4" />
{label}
</Button>
);
}

View File

@@ -1,9 +1,6 @@
import { Plus } from "lucide-react";
import type { ManagedAgent, PresenceLookup } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { CreateNewButton } from "./CreateNewButton";
import { ManagedAgentRow } from "./ManagedAgentRow";
export function ManagedAgentsSection({
@@ -60,20 +57,11 @@ export function ManagedAgentsSection({
Agent profiles and process state — local and remote.
</p>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Create agent"
onClick={onCreate}
size="icon"
type="button"
variant="ghost"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create agent</TooltipContent>
</Tooltip>
<CreateNewButton
ariaLabel="Create agent"
label="Agent"
onClick={onCreate}
/>
</div>
{isLoading ? (

View File

@@ -0,0 +1,155 @@
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { promptPreview } from "@/shared/lib/promptPreview";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/shared/ui/sheet";
import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge";
import {
getPersonaCatalogDetailSelectionCopy,
getPersonaCatalogSelectionAriaLabel,
} from "./personaLibraryCopy";
type PersonaCatalogDetailsSheetProps = {
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isPending: boolean;
onOpenChange: (open: boolean) => void;
onTogglePersona: (persona: AgentPersona) => void;
open: boolean;
persona: AgentPersona | null;
};
export function PersonaCatalogDetailsSheet({
feedbackErrorMessage,
feedbackNoticeMessage,
isPending,
onOpenChange,
onTogglePersona,
open,
persona,
}: PersonaCatalogDetailsSheetProps) {
const preview = persona ? promptPreview(persona.systemPrompt) : "";
const isSelected = persona ? isCatalogPersonaSelected(persona) : false;
const selectionCopy = getPersonaCatalogDetailSelectionCopy(isSelected);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="w-full overflow-y-auto sm:max-w-xl"
data-testid="persona-catalog-details-sheet"
>
{persona ? (
<div className="space-y-6 pr-4">
<SheetHeader className="border-b border-border/60 pb-4 pr-10">
<div className="flex items-start gap-3">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-12 w-12 rounded-xl text-sm"
label={persona.displayName}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SheetTitle className="truncate text-xl">
{persona.displayName}
</SheetTitle>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
<SheetDescription className="mt-2">
{preview || "No summary available."}
</SheetDescription>
</div>
</div>
</SheetHeader>
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className="w-full rounded-xl border border-border/70 bg-card/70 p-4 text-left transition-colors hover:border-primary/40 hover:bg-primary/[0.03]"
data-state={isSelected ? "selected" : "available"}
data-testid={`persona-catalog-detail-selection-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-sm font-semibold tracking-tight"
data-testid="persona-catalog-detail-selection-title"
>
{selectionCopy.title}
</p>
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="persona-catalog-detail-selection-description"
>
{selectionCopy.description}
</p>
</div>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
</button>
{feedbackNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{feedbackErrorMessage}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Type
</p>
<p className="mt-2 text-sm font-medium">Built-in persona</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred model
</p>
<p className="mt-2 text-sm font-medium">
{persona.model ?? "Use app default"}
</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred provider
</p>
<p className="mt-2 text-sm font-medium">
{persona.provider ?? "Use app default"}
</p>
</div>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
System prompt
</p>
<pre className="mt-3 whitespace-pre-wrap break-words font-sans text-sm leading-6 text-foreground">
{persona.systemPrompt}
</pre>
</div>
</div>
) : null}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,81 @@
import * as React from "react";
import type { AgentPersona } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import { PersonaCatalogSurface } from "./PersonaCatalogSurface";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogDialogProps = {
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onClearFeedback: () => void;
onOpenChange: (open: boolean) => void;
onSelectPersona: (persona: AgentPersona, active: boolean) => void;
open: boolean;
personas: AgentPersona[];
};
export function PersonaCatalogDialog({
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onClearFeedback,
onOpenChange,
onSelectPersona,
open,
personas,
}: PersonaCatalogDialogProps) {
const contentRef = React.useRef<HTMLDivElement | null>(null);
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<ChooserDialogContent
className="max-w-5xl"
data-testid="persona-catalog-dialog"
description={personaCatalogCopy.dialogDescription}
footer={
<Button
data-testid="persona-catalog-dialog-done"
onClick={() => onOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Done
</Button>
}
footerClassName="justify-end gap-2"
footerTestId="persona-catalog-dialog-footer"
headerTestId="persona-catalog-dialog-header"
onOpenAutoFocus={(event) => {
event.preventDefault();
contentRef.current?.focus();
}}
ref={contentRef}
scrollAreaTestId="persona-catalog-dialog-scroll-area"
tabIndex={-1}
title={personaCatalogCopy.dialogTitle}
>
<PersonaCatalogSurface
error={error}
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isLoading={isLoading}
isPending={isPending}
onClearFeedback={onClearFeedback}
onSelectPersona={onSelectPersona}
personas={personas}
showHeader={false}
/>
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,185 @@
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import type { AgentPersona } from "@/shared/api/types";
import { promptPreview } from "@/shared/lib/promptPreview";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge";
import { PersonaIdentity } from "./PersonaIdentity";
import {
getPersonaCatalogSelectionActionCopy,
getPersonaCatalogSelectionAriaLabel,
personaCatalogCopy,
} from "./personaLibraryCopy";
type PersonaCatalogSectionProps = {
emptyDescription?: string;
emptyTitle?: string;
error: Error | null;
feedbackErrorMessage?: string | null;
feedbackNoticeMessage?: string | null;
isLoading: boolean;
isPending: boolean;
onTogglePersona: (persona: AgentPersona) => void;
onViewDetails: (persona: AgentPersona) => void;
personas: AgentPersona[];
showHeader?: boolean;
};
export function PersonaCatalogSection({
emptyDescription = personaCatalogCopy.emptyCatalogDescription,
emptyTitle = personaCatalogCopy.emptyCatalogTitle,
error,
feedbackErrorMessage = null,
feedbackNoticeMessage = null,
isLoading,
isPending,
onTogglePersona,
onViewDetails,
personas,
showHeader = true,
}: PersonaCatalogSectionProps) {
return (
<section className="space-y-4" data-testid="agents-persona-catalog">
{showHeader ? (
<div>
<h3 className="text-sm font-semibold tracking-tight">
{personaCatalogCopy.title}
</h3>
<p className="text-sm text-muted-foreground">
{personaCatalogCopy.description}
</p>
</div>
) : null}
{isLoading ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{["first", "second", "third", "fourth"].map((key) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-3 shadow-sm"
key={key}
>
<div className="flex items-center gap-2.5">
<Skeleton className="h-8 w-8 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-20" />
</div>
</div>
</div>
))}
</div>
) : null}
{!isLoading && personas.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const preview = promptPreview(persona.systemPrompt);
const isSelected = isCatalogPersonaSelected(persona);
return (
<div
className={cn(
"group relative flex flex-col gap-4 rounded-xl border bg-card/80 p-3 shadow-sm transition-colors",
isPending
? "cursor-not-allowed opacity-70"
: "cursor-pointer hover:border-primary/40 hover:bg-primary/[0.03]",
isSelected
? "border-primary/40 bg-primary/[0.04]"
: "border-border/70",
)}
data-testid={`persona-catalog-card-${persona.id}`}
data-state={isSelected ? "selected" : "available"}
key={persona.id}
>
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className="absolute inset-0 z-0 rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-testid={`persona-catalog-card-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
/>
<div className="pointer-events-none relative z-10 flex h-full flex-col gap-4">
<div className="flex items-start justify-between gap-3">
<PersonaIdentity
className="min-w-0 flex-1"
persona={persona}
showBuiltInBadge={false}
showPromptTooltip={false}
/>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
<p className="min-h-12 text-xs leading-5 text-muted-foreground">
{preview}
</p>
<div className="mt-auto flex items-center justify-between gap-3 border-t border-border/60 pt-3">
<Button
className="pointer-events-auto"
data-testid={`persona-catalog-details-${persona.id}`}
onClick={() => {
onViewDetails(persona);
}}
size="sm"
type="button"
variant="ghost"
>
{personaCatalogCopy.detailsAction}
</Button>
<span className="text-xs font-medium text-muted-foreground">
{getPersonaCatalogSelectionActionCopy(isSelected)}
</span>
</div>
</div>
</div>
);
})}
</div>
) : null}
{!isLoading && personas.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/70 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">{emptyTitle}</p>
<p className="mt-2 text-sm text-muted-foreground">
{emptyDescription}
</p>
</div>
) : null}
{error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error.message}
</p>
) : null}
{feedbackNoticeMessage ? (
<p
className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary"
data-testid="persona-catalog-feedback-notice"
>
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="persona-catalog-feedback-error"
>
{feedbackErrorMessage}
</p>
) : null}
</section>
);
}

View File

@@ -0,0 +1,26 @@
import { cn } from "@/shared/lib/cn";
import { personaCatalogCopy } from "./personaLibraryCopy";
type PersonaCatalogSelectionBadgeProps = {
isActive: boolean;
};
export function PersonaCatalogSelectionBadge({
isActive,
}: PersonaCatalogSelectionBadgeProps) {
return (
<span
className={cn(
"whitespace-nowrap rounded-full px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.14em]",
isActive
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{isActive
? personaCatalogCopy.selectedState
: personaCatalogCopy.availableState}
</span>
);
}

View File

@@ -0,0 +1,78 @@
import * as React from "react";
import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog";
import type { AgentPersona } from "@/shared/api/types";
import { PersonaCatalogDetailsSheet } from "./PersonaCatalogDetailsSheet";
import { PersonaCatalogSection } from "./PersonaCatalogSection";
type PersonaCatalogSurfaceProps = {
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onClearFeedback: () => void;
onSelectPersona: (persona: AgentPersona, active: boolean) => void;
personas: AgentPersona[];
showHeader?: boolean;
};
export function PersonaCatalogSurface({
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onClearFeedback,
onSelectPersona,
personas,
showHeader = true,
}: PersonaCatalogSurfaceProps) {
const [detailPersonaId, setDetailPersonaId] = React.useState<string | null>(
null,
);
const detailPersona = React.useMemo(
() =>
detailPersonaId
? (personas.find((persona) => persona.id === detailPersonaId) ?? null)
: null,
[detailPersonaId, personas],
);
const handleTogglePersona = (persona: AgentPersona) => {
onSelectPersona(persona, !isCatalogPersonaSelected(persona));
};
return (
<>
<PersonaCatalogSection
error={error}
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isLoading={isLoading}
isPending={isPending}
onTogglePersona={handleTogglePersona}
onViewDetails={(persona) => {
onClearFeedback();
setDetailPersonaId(persona.id);
}}
personas={personas}
showHeader={showHeader}
/>
<PersonaCatalogDetailsSheet
feedbackErrorMessage={feedbackErrorMessage}
feedbackNoticeMessage={feedbackNoticeMessage}
isPending={isPending}
onOpenChange={(open) => {
if (!open) {
setDetailPersonaId(null);
}
}}
onTogglePersona={handleTogglePersona}
open={detailPersonaId !== null}
persona={detailPersona}
/>
</>
);
}

View File

@@ -0,0 +1,62 @@
import { Info } from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { promptPreview } from "@/shared/lib/promptPreview";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type PersonaIdentityProps = {
className?: string;
persona: AgentPersona;
showBuiltInBadge?: boolean;
showPromptTooltip?: boolean;
};
export function PersonaIdentity({
className,
persona,
showBuiltInBadge = persona.isBuiltIn,
showPromptTooltip = true,
}: PersonaIdentityProps) {
const preview = promptPreview(persona.systemPrompt);
return (
<div className={className}>
<div className="flex min-w-0 items-center gap-2.5">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-8 w-8 rounded-lg text-xs"
label={persona.displayName}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold tracking-tight">
{persona.displayName}
</p>
{showBuiltInBadge ? (
<span className="whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
) : null}
{showPromptTooltip && preview ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="View system prompt"
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{preview}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { Upload } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { CreateNewButton } from "./CreateNewButton";
import { personaLibraryCopy } from "./personaLibraryCopy";
type PersonaLibraryEntryPointsProps = {
canChooseCatalog?: boolean;
isPending: boolean;
layout: "header" | "empty";
onCreate: () => void;
onChooseCatalog?: () => void;
onImport?: () => void;
};
export function PersonaLibraryEntryPoints({
canChooseCatalog = false,
isPending,
layout,
onCreate,
onChooseCatalog,
onImport,
}: PersonaLibraryEntryPointsProps) {
const isHeader = layout === "header";
const chooseVariant = isHeader ? "outline" : "default";
const createVariant = isHeader ? "default" : "outline";
return (
<>
{canChooseCatalog && onChooseCatalog ? (
<Button
data-testid="open-persona-catalog"
disabled={isPending}
onClick={onChooseCatalog}
size="sm"
type="button"
variant={chooseVariant}
>
{personaLibraryCopy.chooseFromCatalog}
</Button>
) : null}
<CreateNewButton
disabled={isPending}
label={personaLibraryCopy.createNew}
onClick={onCreate}
variant={createVariant}
/>
{!isHeader && onImport ? (
<Button onClick={onImport} size="sm" type="button" variant="outline">
<Upload className="h-4 w-4" />
{personaLibraryCopy.import}
</Button>
) : null}
</>
);
}

View File

@@ -2,18 +2,13 @@ import {
CopyPlus,
Download,
Ellipsis,
Info,
Pencil,
Plus,
Trash2,
Upload,
} from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { promptPreview } from "@/shared/lib/promptPreview";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -21,30 +16,42 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { PersonaIdentity } from "./PersonaIdentity";
import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints";
import { personaLibraryCopy } from "./personaLibraryCopy";
type PersonasSectionProps = {
canChooseCatalog: boolean;
personas: AgentPersona[];
error: Error | null;
feedbackErrorMessage: string | null;
feedbackNoticeMessage: string | null;
isLoading: boolean;
isPending: boolean;
onCreate: () => void;
onChooseCatalog: () => void;
onDuplicate: (persona: AgentPersona) => void;
onEdit: (persona: AgentPersona) => void;
onExport: (persona: AgentPersona) => void;
onDeactivate: (persona: AgentPersona) => void;
onDelete: (persona: AgentPersona) => void;
onImportFile: (fileBytes: number[], fileName: string) => void;
};
export function PersonasSection({
canChooseCatalog,
personas,
error,
feedbackErrorMessage,
feedbackNoticeMessage,
isLoading,
isPending,
onCreate,
onChooseCatalog,
onDuplicate,
onEdit,
onExport,
onDeactivate,
onDelete,
onImportFile,
}: PersonasSectionProps) {
@@ -57,7 +64,11 @@ export function PersonasSection({
} = useFileImportZone({ onImportFile });
return (
<section className="relative space-y-4" {...dropHandlers}>
<section
className="relative space-y-4"
data-testid="agents-library-personas"
{...dropHandlers}
>
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
@@ -68,9 +79,11 @@ export function PersonasSection({
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Personas</h3>
<h3 className="text-sm font-semibold tracking-tight">
{personaLibraryCopy.title}
</h3>
<p className="text-sm text-muted-foreground">
Reusable agent templates for common roles and prompts.
{personaLibraryCopy.description}
</p>
</div>
<input
@@ -80,20 +93,15 @@ export function PersonasSection({
ref={fileInputRef}
type="file"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Create persona"
onClick={onCreate}
type="button"
variant="ghost"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create persona</TooltipContent>
</Tooltip>
<div className="flex items-center gap-2">
<PersonaLibraryEntryPoints
canChooseCatalog={canChooseCatalog && personas.length > 0}
isPending={isPending}
layout="header"
onCreate={onCreate}
onChooseCatalog={onChooseCatalog}
/>
</div>
</div>
{isLoading ? (
@@ -117,103 +125,77 @@ export function PersonasSection({
{!isLoading && personas.length > 0 ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4">
{personas.map((persona) => {
const preview = promptPreview(persona.systemPrompt);
{personas.map((persona) => (
<div
className="rounded-xl border border-border/70 bg-card/80 p-2 shadow-sm"
data-testid={`library-persona-${persona.id}`}
key={persona.id}
>
<div className="flex items-start justify-between gap-2.5">
<PersonaIdentity persona={persona} />
return (
<div
className="rounded-xl border border-border/70 bg-card/80 p-2 shadow-sm"
key={persona.id}
>
<div className="flex items-start justify-between gap-2.5">
<div className="flex min-w-0 items-center gap-2.5">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
className="h-8 w-8 rounded-lg text-xs"
label={persona.displayName}
/>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-semibold tracking-tight">
{persona.displayName}
</p>
{persona.isBuiltIn ? (
<span className="whitespace-nowrap rounded-full bg-muted px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Built-in
</span>
) : null}
{preview ? (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="View system prompt"
className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
type="button"
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<p>{preview}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
type="button"
>
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-label={`Open actions for ${persona.displayName}`}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
data-testid={`library-persona-menu-${persona.id}`}
type="button"
>
{!persona.isBuiltIn ? (
<DropdownMenuItem
disabled={isPending}
onClick={() => onEdit(persona)}
>
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : null}
<Ellipsis className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
{!persona.isBuiltIn ? (
<DropdownMenuItem
disabled={isPending}
onClick={() => onDuplicate(persona)}
onClick={() => onEdit(persona)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
<Pencil className="h-4 w-4" />
Edit
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={isPending}
onClick={() => onDuplicate(persona)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
onClick={() => onExport(persona)}
>
<Download className="h-4 w-4" />
Export
</DropdownMenuItem>
{persona.isBuiltIn ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onExport(persona)}
onClick={() => onDeactivate(persona)}
>
<Download className="h-4 w-4" />
Export
<Trash2 className="h-4 w-4" />
Remove from My Agents
</DropdownMenuItem>
{!persona.isBuiltIn ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onDelete(persona)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
disabled={isPending}
onClick={() => onDelete(persona)}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
))}
<button
className="flex cursor-pointer items-center justify-center gap-2 rounded-xl border border-dashed border-primary p-2 text-primary transition-colors hover:bg-primary/5"
onClick={openFilePicker}
@@ -226,21 +208,27 @@ export function PersonasSection({
) : null}
{!isLoading && personas.length === 0 ? (
<button
className="w-full cursor-pointer rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center transition-colors hover:border-primary hover:bg-primary/5"
onClick={openFilePicker}
type="button"
>
<div className="rounded-xl border border-dashed border-primary/40 px-6 py-10 text-center">
<p className="text-sm font-semibold tracking-tight">
No personas yet
{personaLibraryCopy.emptyTitle}
</p>
<p className="mt-2 text-sm text-muted-foreground">
Create one to save a role, prompt, and optional avatar for reuse.
{personaLibraryCopy.emptyDescription}
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Or drop a .persona.json, .persona.png, or .zip file here to import.
{personaLibraryCopy.emptyImportHint}
</p>
</button>
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
<PersonaLibraryEntryPoints
canChooseCatalog={canChooseCatalog}
isPending={isPending}
layout="empty"
onCreate={onCreate}
onChooseCatalog={onChooseCatalog}
onImport={openFilePicker}
/>
</div>
</div>
) : null}
{error ? (
@@ -248,6 +236,24 @@ export function PersonasSection({
{error.message}
</p>
) : null}
{feedbackNoticeMessage ? (
<p
className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary"
data-testid="personas-library-feedback-notice"
>
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p
className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
data-testid="personas-library-feedback-error"
>
{feedbackErrorMessage}
</p>
) : null}
</section>
);
}

View File

@@ -17,6 +17,12 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { personaCatalogCopy } from "./personaLibraryCopy";
import {
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
} from "./teamDialogSelection";
type TeamDialogProps = {
open: boolean;
@@ -48,6 +54,13 @@ export function TeamDialog({
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
[],
);
const missingInitialPersonaCount = React.useMemo(() => {
if (!initialValues) {
return 0;
}
return countMissingPersonaIds(initialValues.personaIds, personas);
}, [initialValues, personas]);
React.useEffect(() => {
if (!open || !initialValues) {
@@ -56,7 +69,7 @@ export function TeamDialog({
setName(initialValues.name);
setTeamDescription(initialValues.description ?? "");
setSelectedPersonaIds(initialValues.personaIds);
setSelectedPersonaIds(copySelectedPersonaIds(initialValues.personaIds));
}, [initialValues, open]);
function handleOpenChange(next: boolean) {
@@ -85,7 +98,7 @@ export function TeamDialog({
const baseInput = {
name,
description: teamDescription.trim() || undefined,
personaIds: selectedPersonaIds,
personaIds: filterAvailablePersonaIds(selectedPersonaIds, personas),
};
if ("id" in initialValues) {
@@ -139,9 +152,18 @@ export function TeamDialog({
<p className="text-xs text-muted-foreground">
Select the personas to include in this team.
</p>
{missingInitialPersonaCount > 0 ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
This team references {missingInitialPersonaCount} persona
{missingInitialPersonaCount === 1 ? "" : "s"} that{" "}
{missingInitialPersonaCount === 1 ? "is" : "are"} no longer in
My Agents. Save to remove them, or add them back to My Agents
first.
</p>
) : null}
{personas.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No personas available. Create one first.
{personaCatalogCopy.teamEmptyState}
</p>
) : (
<div

View File

@@ -4,17 +4,16 @@ import {
Ellipsis,
Info,
Pencil,
Plus,
Rocket,
Trash2,
Upload,
Users,
} from "lucide-react";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -24,6 +23,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { CreateNewButton } from "./CreateNewButton";
const MAX_VISIBLE_AVATARS = 4;
@@ -42,15 +42,6 @@ type TeamsSectionProps = {
onImportFile: (fileBytes: number[], fileName: string) => void;
};
function resolvePersonas(
personaIds: string[],
personas: AgentPersona[],
): AgentPersona[] {
return personaIds
.map((id) => personas.find((p) => p.id === id))
.filter((p): p is AgentPersona => p !== undefined);
}
export function TeamsSection({
teams,
personas,
@@ -74,7 +65,11 @@ export function TeamsSection({
} = useFileImportZone({ onImportFile });
return (
<section className="relative space-y-4" {...dropHandlers}>
<section
className="relative space-y-4"
data-testid="agents-library-teams"
{...dropHandlers}
>
{isDragOver ? (
<div className="pointer-events-none absolute -inset-1 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-background/80 backdrop-blur-sm">
<p className="text-sm font-medium text-primary">
@@ -85,9 +80,9 @@ export function TeamsSection({
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold tracking-tight">Teams</h3>
<h3 className="text-sm font-semibold tracking-tight">My teams</h3>
<p className="text-sm text-muted-foreground">
Named groups of personas you can deploy to a channel together.
Saved groups from My Agents that you can add to a channel together.
</p>
</div>
<input
@@ -97,20 +92,11 @@ export function TeamsSection({
ref={fileInputRef}
type="file"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Create team"
onClick={onCreate}
type="button"
variant="ghost"
size="icon"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create team</TooltipContent>
</Tooltip>
<CreateNewButton
ariaLabel="Create team"
label="Team"
onClick={onCreate}
/>
</div>
{isLoading ? (
@@ -135,9 +121,15 @@ export function TeamsSection({
{!isLoading && teams.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{teams.map((team) => {
const resolved = resolvePersonas(team.personaIds, personas);
const visible = resolved.slice(0, MAX_VISIBLE_AVATARS);
const overflow = resolved.length - visible.length;
const resolution = resolveTeamPersonas(team, personas);
const visible = resolution.resolvedPersonas.slice(
0,
MAX_VISIBLE_AVATARS,
);
const overflow =
resolution.resolvedPersonas.length - visible.length;
const missingPersonaCount = resolution.missingPersonaCount;
const hasMissingPersonas = resolution.hasMissingPersonas;
return (
<div
@@ -206,7 +198,7 @@ export function TeamsSection({
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
disabled={isPending}
disabled={isPending || hasMissingPersonas}
onClick={() => onAddToChannel(team)}
>
<Rocket className="h-4 w-4" />
@@ -221,14 +213,14 @@ export function TeamsSection({
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
disabled={isPending || hasMissingPersonas}
onClick={() => onDuplicate(team)}
>
<CopyPlus className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem
disabled={isPending}
disabled={isPending || hasMissingPersonas}
onClick={() => onExport(team)}
>
<Download className="h-4 w-4" />
@@ -246,6 +238,16 @@ export function TeamsSection({
</DropdownMenuContent>
</DropdownMenu>
</div>
{hasMissingPersonas ? (
<p className="mt-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{missingPersonaCount} persona
{missingPersonaCount === 1 ? "" : "s"} in this team{" "}
{missingPersonaCount === 1 ? "is" : "are"} no longer in your
My Agents. Edit the team to repair it before deploying or
exporting.
</p>
) : null}
</div>
);
})}
@@ -268,7 +270,8 @@ export function TeamsSection({
>
<p className="text-sm font-semibold tracking-tight">No teams yet</p>
<p className="mt-2 text-sm text-muted-foreground">
Create a team to group personas for quick deployment to channels.
Create a team from the personas in My Agents for quick deployment to
channels.
</p>
<p className="mt-1 text-xs text-muted-foreground/70">
Or drop a .team.json file here to import.

View File

@@ -0,0 +1,91 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createPersonaDialogState,
duplicatePersonaDialogState,
editPersonaDialogState,
importPersonaDialogState,
} from "./personaDialogState.ts";
test("createPersonaDialogState returns a fresh empty draft", () => {
const first = createPersonaDialogState();
const second = createPersonaDialogState();
assert.equal(first.title, "Create persona");
assert.deepEqual(first.initialValues, {
displayName: "",
avatarUrl: "",
systemPrompt: "",
provider: undefined,
model: undefined,
});
assert.notStrictEqual(first.initialValues, second.initialValues);
});
test("duplicatePersonaDialogState copies persona fields into a new draft", () => {
const state = duplicatePersonaDialogState({
id: "persona-1",
displayName: "Solo",
avatarUrl: "avatar://solo",
systemPrompt: "Be direct.",
provider: "provider-a",
model: "model-a",
isBuiltIn: false,
isActive: true,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.deepEqual(state.initialValues, {
displayName: "Solo copy",
avatarUrl: "avatar://solo",
systemPrompt: "Be direct.",
provider: "provider-a",
model: "model-a",
});
});
test("editPersonaDialogState preserves the persona id for updates", () => {
const state = editPersonaDialogState({
id: "persona-2",
displayName: "Ralph",
avatarUrl: null,
systemPrompt: "Keep it weird.",
provider: null,
model: null,
isBuiltIn: true,
isActive: true,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-02T00:00:00Z",
});
assert.deepEqual(state.initialValues, {
id: "persona-2",
displayName: "Ralph",
avatarUrl: "",
systemPrompt: "Keep it weird.",
provider: undefined,
model: undefined,
});
});
test("importPersonaDialogState maps parsed persona previews into create drafts", () => {
const state = importPersonaDialogState({
displayName: "Imported",
avatarDataUrl: null,
systemPrompt: "Imported prompt",
provider: null,
model: "model-b",
sourceFile: "import.persona.json",
});
assert.equal(state.title, "Import Imported");
assert.deepEqual(state.initialValues, {
displayName: "Imported",
avatarUrl: "",
systemPrompt: "Imported prompt",
provider: undefined,
model: "model-b",
});
});

View File

@@ -0,0 +1,85 @@
import type { ParsePersonaFilesResult } from "@/shared/api/tauriPersonas";
import type {
AgentPersona,
CreatePersonaInput,
UpdatePersonaInput,
} from "@/shared/api/types";
export type PersonaDialogState = {
description: string;
initialValues: CreatePersonaInput | UpdatePersonaInput;
submitLabel: string;
title: string;
};
type ParsedPersonaDraft = ParsePersonaFilesResult["personas"][number];
export function createPersonaDialogState(): PersonaDialogState {
return {
title: "Create persona",
description:
"Save a reusable role, prompt, and optional avatar for future agent deployments.",
submitLabel: "Create persona",
initialValues: {
displayName: "",
avatarUrl: "",
systemPrompt: "",
provider: undefined,
model: undefined,
},
};
}
export function duplicatePersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
return {
title: `Duplicate ${persona.displayName}`,
description:
"Create a new persona by copying this template and adjusting it as needed.",
submitLabel: "Create persona",
initialValues: {
displayName: `${persona.displayName} copy`,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
provider: persona.provider ?? undefined,
model: persona.model ?? undefined,
},
};
}
export function editPersonaDialogState(
persona: AgentPersona,
): PersonaDialogState {
return {
title: `Edit ${persona.displayName}`,
description:
"Update this saved persona. New deployments will use the updated values.",
submitLabel: "Save changes",
initialValues: {
id: persona.id,
displayName: persona.displayName,
avatarUrl: persona.avatarUrl ?? "",
systemPrompt: persona.systemPrompt,
provider: persona.provider ?? undefined,
model: persona.model ?? undefined,
},
};
}
export function importPersonaDialogState(
persona: ParsedPersonaDraft,
): PersonaDialogState {
return {
title: `Import ${persona.displayName}`,
description: "Review and save this imported persona.",
submitLabel: "Create persona",
initialValues: {
displayName: persona.displayName,
avatarUrl: persona.avatarDataUrl ?? "",
systemPrompt: persona.systemPrompt,
provider: persona.provider ?? undefined,
model: persona.model ?? undefined,
},
};
}

View File

@@ -0,0 +1,64 @@
export const personaLibraryCopy = {
title: "My agents",
description:
"The personas you have chosen for this app. Use them to create teams and launch agents.",
chooseFromCatalog: "Choose...",
createNew: "Persona",
import: "Import",
emptyTitle: "No agents yet",
emptyDescription:
"Choose one from Persona Catalog, add your own persona, or import one to get started.",
emptyImportHint:
"Or drop a .persona.json, .persona.png, or .zip file here to import.",
} as const;
export const personaCatalogCopy = {
title: "Persona Catalog",
description: "Choose which built-in personas belong in My Agents.",
dialogTitle: "Choose from Persona Catalog",
dialogDescription:
"Select the built-in personas you want available in My Agents.",
emptyTitle: "You're all set",
emptyDescription: "Everything in Persona Catalog is already in My Agents.",
emptyCatalogDescription:
"New personas will show up here when the app ships more options.",
emptyCatalogTitle: "No personas in the catalog yet",
detailsAction: "View details",
selectAction: "Choose",
deselectAction: "Selected",
selectedState: "Selected",
availableState: "Available",
detailSelectedTitle: "Selected for My Agents",
detailSelectedDescription:
"Turn this off to remove the persona from teams and agent creation in this app.",
detailAvailableTitle: "Available in Persona Catalog",
detailAvailableDescription:
"Turn this on to make the persona available for teams and agent creation.",
teamEmptyState:
"No personas in My Agents yet. Create one or choose one from Persona Catalog first.",
} as const;
export function getPersonaCatalogSelectionActionCopy(isActive: boolean) {
return isActive
? personaCatalogCopy.deselectAction
: personaCatalogCopy.selectAction;
}
export function getPersonaCatalogSelectionAriaLabel(
displayName: string,
isActive: boolean,
) {
return `${isActive ? "Deselect" : "Select"} ${displayName} in My Agents`;
}
export function getPersonaCatalogDetailSelectionCopy(isActive: boolean) {
return isActive
? {
title: personaCatalogCopy.detailSelectedTitle,
description: personaCatalogCopy.detailSelectedDescription,
}
: {
title: personaCatalogCopy.detailAvailableTitle,
description: personaCatalogCopy.detailAvailableDescription,
};
}

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
copySelectedPersonaIds,
countMissingPersonaIds,
filterAvailablePersonaIds,
} from "./teamDialogSelection.ts";
function createPersona(id) {
return {
id,
displayName: id,
avatarUrl: null,
systemPrompt: "Prompt",
provider: null,
model: null,
namePool: [],
isBuiltIn: false,
isActive: true,
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
}
test("copySelectedPersonaIds keeps initial team members until submit filtering", () => {
const selectedPersonaIds = copySelectedPersonaIds([
"persona:available",
"persona:late",
]);
const initiallyAvailable = [createPersona("persona:available")];
const laterAvailable = [
createPersona("persona:available"),
createPersona("persona:late"),
];
assert.deepEqual(selectedPersonaIds, ["persona:available", "persona:late"]);
assert.deepEqual(
filterAvailablePersonaIds(selectedPersonaIds, initiallyAvailable),
["persona:available"],
);
assert.deepEqual(
filterAvailablePersonaIds(selectedPersonaIds, laterAvailable),
["persona:available", "persona:late"],
);
});
test("countMissingPersonaIds reports unresolved team personas", () => {
assert.equal(
countMissingPersonaIds(
["persona:available", "persona:missing-a", "persona:missing-b"],
[createPersona("persona:available")],
),
2,
);
});
test("filterAvailablePersonaIds drops missing personas at submit time", () => {
assert.deepEqual(
filterAvailablePersonaIds(
["persona:missing", "persona:available"],
[createPersona("persona:available")],
),
["persona:available"],
);
});

View File

@@ -0,0 +1,26 @@
import type { AgentPersona } from "@/shared/api/types";
function getAvailablePersonaIds(personas: AgentPersona[]): Set<string> {
return new Set(personas.map((persona) => persona.id));
}
export function copySelectedPersonaIds(personaIds: string[]): string[] {
return [...personaIds];
}
export function countMissingPersonaIds(
personaIds: string[],
personas: AgentPersona[],
): number {
const availablePersonaIds = getAvailablePersonaIds(personas);
return personaIds.filter((personaId) => !availablePersonaIds.has(personaId))
.length;
}
export function filterAvailablePersonaIds(
personaIds: string[],
personas: AgentPersona[],
): string[] {
const availablePersonaIds = getAvailablePersonaIds(personas);
return personaIds.filter((personaId) => availablePersonaIds.has(personaId));
}

View File

@@ -19,13 +19,8 @@ import type {
ManagedAgentBackend,
} from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Dialog } from "@/shared/ui/dialog";
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
import {
DropdownMenu,
DropdownMenuContent,
@@ -41,6 +36,8 @@ import {
collectProviderWarnings,
resolvePersonaProvider,
} from "@/features/agents/lib/resolvePersonaProvider";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { getUsableTeams } from "@/features/agents/lib/teamPersonas";
type AddChannelBotDialogProps = {
backendProviders?: BackendProviderCandidate[];
@@ -108,8 +105,14 @@ export function AddChannelBotDialog({
open && channelId !== null,
);
const createBotsMutation = useCreateChannelManagedAgentsMutation(channelId);
const personas = personasQuery.data ?? [];
const teams = teamsQuery.data ?? [];
const personas = React.useMemo(
() => getActivePersonas(personasQuery.data ?? []),
[personasQuery.data],
);
const teams = React.useMemo(
() => getUsableTeams(teamsQuery.data ?? [], personas),
[personas, teamsQuery.data],
);
const [selectedProviderId, setSelectedProviderId] = React.useState("");
const [selectedPersonaIds, setSelectedPersonaIds] = React.useState<string[]>(
[],
@@ -401,198 +404,12 @@ export function AddChannelBotDialog({
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-3xl overflow-hidden p-0">
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add agents</DialogTitle>
<DialogDescription>
Select any combination of saved personas, or turn on Generic for a
one-off custom agent.
</DialogDescription>
</DialogHeader>
<div className="space-y-5 px-6 py-5">
{resolvedBackendProviders.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Run on</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={createBotsMutation.isPending}
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{resolvedBackendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{isProviderMode && selectedBackendProvider ? (
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use providers
from trusted sources.
</p>
</div>
) : null}
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{isProviderMode && probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium">Runtime</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{providerTriggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={setSelectedProviderId}
value={selectedProvider?.id ?? ""}
>
{providers.map((provider) => (
<DropdownMenuRadioItem
key={provider.id}
value={provider.id}
>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{teams.length > 0 ? (
<AddChannelBotTeamsSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
teams={teams}
/>
) : null}
<AddChannelBotPersonasSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
includeGeneric={includeGeneric}
isLoading={personasQuery.isLoading}
onToggleGeneric={() => {
setIncludeGeneric((current) => !current);
setSubmissionNotice(null);
setSubmissionError(null);
}}
onTogglePersona={(personaId) => {
setSelectedPersonaIds((current) =>
toggleValue(current, personaId),
);
setSubmissionNotice(null);
setSubmissionError(null);
}}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
/>
{includeGeneric ? (
<AddChannelBotGenericSection
disabled={createBotsMutation.isPending}
name={customName}
onNameChange={(value) => {
setHasEditedCustomName(true);
setCustomName(value);
}}
onPromptChange={setCustomPrompt}
prompt={customPrompt}
/>
) : null}
{selectedCount === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
Pick one or more personas, or enable Generic to add a custom
agent.
</div>
) : null}
{providersErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersErrorMessage}
</p>
) : null}
{providerWarnings.length > 0
? providerWarnings.map((warning) => (
<div
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
key={warning}
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">{warning}</p>
</div>
))
: null}
{personasQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{personasQuery.error.message}
</p>
) : null}
{submissionNotice ? (
<p className="rounded-2xl border border-border/70 bg-muted/25 px-4 py-3 text-sm text-foreground">
{submissionNotice}
</p>
) : null}
{submissionError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{submissionError}
</p>
) : null}
{createBotsMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createBotsMutation.error.message}
</p>
) : null}
</div>
<div className="flex justify-end gap-2 border-t border-border/60 px-6 py-4">
<ChooserDialogContent
className="max-w-3xl"
data-testid="add-channel-bot-dialog"
description="Select any combination of saved personas, or turn on Generic for a one-off custom agent."
footer={
<>
<Button
onClick={() => handleOpenChange(false)}
size="sm"
@@ -609,9 +426,188 @@ export function AddChannelBotDialog({
>
{addButtonLabel}
</Button>
</>
}
footerClassName="justify-end gap-2"
footerTestId="add-channel-bot-dialog-footer"
headerTestId="add-channel-bot-dialog-header"
scrollAreaClassName="space-y-5"
scrollAreaTestId="add-channel-bot-dialog-scroll-area"
title="Add agents"
>
{resolvedBackendProviders.length > 0 ? (
<div className="space-y-1.5">
<div className="text-sm font-medium">Run on</div>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm"
disabled={createBotsMutation.isPending}
onChange={(e) => handleRunOnChange(e.target.value)}
value={runOn}
>
<option value="local">This computer</option>
{resolvedBackendProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.id}
</option>
))}
</select>
</div>
) : null}
{isProviderMode && selectedBackendProvider ? (
<div className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">
This provider at{" "}
<span className="font-mono font-medium">
{selectedBackendProvider.binaryPath}
</span>{" "}
will receive your agent&apos;s private key. Only use providers
from trusted sources.
</p>
</div>
) : null}
{probeError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Could not probe provider: {probeError}
</p>
) : null}
{isProviderMode && probedProvider?.config_schema ? (
<ProviderConfigFields
config={providerConfig}
onChange={setProviderConfig}
schema={probedProvider.config_schema}
/>
) : null}
<div className="space-y-1.5">
<div className="text-sm font-medium">Runtime</div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
className="h-9 max-w-full justify-start gap-1.5 rounded-full border border-border/50 bg-muted/45 px-3 text-sm font-medium text-foreground shadow-none hover:bg-muted/70"
disabled={!canChooseProvider}
size="default"
type="button"
variant="ghost"
>
<span className="truncate">{providerTriggerLabel}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-40"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuRadioGroup
onValueChange={setSelectedProviderId}
value={selectedProvider?.id ?? ""}
>
{providers.map((provider) => (
<DropdownMenuRadioItem key={provider.id} value={provider.id}>
{provider.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</DialogContent>
{teams.length > 0 ? (
<AddChannelBotTeamsSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
isLoading={teamsQuery.isLoading}
onToggleTeam={handleToggleTeam}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
teams={teams}
/>
) : null}
<AddChannelBotPersonasSection
canToggleSelections={canToggleSelections}
inChannelPersonaIds={inChannelPersonaIds}
includeGeneric={includeGeneric}
isLoading={personasQuery.isLoading}
onToggleGeneric={() => {
setIncludeGeneric((current) => !current);
setSubmissionNotice(null);
setSubmissionError(null);
}}
onTogglePersona={(personaId) => {
setSelectedPersonaIds((current) => toggleValue(current, personaId));
setSubmissionNotice(null);
setSubmissionError(null);
}}
personas={personas}
selectedPersonaIds={selectedPersonaIds}
/>
{includeGeneric ? (
<AddChannelBotGenericSection
disabled={createBotsMutation.isPending}
name={customName}
onNameChange={(value) => {
setHasEditedCustomName(true);
setCustomName(value);
}}
onPromptChange={setCustomPrompt}
prompt={customPrompt}
/>
) : null}
{selectedCount === 0 ? (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/15 px-4 py-4 text-sm text-muted-foreground">
Pick one or more personas, or enable Generic to add a custom agent.
</div>
) : null}
{providersErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{providersErrorMessage}
</p>
) : null}
{providerWarnings.length > 0
? providerWarnings.map((warning) => (
<div
className="flex gap-3 rounded-2xl border border-warning/30 bg-warning-bg px-4 py-3"
key={warning}
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p className="text-sm text-warning">{warning}</p>
</div>
))
: null}
{personasQuery.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{personasQuery.error.message}
</p>
) : null}
{submissionNotice ? (
<p className="rounded-2xl border border-border/70 bg-muted/25 px-4 py-3 text-sm text-foreground">
{submissionNotice}
</p>
) : null}
{submissionError ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{submissionError}
</p>
) : null}
{createBotsMutation.error instanceof Error ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{createBotsMutation.error.message}
</p>
) : null}
</ChooserDialogContent>
</Dialog>
);
}

View File

@@ -4,6 +4,7 @@ import type * as React from "react";
import type { AgentPersona, AgentTeam } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { resolveTeamPersonas } from "@/features/agents/lib/teamPersonas";
import {
Tooltip,
TooltipContent,
@@ -45,15 +46,6 @@ function SelectionChipButton({
);
}
function resolveTeamPersonas(
team: AgentTeam,
personas: AgentPersona[],
): AgentPersona[] {
return team.personaIds
.map((id) => personas.find((p) => p.id === id))
.filter((p): p is AgentPersona => p !== undefined);
}
type AddChannelBotTeamsSectionProps = {
canToggleSelections: boolean;
inChannelPersonaIds?: ReadonlySet<string>;
@@ -89,8 +81,8 @@ export function AddChannelBotTeamsSection({
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap gap-2">
{teams.map((team) => {
const resolved = resolveTeamPersonas(team, personas);
const validIds = resolved.map((p) => p.id);
const resolution = resolveTeamPersonas(team, personas);
const validIds = resolution.resolvedPersonaIds;
const allSelected =
validIds.length > 0 &&
validIds.every((id) => selectedPersonaIds.includes(id));
@@ -107,7 +99,7 @@ export function AddChannelBotTeamsSection({
<SelectionChipButton
disabled={
!canToggleSelections ||
validIds.length === 0 ||
!resolution.isUsable ||
allInChannel
}
label={team.name}
@@ -160,7 +152,7 @@ export function AddChannelBotTeamsSection({
</p>
) : null}
<div className="flex flex-wrap gap-1">
{resolved.map((persona) => {
{resolution.resolvedPersonas.map((persona) => {
const personaInChannel =
inChannelPersonaIds?.has(persona.id) ?? false;
return (

View File

@@ -11,8 +11,9 @@ import {
import { pickBotName } from "@/features/agents/lib/pickBotName";
import {
useBotRecents,
DEFAULT_PERSONA_NAMES,
pickQuickBotPersonas,
} from "@/features/agents/lib/useBotRecents";
import { getActivePersonas } from "@/features/agents/lib/catalog";
import { useChannelMembersQuery } from "@/features/channels/hooks";
import { QuickBotBar } from "@/features/channels/ui/QuickBotBar";
import { useQuickBotDrop } from "@/features/channels/ui/useQuickBotDrop";
@@ -65,7 +66,10 @@ export function ChannelMembersBar({
(member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey,
) ?? null;
const personasQuery2 = usePersonasQuery();
const allPersonas = personasQuery2.data ?? [];
const allPersonas = React.useMemo(
() => getActivePersonas(personasQuery2.data ?? []),
[personasQuery2.data],
);
const { recentIds, pushRecent } = useBotRecents();
const quickDrop = useQuickBotDrop(channel.id);
@@ -74,31 +78,10 @@ export function ChannelMembersBar({
const inflightNamesRef = React.useRef<Record<string, string[]>>({});
// Resolve the 3 personas to show in the quick bar.
// Use recents if available, otherwise fall back to default names.
const quickPersonas = React.useMemo(() => {
if (allPersonas.length === 0) return [];
const resolved: typeof allPersonas = [];
if (recentIds.length > 0) {
for (const id of recentIds) {
const found = allPersonas.find((p) => p.id === id);
if (found) resolved.push(found);
if (resolved.length >= 3) break;
}
}
if (resolved.length < 3) {
for (const name of DEFAULT_PERSONA_NAMES) {
if (resolved.length >= 3) break;
const found = allPersonas.find(
(p) =>
p.displayName.toLowerCase() === name.toLowerCase() &&
!resolved.some((r) => r.id === p.id),
);
if (found) resolved.push(found);
}
}
const resolved = pickQuickBotPersonas(allPersonas, recentIds);
// Reset in-flight names when members list updates (the new bot appeared).
inflightNamesRef.current = {};

View File

@@ -56,6 +56,7 @@ type RawPersona = {
model?: string | null;
name_pool?: string[];
is_builtin: boolean;
is_active?: boolean;
created_at: string;
updated_at: string;
};
@@ -70,6 +71,7 @@ function fromRawPersona(persona: RawPersona): AgentPersona {
model: persona.model ?? null,
namePool: persona.name_pool ?? [],
isBuiltIn: persona.is_builtin,
isActive: persona.is_active ?? true,
createdAt: persona.created_at,
updatedAt: persona.updated_at,
};
@@ -118,6 +120,15 @@ export async function deletePersona(id: string): Promise<void> {
await invokeTauri("delete_persona", { id });
}
export async function setPersonaActive(
id: string,
active: boolean,
): Promise<AgentPersona> {
return fromRawPersona(
await invokeTauri<RawPersona>("set_persona_active", { id, active }),
);
}
export async function parsePersonaFiles(
fileBytes: number[],
fileName: string,

View File

@@ -419,6 +419,7 @@ export type AgentPersona = {
model: string | null;
namePool: string[];
isBuiltIn: boolean;
isActive: boolean;
createdAt: string;
updatedAt: string;
};

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./dialog";
type ChooserDialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogContent
> & {
description?: React.ReactNode;
footer?: React.ReactNode;
footerClassName?: string;
footerTestId?: string;
headerClassName?: string;
headerTestId?: string;
scrollAreaClassName?: string;
scrollAreaTestId?: string;
title: React.ReactNode;
};
export const ChooserDialogContent = React.forwardRef<
React.ElementRef<typeof DialogContent>,
ChooserDialogContentProps
>(
(
{
children,
className,
description,
footer,
footerClassName,
footerTestId,
headerClassName,
headerTestId,
scrollAreaClassName,
scrollAreaTestId,
title,
...props
},
ref,
) => (
<DialogContent
className={cn(
"flex max-h-[85vh] flex-col overflow-hidden p-0",
className,
)}
ref={ref}
{...props}
>
<DialogHeader
className={cn(
"shrink-0 border-b border-border/60 px-6 py-5 pr-14",
headerClassName,
)}
data-testid={headerTestId}
>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-6 py-5",
scrollAreaClassName,
)}
data-testid={scrollAreaTestId}
>
{children}
</div>
{footer ? (
<div
className={cn(
"flex shrink-0 border-t border-border/60 px-6 py-4",
footerClassName,
)}
data-testid={footerTestId}
>
{footer}
</div>
) : null}
</DialogContent>
),
);
ChooserDialogContent.displayName = "ChooserDialogContent";

View File

@@ -356,6 +356,16 @@ type RawPersona = {
avatar_url: string | null;
system_prompt: string;
is_builtin: boolean;
is_active: boolean;
created_at: string;
updated_at: string;
};
type RawTeam = {
id: string;
name: string;
description: string | null;
persona_ids: string[];
created_at: string;
updated_at: string;
};
@@ -392,6 +402,10 @@ declare global {
channelName: string;
pubkey?: string;
}) => RelayEvent;
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__SPROUT_E2E_PUSH_MOCK_FEED_ITEM__?: (item: RawFeedItem) => RawFeedItem;
}
}
@@ -651,52 +665,32 @@ function resetMockPersonas() {
const now = new Date().toISOString();
mockPersonas = [
{
id: "builtin:orchestrator",
display_name: "Orchestrator",
id: "builtin:solo",
display_name: "Solo",
avatar_url: null,
system_prompt:
"You are an orchestration agent. Coordinate multi-step work across specialized agents, keep the overall plan moving, and synthesize results into a clear final outcome. When another agent should take a task, @mention them explicitly with the assignment, expected deliverable, and any relevant constraints or deadlines.",
system_prompt: "You are Solo.",
is_builtin: true,
is_active: false,
created_at: now,
updated_at: now,
},
{
id: "builtin:researcher",
display_name: "Researcher",
id: "builtin:ralph",
display_name: "Ralph",
avatar_url: null,
system_prompt:
"You are a research agent. Gather relevant information, compare sources, call out uncertainty, and return concise findings with evidence.",
system_prompt: "You are Ralph.",
is_builtin: true,
is_active: false,
created_at: now,
updated_at: now,
},
{
id: "builtin:planner",
display_name: "Planner",
id: "builtin:scout",
display_name: "Scout",
avatar_url: null,
system_prompt:
"You are a planning agent. Turn ambiguous requests into structured plans with milestones, dependencies, risks, and clear next actions. Do not implement the work yourself unless asked.",
is_builtin: true,
created_at: now,
updated_at: now,
},
{
id: "builtin:implementer",
display_name: "Builder",
avatar_url: null,
system_prompt:
"You are a builder agent. Execute tasks directly, make code and configuration changes carefully, validate the result, and explain important decisions and follow-up items.",
is_builtin: true,
created_at: now,
updated_at: now,
},
{
id: "builtin:refactor",
display_name: "Refactor",
avatar_url: null,
system_prompt:
"You are a refactoring agent. Improve structure, naming, duplication, and module boundaries without changing externally observable behavior. Keep changes incremental, preserve compatibility, and add or update validation when behavior could drift.",
system_prompt: "You are Scout.",
is_builtin: true,
is_active: false,
created_at: now,
updated_at: now,
},
@@ -704,15 +698,19 @@ function resetMockPersonas() {
id: "builtin:reviewer",
display_name: "Reviewer",
avatar_url: null,
system_prompt:
"You are a review agent. Inspect plans, code, and outputs for bugs, regressions, edge cases, security issues, and missing tests. Prioritize findings by severity, cite concrete evidence, and keep summaries secondary to the actual review.",
system_prompt: "You are Reviewer.",
is_builtin: true,
is_active: false,
created_at: now,
updated_at: now,
},
];
}
function resetMockTeams() {
mockTeams = [];
}
function getMockProfileByPubkey(pubkey: string): RawProfile | null {
const normalizedPubkey = pubkey.toLowerCase();
const existing = mockProfiles.get(normalizedPubkey);
@@ -1045,6 +1043,7 @@ let mockTokens: MockToken[] = [];
let mockMintTokenError: string | null = null;
let mockManagedAgents: MockManagedAgent[] = [];
let mockPersonas: RawPersona[] = [];
let mockTeams: RawTeam[] = [];
let mockRelayAgents: RawRelayAgent[] = [
{
pubkey: ALICE_PUBKEY,
@@ -3000,6 +2999,7 @@ async function handleCreatePersona(args: {
avatar_url: args.input.avatarUrl?.trim() || null,
system_prompt: args.input.systemPrompt.trim(),
is_builtin: false,
is_active: true,
created_at: now,
updated_at: now,
};
@@ -3041,6 +3041,11 @@ async function handleDeletePersona(args: { id: string }): Promise<void> {
if (persona.is_builtin) {
throw new Error("Built-in personas cannot be deleted.");
}
if (mockTeams.some((team) => team.persona_ids.includes(args.id))) {
throw new Error(
`${persona.display_name} is still referenced by a team. Remove it from those teams first.`,
);
}
mockPersonas = mockPersonas.filter((candidate) => candidate.id !== args.id);
const now = new Date().toISOString();
@@ -3052,6 +3057,148 @@ async function handleDeletePersona(args: { id: string }): Promise<void> {
}
}
async function handleSetPersonaActive(args: {
id: string;
active: boolean;
}): Promise<RawPersona> {
const persona = mockPersonas.find((candidate) => candidate.id === args.id);
if (!persona) {
throw new Error(`Persona ${args.id} not found.`);
}
if (!persona.is_builtin) {
throw new Error(
"Only built-in personas can be added to or removed from My Agents.",
);
}
if (
!args.active &&
mockManagedAgents.some((agent) => agent.persona_id === args.id)
) {
throw new Error(
`${persona.display_name} is still assigned to a managed agent. Remove or reassign those agents first.`,
);
}
if (
!args.active &&
mockTeams.some((team) => team.persona_ids.includes(args.id))
) {
throw new Error(
`${persona.display_name} is still referenced by a team. Remove it from those teams first.`,
);
}
persona.is_active = args.active;
persona.updated_at = new Date().toISOString();
return { ...persona };
}
function ensureMockPersonaIsActive(personaId: string) {
const persona = mockPersonas.find((candidate) => candidate.id === personaId);
if (!persona) {
throw new Error(`persona ${personaId} not found`);
}
if (!persona.is_active) {
throw new Error(
`${persona.display_name} is not in My Agents. Choose it from Persona Catalog first.`,
);
}
}
function ensureMockPersonaIdsAreActive(personaIds: string[]) {
for (const personaId of personaIds) {
ensureMockPersonaIsActive(personaId);
}
}
async function handleListTeams(): Promise<RawTeam[]> {
return mockTeams.map((team) => ({
...team,
persona_ids: [...team.persona_ids],
}));
}
async function handleCreateTeam(args: {
input: {
name: string;
description?: string;
personaIds: string[];
};
}): Promise<RawTeam> {
ensureMockPersonaIdsAreActive(args.input.personaIds);
const now = new Date().toISOString();
const team: RawTeam = {
id: crypto.randomUUID(),
name: args.input.name.trim(),
description: args.input.description?.trim() || null,
persona_ids: [...args.input.personaIds],
created_at: now,
updated_at: now,
};
mockTeams.push(team);
return { ...team, persona_ids: [...team.persona_ids] };
}
async function handleUpdateTeam(args: {
input: {
id: string;
name: string;
description?: string;
personaIds: string[];
};
}): Promise<RawTeam> {
const team = mockTeams.find((candidate) => candidate.id === args.input.id);
if (!team) {
throw new Error(`Team ${args.input.id} not found.`);
}
ensureMockPersonaIdsAreActive(args.input.personaIds);
team.name = args.input.name.trim();
team.description = args.input.description?.trim() || null;
team.persona_ids = [...args.input.personaIds];
team.updated_at = new Date().toISOString();
return { ...team, persona_ids: [...team.persona_ids] };
}
async function handleDeleteTeam(args: { id: string }): Promise<void> {
mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id);
}
async function handleExportTeamToJson(args: { id: string }): Promise<boolean> {
const team = mockTeams.find((candidate) => candidate.id === args.id);
if (!team) {
throw new Error(`Team ${args.id} not found.`);
}
const missingPersonaIds = team.persona_ids.filter(
(personaId) =>
!mockPersonas.some((candidate) => candidate.id === personaId),
);
if (missingPersonaIds.length > 0) {
throw new Error(
`Team ${team.name} references missing personas: ${missingPersonaIds.join(", ")}. Repair the team before exporting.`,
);
}
return true;
}
async function handleParseTeamFile(): Promise<{
name: string;
description: string | null;
personas: Array<{
display_name: string;
system_prompt: string;
avatar_url: string | null;
}>;
}> {
return {
name: "Imported Team",
description: null,
personas: [],
};
}
async function handleParsePersonaFiles(args: {
fileBytes: number[];
fileName: string;
@@ -3113,6 +3260,9 @@ async function handleCreateManagedAgent(args: {
| { type: "provider"; id: string; config: Record<string, unknown> };
};
}): Promise<RawCreateManagedAgentResponse> {
if (args.input.personaId) {
ensureMockPersonaIsActive(args.input.personaId);
}
const name = args.input.name.trim();
const now = new Date().toISOString();
const pubkey = crypto
@@ -3779,6 +3929,7 @@ export function maybeInstallE2eTauriMocks() {
resetMockTokens(config);
resetMockManagedAgents();
resetMockPersonas();
resetMockTeams();
resetMockWorkflows();
mockWindows("main");
window.__SPROUT_E2E_COMMANDS__ = [];
@@ -3813,7 +3964,7 @@ export function maybeInstallE2eTauriMocks() {
window.dispatchEvent(new CustomEvent("sprout:e2e-home-feed-updated"));
return item;
};
mockIPC(async (command, payload) => {
const handleMockCommand = async (command: string, payload: unknown) => {
const activeConfig = getConfig();
const identity = getIdentity(activeConfig);
window.__SPROUT_E2E_COMMANDS__?.push(command);
@@ -3918,6 +4069,28 @@ export function maybeInstallE2eTauriMocks() {
return handleDeletePersona(
payload as Parameters<typeof handleDeletePersona>[0],
);
case "set_persona_active":
return handleSetPersonaActive(
payload as Parameters<typeof handleSetPersonaActive>[0],
);
case "list_teams":
return handleListTeams();
case "create_team":
return handleCreateTeam(
payload as Parameters<typeof handleCreateTeam>[0],
);
case "update_team":
return handleUpdateTeam(
payload as Parameters<typeof handleUpdateTeam>[0],
);
case "delete_team":
return handleDeleteTeam(
payload as Parameters<typeof handleDeleteTeam>[0],
);
case "export_team_to_json":
return handleExportTeamToJson(payload as { id: string });
case "parse_team_file":
return handleParseTeamFile();
case "parse_persona_files":
return handleParsePersonaFiles(
payload as { fileBytes: number[]; fileName: string },
@@ -4173,7 +4346,10 @@ export function maybeInstallE2eTauriMocks() {
default:
throw new Error(`Unsupported mocked Tauri command: ${command}`);
}
});
};
window.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ = (command, payload) =>
handleMockCommand(command, payload ?? null);
mockIPC(handleMockCommand);
installed = true;
}

View File

@@ -0,0 +1,364 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
async function gotoApp(page: import("@playwright/test").Page) {
let lastError: unknown = null;
for (const attempt of [0, 1]) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForInvokeBridge(page);
try {
await expect(page.getByTestId("open-agents-view")).toBeVisible({
timeout: 10_000,
});
return;
} catch (error) {
lastError = error;
if (attempt === 1) {
throw error;
}
}
}
throw lastError;
}
async function getCatalogOrder(page: import("@playwright/test").Page) {
return page
.locator('[data-testid^="persona-catalog-card-target-"]')
.evaluateAll((elements) =>
elements.map((element) => element.getAttribute("data-testid") ?? ""),
);
}
async function waitForInvokeBridge(page: import("@playwright/test").Page) {
await page.waitForFunction(
() => {
const tauriWindow = window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: unknown;
__TAURI_INTERNALS__?: {
invoke?: unknown;
};
};
return (
typeof tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ === "function" ||
typeof tauriWindow.__TAURI_INTERNALS__?.invoke === "function"
);
},
null,
{ timeout: 5_000 },
);
}
async function invokeTauri<T>(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
): Promise<T> {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: targetCommand, payload: targetPayload }) => {
const tauriWindow = window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ ??
tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
return (await invoke(targetCommand, targetPayload)) as T;
},
{ command, payload },
);
}
async function invokeTauriExpectError(
page: import("@playwright/test").Page,
command: string,
payload?: Record<string, unknown>,
) {
await waitForInvokeBridge(page);
return page.evaluate(
async ({ command: targetCommand, payload: targetPayload }) => {
const tauriWindow = window as Window & {
__SPROUT_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
__TAURI_INTERNALS__?: {
invoke?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<unknown>;
};
};
const invoke =
tauriWindow.__SPROUT_E2E_INVOKE_MOCK_COMMAND__ ??
tauriWindow.__TAURI_INTERNALS__?.invoke;
if (!invoke) {
throw new Error("Mock invoke bridge is unavailable.");
}
try {
await invoke(targetCommand, targetPayload);
return null;
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
},
{ command, payload },
);
}
test("built-in personas are chosen from the dialog and can be selected", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 420 });
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-library-personas")).toContainText(
"No agents yet",
);
await expect(page.getByTestId("agents-persona-catalog")).toHaveCount(0);
await page.getByTestId("open-persona-catalog").click();
await expect(page.getByTestId("persona-catalog-dialog")).toContainText(
"Reviewer",
);
await expect(page.getByTestId("persona-catalog-dialog-header")).toBeVisible();
await expect(
page.getByTestId("persona-catalog-dialog-scroll-area"),
).toBeVisible();
await expect(
page.getByTestId("persona-catalog-dialog-scroll-area"),
).toHaveCSS("overflow-y", "auto");
expect(
await page
.getByTestId("persona-catalog-dialog-scroll-area")
.evaluate(
(element) =>
element.scrollHeight > element.clientHeight &&
element.clientHeight > 0,
),
).toBe(true);
await expect(page.getByTestId("persona-catalog-dialog-footer")).toBeVisible();
await expect(page.getByRole("tooltip")).toHaveCount(0);
const initialCatalogOrder = await getCatalogOrder(page);
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await expect(
page.getByTestId("persona-catalog-feedback-notice"),
).toContainText("Selected Reviewer for My Agents.");
await expect(page.getByTestId("agents-library-personas")).toContainText(
"Reviewer",
);
await expect(
page.getByTestId("persona-catalog-card-target-builtin:reviewer"),
).toHaveAttribute("aria-pressed", "true");
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await expect(
page.getByTestId("persona-catalog-feedback-notice"),
).toContainText("Deselected Reviewer from My Agents.");
await expect(page.getByTestId("agents-library-personas")).not.toContainText(
"Reviewer",
);
await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder);
});
test("persona catalog can reopen from the populated library header", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await expect(page.getByTestId("agents-library-personas")).toContainText(
"Reviewer",
);
await page.getByTestId("persona-catalog-dialog-done").click();
await page.getByTestId("open-persona-catalog").click();
await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible();
await expect(
page.getByTestId("persona-catalog-card-target-builtin:reviewer"),
).toHaveAttribute("aria-pressed", "true");
});
test("persona catalog chooser order stays stable when selection changes", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
const before = await getCatalogOrder(page);
await page.getByTestId("persona-catalog-card-target-builtin:solo").click();
await expect(
page.getByTestId("persona-catalog-feedback-notice"),
).toContainText("Selected Solo for My Agents.");
expect(await getCatalogOrder(page)).toEqual(before);
});
test("catalog details sheet shows the full persona details", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await page.getByTestId("persona-catalog-details-builtin:reviewer").click();
const detailSelectionTarget = page.getByTestId(
"persona-catalog-detail-selection-target-builtin:reviewer",
);
await expect(page.getByTestId("persona-catalog-details-sheet")).toContainText(
"Reviewer",
);
await expect(page.getByTestId("persona-catalog-details-sheet")).toContainText(
"You are Reviewer.",
);
await expect(
page.getByTestId("persona-catalog-detail-selection-title"),
).toHaveText("Available in Persona Catalog");
await expect(detailSelectionTarget).toHaveAttribute(
"aria-label",
"Select Reviewer in My Agents",
);
await expect(detailSelectionTarget).toHaveAttribute("aria-pressed", "false");
await detailSelectionTarget.click();
await expect(
page.getByTestId("persona-catalog-detail-selection-title"),
).toHaveText("Selected for My Agents");
await expect(detailSelectionTarget).toHaveAttribute(
"aria-label",
"Deselect Reviewer in My Agents",
);
await expect(detailSelectionTarget).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("agents-library-personas")).toContainText(
"Reviewer",
);
});
test("inactive built-ins cannot be used to create teams", async ({ page }) => {
await gotoApp(page);
const error = await invokeTauriExpectError(page, "create_team", {
input: {
name: "Reviewers",
personaIds: ["builtin:reviewer"],
},
});
expect(error).toBe(
"Reviewer is not in My Agents. Choose it from Persona Catalog first.",
);
});
test("built-in deselection failures show up in Persona Catalog", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await invokeTauri(page, "create_team", {
input: {
name: "Reviewers",
personaIds: ["builtin:reviewer"],
},
});
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await expect(
page.getByTestId("persona-catalog-feedback-error"),
).toContainText("Reviewer is still referenced by a team.");
});
test("channel quick add falls back to added personas when defaults are absent", async ({
page,
}) => {
await gotoApp(page);
await page.getByTestId("open-agents-view").click();
await page.getByTestId("open-persona-catalog").click();
await page
.getByTestId("persona-catalog-card-target-builtin:reviewer")
.click();
await page.getByTestId("persona-catalog-dialog-done").click();
await page.getByTestId("channel-random").click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await page.getByTestId("channel-add-bot-trigger").hover();
await expect(
page.getByRole("button", { name: "Add Reviewer" }),
).toBeVisible();
});
test("personas referenced by teams cannot be deleted", async ({ page }) => {
await gotoApp(page);
const persona = await invokeTauri<{ id: string }>(page, "create_persona", {
input: {
displayName: "Analyst",
systemPrompt: "You are Analyst.",
},
});
await invokeTauri(page, "create_team", {
input: {
name: "Analysts",
personaIds: [persona.id],
},
});
const error = await invokeTauriExpectError(page, "delete_persona", {
id: persona.id,
});
expect(error).toBe(
"Analyst is still referenced by a team. Remove it from those teams first.",
);
});

View File

@@ -514,6 +514,7 @@ test("members sidebar keeps direct pubkey entry behind a toggle", async ({
test("open-channel members can add agents from the header", async ({
page,
}) => {
await page.setViewportSize({ width: 1280, height: 420 });
await page.goto("/");
await page.getByTestId("channel-random").click();
@@ -524,6 +525,23 @@ test("open-channel members can add agents from the header", async ({
await addAgentTrigger.click();
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
await expect(page.getByTestId("add-channel-bot-dialog-header")).toBeVisible();
await expect(
page.getByTestId("add-channel-bot-dialog-scroll-area"),
).toBeVisible();
await expect(
page.getByTestId("add-channel-bot-dialog-scroll-area"),
).toHaveCSS("overflow-y", "auto");
expect(
await page
.getByTestId("add-channel-bot-dialog-scroll-area")
.evaluate(
(element) =>
element.scrollHeight > element.clientHeight &&
element.clientHeight > 0,
),
).toBe(true);
await expect(page.getByTestId("add-channel-bot-dialog-footer")).toBeVisible();
});
test("removing a channel-scoped agent also cleans up the managed agent record", async ({