From 4d44f4741f0bcde026a96c7081875c99642048a3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 8 Apr 2026 21:04:09 -0700 Subject: [PATCH] Restore chooser-based persona catalog flow (#275) --- desktop/playwright.config.ts | 1 + desktop/scripts/check-file-sizes.mjs | 5 +- desktop/src-tauri/src/commands/agents.rs | 22 +- desktop/src-tauri/src/commands/personas.rs | 62 ++- desktop/src-tauri/src/commands/teams.rs | 9 +- desktop/src-tauri/src/lib.rs | 1 + .../src-tauri/src/managed_agents/personas.rs | 160 +++---- .../src/managed_agents/personas/tests.rs | 236 +++++++++++ desktop/src-tauri/src/managed_agents/teams.rs | 51 ++- desktop/src-tauri/src/managed_agents/types.rs | 32 ++ desktop/src/features/agents/hooks.ts | 16 + .../src/features/agents/lib/catalog.test.mjs | 157 +++++++ desktop/src/features/agents/lib/catalog.ts | 64 +++ .../features/agents/lib/teamPersonas.test.mjs | 94 ++++ .../src/features/agents/lib/teamPersonas.ts | 74 ++++ .../agents/lib/useBotRecents.test.mjs | 65 +++ .../src/features/agents/lib/useBotRecents.ts | 52 +++ .../agents/ui/AddTeamToChannelDialog.tsx | 31 +- .../src/features/agents/ui/AgentsScreen.tsx | 2 +- desktop/src/features/agents/ui/AgentsView.tsx | 247 ++++++----- .../features/agents/ui/CreateNewButton.tsx | 33 ++ .../agents/ui/ManagedAgentsSection.tsx | 24 +- .../agents/ui/PersonaCatalogDetailsSheet.tsx | 155 +++++++ .../agents/ui/PersonaCatalogDialog.tsx | 81 ++++ .../agents/ui/PersonaCatalogSection.tsx | 185 ++++++++ .../ui/PersonaCatalogSelectionBadge.tsx | 26 ++ .../agents/ui/PersonaCatalogSurface.tsx | 78 ++++ .../features/agents/ui/PersonaIdentity.tsx | 62 +++ .../agents/ui/PersonaLibraryEntryPoints.tsx | 57 +++ .../features/agents/ui/PersonasSection.tsx | 242 +++++------ desktop/src/features/agents/ui/TeamDialog.tsx | 28 +- .../src/features/agents/ui/TeamsSection.tsx | 73 ++-- .../agents/ui/personaDialogState.test.mjs | 91 ++++ .../features/agents/ui/personaDialogState.ts | 85 ++++ .../features/agents/ui/personaLibraryCopy.ts | 64 +++ .../agents/ui/teamDialogSelection.test.mjs | 66 +++ .../features/agents/ui/teamDialogSelection.ts | 26 ++ .../channels/ui/AddChannelBotDialog.tsx | 400 +++++++++--------- .../channels/ui/AddChannelBotTeamsSection.tsx | 18 +- .../channels/ui/ChannelMembersBar.tsx | 31 +- desktop/src/shared/api/tauriPersonas.ts | 11 + desktop/src/shared/api/types.ts | 1 + .../src/shared/ui/chooser-dialog-content.tsx | 92 ++++ desktop/src/testing/e2eBridge.ts | 248 +++++++++-- desktop/tests/e2e/agents.spec.ts | 364 ++++++++++++++++ desktop/tests/e2e/channels.spec.ts | 18 + 46 files changed, 3259 insertions(+), 681 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/personas/tests.rs create mode 100644 desktop/src/features/agents/lib/catalog.test.mjs create mode 100644 desktop/src/features/agents/lib/catalog.ts create mode 100644 desktop/src/features/agents/lib/teamPersonas.test.mjs create mode 100644 desktop/src/features/agents/lib/teamPersonas.ts create mode 100644 desktop/src/features/agents/lib/useBotRecents.test.mjs create mode 100644 desktop/src/features/agents/ui/CreateNewButton.tsx create mode 100644 desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx create mode 100644 desktop/src/features/agents/ui/PersonaCatalogDialog.tsx create mode 100644 desktop/src/features/agents/ui/PersonaCatalogSection.tsx create mode 100644 desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx create mode 100644 desktop/src/features/agents/ui/PersonaCatalogSurface.tsx create mode 100644 desktop/src/features/agents/ui/PersonaIdentity.tsx create mode 100644 desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx create mode 100644 desktop/src/features/agents/ui/personaDialogState.test.mjs create mode 100644 desktop/src/features/agents/ui/personaDialogState.ts create mode 100644 desktop/src/features/agents/ui/personaLibraryCopy.ts create mode 100644 desktop/src/features/agents/ui/teamDialogSelection.test.mjs create mode 100644 desktop/src/features/agents/ui/teamDialogSelection.ts create mode 100644 desktop/src/shared/ui/chooser-dialog-content.tsx create mode 100644 desktop/tests/e2e/agents.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 3ed95fd510..3fc38c66f2 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ { name: "integration", testMatch: [ + "**/agents.spec.ts", "**/stream.spec.ts", "**/integration.spec.ts", "**/profile.spec.ts", diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 720c11f0b4..c7aec283d4 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 1e651c939c..6079aadd69 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -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(); diff --git a/desktop/src-tauri/src/commands/personas.rs b/desktop/src-tauri/src/commands/personas.rs index 75afaeca21..c5e881f3f7 100644 --- a/desktop/src-tauri/src/commands/personas.rs +++ b/desktop/src-tauri/src/commands/personas.rs @@ -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 { + 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 // --------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index d395c59b62..5aecaa74d3 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -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() diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2be0cfabff..f5ab177d4d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -409,6 +409,7 @@ pub fn run() { create_persona, update_persona, delete_persona, + set_persona_active, list_teams, create_team, update_team, diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index 611bdaf082..5983367969 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -403,6 +403,7 @@ fn built_in_persona_records(now: &str) -> Vec { 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, now: &str) -> (Vec, now: &str) -> (Vec, now: &str) -> (Vec 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, 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; diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs new file mode 100644 index 0000000000..10fb68cfd7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -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::>(); + 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()); +} diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index c186b2f6fe..6d9ac69e12 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -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, String> { - let resolved_personas: Vec = 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] diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 8e2397f25b..78dd969190 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -33,6 +33,8 @@ pub struct PersonaRecord { pub name_pool: Vec, #[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()); + } +} diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 3dce9f67c1..7fcccfe571 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -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(); diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs new file mode 100644 index 0000000000..8476353c17 --- /dev/null +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts new file mode 100644 index 0000000000..0fd64a820a --- /dev/null +++ b/desktop/src/features/agents/lib/catalog.ts @@ -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; +}; + +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), + }; +} diff --git a/desktop/src/features/agents/lib/teamPersonas.test.mjs b/desktop/src/features/agents/lib/teamPersonas.test.mjs new file mode 100644 index 0000000000..36d64428f0 --- /dev/null +++ b/desktop/src/features/agents/lib/teamPersonas.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/agents/lib/teamPersonas.ts b/desktop/src/features/agents/lib/teamPersonas.ts new file mode 100644 index 0000000000..fc08790e36 --- /dev/null +++ b/desktop/src/features/agents/lib/teamPersonas.ts @@ -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, +) { + 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, + 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, + }; +} diff --git a/desktop/src/features/agents/lib/useBotRecents.test.mjs b/desktop/src/features/agents/lib/useBotRecents.test.mjs new file mode 100644 index 0000000000..28bc1b2d6b --- /dev/null +++ b/desktop/src/features/agents/lib/useBotRecents.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts index 31b0322d16..129e1b0196 100644 --- a/desktop/src/features/agents/lib/useBotRecents.ts +++ b/desktop/src/features/agents/lib/useBotRecents.ts @@ -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; diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index c162e859aa..8db73e905b 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -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({ + {missingPersonaCount > 0 ? ( +

+ 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. +

+ ) : null} + {!defaultProvider && !providersQuery.isLoading ? (

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 diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 22b4b696fc..f6cca46fc0 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -12,7 +12,7 @@ export function AgentsScreen() { return ( <> diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a445a3af10..608d18c676 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -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(null); + React.useState(null); const [personaToDelete, setPersonaToDelete] = React.useState(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("library"); const teamActions = useTeamActions( { setActionNoticeMessage, setActionErrorMessage }, @@ -106,6 +120,7 @@ export function AgentsView() { const [batchImportResult, setBatchImportResult] = React.useState(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( @@ -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() {

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} /> @@ -693,6 +711,28 @@ export function AgentsView() { open={personaToDelete !== null} persona={personaToDelete} /> + { + clearPersonaFeedback("catalog"); + }} + onOpenChange={setIsCatalogDialogOpen} + onSelectPersona={(persona, active) => { + void handleSetPersonaActive(persona, active, "catalog"); + }} + open={isCatalogDialogOpen} + personas={catalogPersonas} + /> @@ -738,14 +778,15 @@ export function AgentsView() { } }} open={teamActions.teamToAddToChannel !== null} - personas={personas} + personas={libraryPersonas} team={teamActions.teamToAddToChannel} /> { + clearPersonaFeedback("library"); setBatchImportResult(null); - setActionNoticeMessage( + setPersonaNoticeMessage( `Imported ${count} persona${count !== 1 ? "s" : ""}.`, ); void queryClient.invalidateQueries({ queryKey: personasQueryKey }); diff --git a/desktop/src/features/agents/ui/CreateNewButton.tsx b/desktop/src/features/agents/ui/CreateNewButton.tsx new file mode 100644 index 0000000000..2dc8d49d05 --- /dev/null +++ b/desktop/src/features/agents/ui/CreateNewButton.tsx @@ -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 ( + + ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index 2c324d0c9b..f5f1139147 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -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.

- - - - - Create agent - +
{isLoading ? ( diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx new file mode 100644 index 0000000000..86f00a3bcd --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -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 ( + + + {persona ? ( +
+ +
+ +
+
+ + {persona.displayName} + + +
+ + {preview || "No summary available."} + +
+
+
+ + + + {feedbackNoticeMessage ? ( +

+ {feedbackNoticeMessage} +

+ ) : null} + + {feedbackErrorMessage ? ( +

+ {feedbackErrorMessage} +

+ ) : null} + +
+
+

+ Type +

+

Built-in persona

+
+
+

+ Preferred model +

+

+ {persona.model ?? "Use app default"} +

+
+
+

+ Preferred provider +

+

+ {persona.provider ?? "Use app default"} +

+
+
+ +
+

+ System prompt +

+
+                {persona.systemPrompt}
+              
+
+
+ ) : null} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx new file mode 100644 index 0000000000..bf971f284b --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -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(null); + + return ( + + onOpenChange(false)} + size="sm" + type="button" + variant="outline" + > + Done + + } + 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} + > + + + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx new file mode 100644 index 0000000000..5dd21f0bfb --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx @@ -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 ( +
+ {showHeader ? ( +
+

+ {personaCatalogCopy.title} +

+

+ {personaCatalogCopy.description} +

+
+ ) : null} + + {isLoading ? ( +
+ {["first", "second", "third", "fourth"].map((key) => ( +
+
+ +
+ + +
+
+
+ ))} +
+ ) : null} + + {!isLoading && personas.length > 0 ? ( +
+ {personas.map((persona) => { + const preview = promptPreview(persona.systemPrompt); + const isSelected = isCatalogPersonaSelected(persona); + + return ( +
+ + + + {getPersonaCatalogSelectionActionCopy(isSelected)} + +
+
+ + ); + })} + + ) : null} + + {!isLoading && personas.length === 0 ? ( +
+

{emptyTitle}

+

+ {emptyDescription} +

+
+ ) : null} + + {error ? ( +

+ {error.message} +

+ ) : null} + + {feedbackNoticeMessage ? ( +

+ {feedbackNoticeMessage} +

+ ) : null} + + {feedbackErrorMessage ? ( +

+ {feedbackErrorMessage} +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx b/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx new file mode 100644 index 0000000000..bcac5a42b0 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx @@ -0,0 +1,26 @@ +import { cn } from "@/shared/lib/cn"; + +import { personaCatalogCopy } from "./personaLibraryCopy"; + +type PersonaCatalogSelectionBadgeProps = { + isActive: boolean; +}; + +export function PersonaCatalogSelectionBadge({ + isActive, +}: PersonaCatalogSelectionBadgeProps) { + return ( + + {isActive + ? personaCatalogCopy.selectedState + : personaCatalogCopy.availableState} + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx new file mode 100644 index 0000000000..f397f60716 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx @@ -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( + 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 ( + <> + { + onClearFeedback(); + setDetailPersonaId(persona.id); + }} + personas={personas} + showHeader={showHeader} + /> + + { + if (!open) { + setDetailPersonaId(null); + } + }} + onTogglePersona={handleTogglePersona} + open={detailPersonaId !== null} + persona={detailPersona} + /> + + ); +} diff --git a/desktop/src/features/agents/ui/PersonaIdentity.tsx b/desktop/src/features/agents/ui/PersonaIdentity.tsx new file mode 100644 index 0000000000..25c45c41f6 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaIdentity.tsx @@ -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 ( +
+
+ +
+
+

+ {persona.displayName} +

+ {showBuiltInBadge ? ( + + Built-in + + ) : null} + {showPromptTooltip && preview ? ( + + + + + +

{preview}

+
+
+ ) : null} +
+
+
+
+ ); +} diff --git a/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx b/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx new file mode 100644 index 0000000000..113d38b898 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx @@ -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 ? ( + + ) : null} + + {!isHeader && onImport ? ( + + ) : null} + + ); +} diff --git a/desktop/src/features/agents/ui/PersonasSection.tsx b/desktop/src/features/agents/ui/PersonasSection.tsx index 24a5e68891..f4123cef83 100644 --- a/desktop/src/features/agents/ui/PersonasSection.tsx +++ b/desktop/src/features/agents/ui/PersonasSection.tsx @@ -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 ( -
+
{isDragOver ? (

@@ -68,9 +79,11 @@ export function PersonasSection({

-

Personas

+

+ {personaLibraryCopy.title} +

- Reusable agent templates for common roles and prompts. + {personaLibraryCopy.description}

- - - - - Create persona - +
+ 0} + isPending={isPending} + layout="header" + onCreate={onCreate} + onChooseCatalog={onChooseCatalog} + /> +
{isLoading ? ( @@ -117,103 +125,77 @@ export function PersonasSection({ {!isLoading && personas.length > 0 ? (
- {personas.map((persona) => { - const preview = promptPreview(persona.systemPrompt); + {personas.map((persona) => ( +
+
+ - return ( -
-
-
- -
-
-

- {persona.displayName} -

- {persona.isBuiltIn ? ( - - Built-in - - ) : null} - {preview ? ( - - - - - -

{preview}

-
-
- ) : null} -
-
-
- - - - - - event.preventDefault()} + + + + + event.preventDefault()} + > + {!persona.isBuiltIn ? ( onDuplicate(persona)} + onClick={() => onEdit(persona)} > - - Duplicate + + Edit + ) : null} + onDuplicate(persona)} + > + + Duplicate + + onExport(persona)} + > + + Export + + {persona.isBuiltIn ? ( onExport(persona)} + onClick={() => onDeactivate(persona)} > - - Export + + Remove from My Agents - {!persona.isBuiltIn ? ( - onDelete(persona)} - > - - Delete - - ) : null} - - -
+ ) : ( + onDelete(persona)} + > + + Delete + + )} + +
- ); - })} +
+ ))} +
+ +
+
) : null} {error ? ( @@ -248,6 +236,24 @@ export function PersonasSection({ {error.message}

) : null} + + {feedbackNoticeMessage ? ( +

+ {feedbackNoticeMessage} +

+ ) : null} + + {feedbackErrorMessage ? ( +

+ {feedbackErrorMessage} +

+ ) : null}
); } diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index e966e69891..aa68056b26 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -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( [], ); + 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({

Select the personas to include in this team.

+ {missingInitialPersonaCount > 0 ? ( +

+ 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. +

+ ) : null} {personas.length === 0 ? (

- No personas available. Create one first. + {personaCatalogCopy.teamEmptyState}

) : (
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 ( -
+
{isDragOver ? (

@@ -85,9 +80,9 @@ export function TeamsSection({

-

Teams

+

My teams

- Named groups of personas you can deploy to a channel together. + Saved groups from My Agents that you can add to a channel together.

- - - - - Create team - +
{isLoading ? ( @@ -135,9 +121,15 @@ export function TeamsSection({ {!isLoading && teams.length > 0 ? (
{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 (
event.preventDefault()} > onAddToChannel(team)} > @@ -221,14 +213,14 @@ export function TeamsSection({ Edit onDuplicate(team)} > Duplicate onExport(team)} > @@ -246,6 +238,16 @@ export function TeamsSection({
+ + {hasMissingPersonas ? ( +

+ {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. +

+ ) : null}
); })} @@ -268,7 +270,8 @@ export function TeamsSection({ >

No teams yet

- 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.

Or drop a .team.json file here to import. diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs new file mode 100644 index 0000000000..3659c08e79 --- /dev/null +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -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", + }); +}); diff --git a/desktop/src/features/agents/ui/personaDialogState.ts b/desktop/src/features/agents/ui/personaDialogState.ts new file mode 100644 index 0000000000..a3f18ada62 --- /dev/null +++ b/desktop/src/features/agents/ui/personaDialogState.ts @@ -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, + }, + }; +} diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts new file mode 100644 index 0000000000..9066afab34 --- /dev/null +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -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, + }; +} diff --git a/desktop/src/features/agents/ui/teamDialogSelection.test.mjs b/desktop/src/features/agents/ui/teamDialogSelection.test.mjs new file mode 100644 index 0000000000..cb0d31df62 --- /dev/null +++ b/desktop/src/features/agents/ui/teamDialogSelection.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/agents/ui/teamDialogSelection.ts b/desktop/src/features/agents/ui/teamDialogSelection.ts new file mode 100644 index 0000000000..924dd5aa16 --- /dev/null +++ b/desktop/src/features/agents/ui/teamDialogSelection.ts @@ -0,0 +1,26 @@ +import type { AgentPersona } from "@/shared/api/types"; + +function getAvailablePersonaIds(personas: AgentPersona[]): Set { + 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)); +} diff --git a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx index f3424f91b9..492463b7de 100644 --- a/desktop/src/features/channels/ui/AddChannelBotDialog.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotDialog.tsx @@ -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( [], @@ -401,198 +404,12 @@ export function AddChannelBotDialog({ return (

- -
- - Add agents - - Select any combination of saved personas, or turn on Generic for a - one-off custom agent. - - - -
- {resolvedBackendProviders.length > 0 ? ( -
-
Run on
- -
- ) : null} - - {isProviderMode && selectedBackendProvider ? ( -
- -

- This provider at{" "} - - {selectedBackendProvider.binaryPath} - {" "} - will receive your agent's private key. Only use providers - from trusted sources. -

-
- ) : null} - - {probeError ? ( -

- Could not probe provider: {probeError} -

- ) : null} - - {isProviderMode && probedProvider?.config_schema ? ( - - ) : null} - -
-
Runtime
- - - - - event.preventDefault()} - > - - {providers.map((provider) => ( - - {provider.label} - - ))} - - - -
- - {teams.length > 0 ? ( - - ) : null} - - { - setIncludeGeneric((current) => !current); - setSubmissionNotice(null); - setSubmissionError(null); - }} - onTogglePersona={(personaId) => { - setSelectedPersonaIds((current) => - toggleValue(current, personaId), - ); - setSubmissionNotice(null); - setSubmissionError(null); - }} - personas={personas} - selectedPersonaIds={selectedPersonaIds} - /> - - {includeGeneric ? ( - { - setHasEditedCustomName(true); - setCustomName(value); - }} - onPromptChange={setCustomPrompt} - prompt={customPrompt} - /> - ) : null} - - {selectedCount === 0 ? ( -
- Pick one or more personas, or enable Generic to add a custom - agent. -
- ) : null} - - {providersErrorMessage ? ( -

- {providersErrorMessage} -

- ) : null} - - {providerWarnings.length > 0 - ? providerWarnings.map((warning) => ( -
- -

{warning}

-
- )) - : null} - - {personasQuery.error instanceof Error ? ( -

- {personasQuery.error.message} -

- ) : null} - - {submissionNotice ? ( -

- {submissionNotice} -

- ) : null} - - {submissionError ? ( -

- {submissionError} -

- ) : null} - - {createBotsMutation.error instanceof Error ? ( -

- {createBotsMutation.error.message} -

- ) : null} -
- -
+ + + } + 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 ? ( +
+
Run on
+
+ ) : null} + + {isProviderMode && selectedBackendProvider ? ( +
+ +

+ This provider at{" "} + + {selectedBackendProvider.binaryPath} + {" "} + will receive your agent's private key. Only use providers + from trusted sources. +

+
+ ) : null} + + {probeError ? ( +

+ Could not probe provider: {probeError} +

+ ) : null} + + {isProviderMode && probedProvider?.config_schema ? ( + + ) : null} + +
+
Runtime
+ + + + + event.preventDefault()} + > + + {providers.map((provider) => ( + + {provider.label} + + ))} + + +
- + + {teams.length > 0 ? ( + + ) : null} + + { + setIncludeGeneric((current) => !current); + setSubmissionNotice(null); + setSubmissionError(null); + }} + onTogglePersona={(personaId) => { + setSelectedPersonaIds((current) => toggleValue(current, personaId)); + setSubmissionNotice(null); + setSubmissionError(null); + }} + personas={personas} + selectedPersonaIds={selectedPersonaIds} + /> + + {includeGeneric ? ( + { + setHasEditedCustomName(true); + setCustomName(value); + }} + onPromptChange={setCustomPrompt} + prompt={customPrompt} + /> + ) : null} + + {selectedCount === 0 ? ( +
+ Pick one or more personas, or enable Generic to add a custom agent. +
+ ) : null} + + {providersErrorMessage ? ( +

+ {providersErrorMessage} +

+ ) : null} + + {providerWarnings.length > 0 + ? providerWarnings.map((warning) => ( +
+ +

{warning}

+
+ )) + : null} + + {personasQuery.error instanceof Error ? ( +

+ {personasQuery.error.message} +

+ ) : null} + + {submissionNotice ? ( +

+ {submissionNotice} +

+ ) : null} + + {submissionError ? ( +

+ {submissionError} +

+ ) : null} + + {createBotsMutation.error instanceof Error ? ( +

+ {createBotsMutation.error.message} +

+ ) : null} +
); } diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index f7a5857fca..15a084f808 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -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; @@ -89,8 +81,8 @@ export function AddChannelBotTeamsSection({
{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({ ) : null}
- {resolved.map((persona) => { + {resolution.resolvedPersonas.map((persona) => { const personaInChannel = inChannelPersonaIds?.has(persona.id) ?? false; return ( diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index caf088bf22..5c9c779244 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -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>({}); // 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 = {}; diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index d105ebaebe..9fa1685f70 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -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 { await invokeTauri("delete_persona", { id }); } +export async function setPersonaActive( + id: string, + active: boolean, +): Promise { + return fromRawPersona( + await invokeTauri("set_persona_active", { id, active }), + ); +} + export async function parsePersonaFiles( fileBytes: number[], fileName: string, diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 7b4cc2d826..9be6e1db7f 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -419,6 +419,7 @@ export type AgentPersona = { model: string | null; namePool: string[]; isBuiltIn: boolean; + isActive: boolean; createdAt: string; updatedAt: string; }; diff --git a/desktop/src/shared/ui/chooser-dialog-content.tsx b/desktop/src/shared/ui/chooser-dialog-content.tsx new file mode 100644 index 0000000000..19c6463354 --- /dev/null +++ b/desktop/src/shared/ui/chooser-dialog-content.tsx @@ -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, + ChooserDialogContentProps +>( + ( + { + children, + className, + description, + footer, + footerClassName, + footerTestId, + headerClassName, + headerTestId, + scrollAreaClassName, + scrollAreaTestId, + title, + ...props + }, + ref, + ) => ( + + + {title} + {description ? ( + {description} + ) : null} + + +
+ {children} +
+ + {footer ? ( +
+ {footer} +
+ ) : null} +
+ ), +); +ChooserDialogContent.displayName = "ChooserDialogContent"; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 2eaa16772a..dc8c9c1eb8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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, + ) => Promise; __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 { 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 { } } +async function handleSetPersonaActive(args: { + id: string; + active: boolean; +}): Promise { + 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 { + return mockTeams.map((team) => ({ + ...team, + persona_ids: [...team.persona_ids], + })); +} + +async function handleCreateTeam(args: { + input: { + name: string; + description?: string; + personaIds: string[]; + }; +}): Promise { + 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 { + 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 { + mockTeams = mockTeams.filter((candidate) => candidate.id !== args.id); +} + +async function handleExportTeamToJson(args: { id: string }): Promise { + 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 }; }; }): Promise { + 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[0], ); + case "set_persona_active": + return handleSetPersonaActive( + payload as Parameters[0], + ); + case "list_teams": + return handleListTeams(); + case "create_team": + return handleCreateTeam( + payload as Parameters[0], + ); + case "update_team": + return handleUpdateTeam( + payload as Parameters[0], + ); + case "delete_team": + return handleDeleteTeam( + payload as Parameters[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; } diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts new file mode 100644 index 0000000000..09dbfa9643 --- /dev/null +++ b/desktop/tests/e2e/agents.spec.ts @@ -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( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + 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, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + 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, +) { + 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, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + + 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.", + ); +}); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9c7dc77fb4..8661cc3103 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -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 ({