From d6fe9de6787dd396a9599ab3fe69f7cc2c25acc2 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Wed, 22 Jul 2026 16:59:00 -0700 Subject: [PATCH] Refine agent catalog sharing --- .../src-tauri/src/commands/media_download.rs | 3 + .../src/commands/personas/snapshot.rs | 1 + .../src/commands/personas/snapshot/import.rs | 60 ++- .../src/commands/personas/snapshot/tests.rs | 22 +- .../src/commands/team_snapshot/tests.rs | 1 + .../src/managed_agents/agent_snapshot.rs | 12 +- .../src-tauri/src/managed_agents/personas.rs | 5 +- .../src/managed_agents/personas/tests.rs | 5 +- .../features/agents/assets/agent-outline.svg | 15 + .../src/features/agents/lib/catalog.test.mjs | 43 +- desktop/src/features/agents/lib/catalog.ts | 2 +- .../legacyPersonaCatalogVisibility.test.mjs | 27 - .../lib/legacyPersonaCatalogVisibility.ts | 28 -- .../lib/personaCatalogVisibility.test.mjs | 56 +++ .../agents/lib/personaCatalogVisibility.ts | 51 ++ .../agents/ui/AgentDefinitionDialog.tsx | 45 +- .../agents/ui/AgentDefinitionDialogFooter.tsx | 57 +++ .../agents/ui/AgentDefinitionMetadata.tsx | 55 +++ .../agents/ui/AgentSnapshotImportDialog.tsx | 8 + desktop/src/features/agents/ui/AgentsView.tsx | 53 +- .../features/agents/ui/CreateIdentityCard.tsx | 6 +- .../agents/ui/PersonaCatalogDetailsSheet.tsx | 30 +- .../agents/ui/PersonaCatalogDialog.tsx | 99 ++-- .../features/agents/ui/PersonaShareDialog.tsx | 35 ++ .../src/features/agents/ui/TeamsSection.tsx | 10 +- .../agents/ui/UnifiedAgentsSection.tsx | 66 +-- .../features/agents/ui/personaLibraryCopy.ts | 9 +- .../features/agents/ui/usePersonaActions.ts | 42 +- .../api/tauriPersonas.snapshotImport.test.mjs | 3 + desktop/src/shared/api/tauriPersonas.ts | 4 + desktop/src/testing/e2eBridge.ts | 7 +- .../e2e/agent-snapshot-recipient.spec.ts | 7 + desktop/tests/e2e/agents.spec.ts | 464 ++++++++++++++---- 33 files changed, 913 insertions(+), 418 deletions(-) create mode 100644 desktop/src/features/agents/assets/agent-outline.svg delete mode 100644 desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs delete mode 100644 desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts create mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs create mode 100644 desktop/src/features/agents/lib/personaCatalogVisibility.ts create mode 100644 desktop/src/features/agents/ui/AgentDefinitionDialogFooter.tsx create mode 100644 desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c182986cb..91429846f 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -610,6 +610,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -659,6 +660,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, @@ -704,6 +706,7 @@ mod tests { version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "test".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index e4d8a5d1b..a3ba73187 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -432,6 +432,7 @@ mod png_body_tests { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: crate::managed_agents::agent_snapshot::AgentSnapshotDefinition { name: "Agent".to_string(), + source_is_builtin: false, system_prompt: None, runtime: None, model: None, diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index ac5c0eace..a444794dd 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, MemoryLevel}, + agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -50,6 +50,13 @@ pub(super) fn reject_legacy_persona_filename(file_name: &str) -> Result<(), Stri pub struct AgentSnapshotImportPreview { /// Agent display name from the snapshot. pub display_name: String, + /// Whether the exported source definition was built in. This is display + /// metadata only; confirmed imports are always independent custom agents. + pub is_builtin: bool, + /// Preferred model from the exported definition. + pub model: Option, + /// Preferred runtime from the exported definition. + pub runtime: Option, /// System prompt, if any. pub system_prompt: Option, /// Effective avatar: data URL if present, otherwise the source URL fallback. @@ -262,32 +269,41 @@ pub async fn preview_agent_snapshot_import( reject_legacy_persona_filename(&file_name)?; let snapshot = decode_snapshot_from_bytes(&file_bytes)?; - let memory_level = match snapshot.memory.level { - MemoryLevel::None => "none", - MemoryLevel::Core => "core", - MemoryLevel::Everything => "everything", - } - .to_string(); - - Ok(AgentSnapshotImportPreview { - display_name: snapshot.profile.display_name.clone(), - system_prompt: snapshot.definition.system_prompt.clone(), - // Effective avatar: data URL wins; URL fallback if no data URL. - avatar_url: snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()), - memory_level, - memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - }) + Ok(build_agent_snapshot_import_preview(&snapshot)) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? } +pub(crate) fn build_agent_snapshot_import_preview( + snapshot: &AgentSnapshot, +) -> AgentSnapshotImportPreview { + let memory_level = match snapshot.memory.level { + MemoryLevel::None => "none", + MemoryLevel::Core => "core", + MemoryLevel::Everything => "everything", + } + .to_string(); + + AgentSnapshotImportPreview { + display_name: snapshot.profile.display_name.clone(), + is_builtin: snapshot.definition.source_is_builtin, + model: snapshot.definition.model.clone(), + runtime: snapshot.definition.runtime.clone(), + system_prompt: snapshot.definition.system_prompt.clone(), + // Effective avatar: data URL wins; URL fallback if no data URL. + avatar_url: snapshot + .profile + .avatar_data_url + .clone() + .or_else(|| snapshot.profile.avatar_url.clone()), + memory_level, + memory_entry_count: snapshot.memory.entries.len(), + source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), + has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), + } +} + // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── /// Import a `buzz-agent-snapshot v1` file as a brand-new agent. diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index b1d19f06b..c7ddbc37a 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -1,6 +1,7 @@ use super::import::{ - decode_snapshot_from_bytes, reject_legacy_persona_filename, resolve_snapshot_import_behavior, - AgentSnapshotImportResult, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, + build_agent_snapshot_import_preview, decode_snapshot_from_bytes, + reject_legacy_persona_filename, resolve_snapshot_import_behavior, AgentSnapshotImportResult, + MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; use super::*; use crate::managed_agents::{ @@ -94,6 +95,7 @@ fn make_snapshot( version: FORMAT_VERSION, definition: AgentSnapshotDefinition { name: "Test Agent".to_string(), + source_is_builtin: false, system_prompt: Some("You are helpful.".to_string()), runtime: None, model: None, @@ -551,6 +553,22 @@ fn import_preview_flags_non_empty_source_allowlist() { ); } +#[test] +fn import_preview_includes_exported_definition_metadata() { + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.source_is_builtin = true; + snapshot.definition.model = Some("claude-opus-4-5".to_string()); + snapshot.definition.runtime = Some("goose".to_string()); + let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); + let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); + + let preview = build_agent_snapshot_import_preview(&decoded); + + assert!(preview.is_builtin); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); + assert_eq!(preview.runtime.as_deref(), Some("goose")); +} + // ── Import: resolve_snapshot_import_behavior — the production selection path // // All tests below call `resolve_snapshot_import_behavior` directly. This is diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index ca7dc6183..c91d63459 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -13,6 +13,7 @@ fn member(name: &str) -> AgentSnapshot { version: crate::managed_agents::agent_snapshot::FORMAT_VERSION, definition: AgentSnapshotDefinition { name: name.to_string(), + source_is_builtin: false, system_prompt: Some(format!("{name} prompt")), runtime: Some("goose".to_string()), model: None, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index b0bf8f599..fff7494e9 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -31,7 +31,11 @@ //! - lineage ids: `persona_id`, `team_id`, `source_team`, `source_team_persona_slug`, //! `persona_source_version` //! - internal bookkeeping: `start_on_app_launch`, -//! `auto_restart_on_config_change`, `is_builtin` +//! `auto_restart_on_config_change` +//! +//! The portable `sourceIsBuiltIn` hint preserves how the exported definition +//! should be described in an import preview. It never grants built-in status +//! to the newly imported definition. //! //! These exclusions are enforced by construction (only explicit fields are //! placed into `AgentSnapshotDefinition`) and asserted by unit tests. @@ -87,6 +91,10 @@ pub enum MemoryLevel { #[serde(rename_all = "camelCase")] pub struct AgentSnapshotDefinition { pub name: String, + /// Portable source classification for import-preview metadata. Imported + /// definitions are still created as custom agents with fresh identities. + #[serde(default)] + pub source_is_builtin: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub system_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -191,6 +199,7 @@ pub fn build_snapshot( .display_name .clone() .unwrap_or_else(|| record.name.clone()), + source_is_builtin: record.is_builtin, system_prompt: record.system_prompt.clone(), runtime: record.runtime.clone(), model: record.model.clone(), @@ -913,6 +922,7 @@ mod tests { let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); assert_eq!( snapshot.definition.system_prompt.as_deref(), Some("You are a test agent.") diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index b0d874dc7..fc7e9449e 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -254,10 +254,7 @@ pub fn ensure_persona_is_active( .ok_or_else(|| format!("agent {persona_id} not found"))?; if !persona.is_active { - return Err(format!( - "{} is not in My Agents. Choose it from Agent Catalog first.", - persona.display_name - )); + return Err(format!("{} is not in My Agents.", persona.display_name)); } Ok(()) diff --git a/desktop/src-tauri/src/managed_agents/personas/tests.rs b/desktop/src-tauri/src/managed_agents/personas/tests.rs index e924345e8..1598f2606 100644 --- a/desktop/src-tauri/src/managed_agents/personas/tests.rs +++ b/desktop/src-tauri/src/managed_agents/personas/tests.rs @@ -171,10 +171,7 @@ fn ensure_persona_is_active_rejects_inactive_personas() { let err = ensure_persona_is_active(&[persona], "builtin:fizz").unwrap_err(); - assert_eq!( - err, - "Fizz is not in My Agents. Choose it from Agent Catalog first." - ); + assert_eq!(err, "Fizz is not in My Agents."); } #[test] diff --git a/desktop/src/features/agents/assets/agent-outline.svg b/desktop/src/features/agents/assets/agent-outline.svg new file mode 100644 index 000000000..b89f4c61c --- /dev/null +++ b/desktop/src/features/agents/assets/agent-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 7fa72f4f3..59ad24c1d 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -25,33 +25,38 @@ function createPersona(id, displayName, overrides = {}) { }; } -test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { +test("getCatalogPersonas hides built-ins and includes shared custom agents", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), createPersona("custom:builder", "Builder"), ]; assert.deepEqual( - getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:fizz"], + getCatalogPersonas(personas, new Set(["custom:builder"])).map( + (persona) => persona.id, + ), + ["custom:builder"], ); }); -test("getCatalogSelectionState keeps built-in selection rules in one place", () => { +test("getCatalogSelectionState only selects shared custom agents", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), createPersona("custom:builder", "Builder"), ]; - const state = getCatalogSelectionState(personas); + const state = getCatalogSelectionState( + personas, + new Set(["builtin:fizz", "custom:builder"]), + ); assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.deepEqual( state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.deepEqual( state.unselectedCatalogPersonas.map((persona) => persona.id), @@ -61,23 +66,22 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", () test("getCatalogPersonas keeps chooser order stable when selection changes", () => { const inactive = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: false }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, + createPersona("custom:fizz", "Fizz", { isActive: false }), + createPersona("custom:reviewer", "Reviewer", { isActive: true, }), ]; const active = [ - createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), - createPersona("builtin:reviewer", "Reviewer", { - isBuiltIn: true, + createPersona("custom:fizz", "Fizz", { isActive: true }), + createPersona("custom:reviewer", "Reviewer", { isActive: false, }), ]; + const shared = new Set(["custom:fizz", "custom:reviewer"]); assert.deepEqual( - getCatalogPersonas(inactive).map((persona) => persona.id), - getCatalogPersonas(active).map((persona) => persona.id), + getCatalogPersonas(inactive, shared).map((persona) => persona.id), + getCatalogPersonas(active, shared).map((persona) => persona.id), ); }); @@ -118,13 +122,16 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { }); }); -test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { +test("getPersonaLibraryState keeps built-ins in the library but not the catalog", () => { const personas = [ createPersona("builtin:fizz", "Fizz", { isBuiltIn: true, isActive: true }), createPersona("custom:builder", "Builder"), ]; - const state = getPersonaLibraryState(personas); + const state = getPersonaLibraryState( + personas, + new Set(["builtin:fizz", "custom:builder"]), + ); assert.deepEqual( state.libraryPersonas.map((persona) => persona.id), @@ -132,7 +139,7 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p ); assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:fizz"], + ["custom:builder"], ); assert.equal(state.personaLabelsById["builtin:fizz"], "Fizz"); }); diff --git a/desktop/src/features/agents/lib/catalog.ts b/desktop/src/features/agents/lib/catalog.ts index 226aafca5..95cf12c2c 100644 --- a/desktop/src/features/agents/lib/catalog.ts +++ b/desktop/src/features/agents/lib/catalog.ts @@ -28,7 +28,7 @@ export function isPersonaVisibleInCatalog( persona: AgentPersona, sharedCatalogPersonaIds: ReadonlySet = new Set(), ) { - return persona.isBuiltIn || sharedCatalogPersonaIds.has(persona.id); + return !persona.isBuiltIn && sharedCatalogPersonaIds.has(persona.id); } export function getCatalogPersonas( diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs deleted file mode 100644 index 9439d4a36..000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { clearLegacyPersonaCatalogVisibility } from "./legacyPersonaCatalogVisibility.ts"; - -test("clearLegacyPersonaCatalogVisibility removes the retired preference", () => { - const removedKeys = []; - - clearLegacyPersonaCatalogVisibility({ - removeItem(key) { - removedKeys.push(key); - }, - }); - - assert.deepEqual(removedKeys, ["buzz-persona-catalog-visibility-v1"]); -}); - -test("clearLegacyPersonaCatalogVisibility ignores unavailable storage", () => { - assert.doesNotThrow(() => clearLegacyPersonaCatalogVisibility(null)); - assert.doesNotThrow(() => - clearLegacyPersonaCatalogVisibility({ - removeItem() { - throw new Error("storage unavailable"); - }, - }), - ); -}); diff --git a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts b/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts deleted file mode 100644 index 38b2d5d97..000000000 --- a/desktop/src/features/agents/lib/legacyPersonaCatalogVisibility.ts +++ /dev/null @@ -1,28 +0,0 @@ -const LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = - "buzz-persona-catalog-visibility-v1"; - -/** - * Removes the retired custom-persona catalog preference so it cannot resurface - * agents after the visibility control has been removed. - */ -export function clearLegacyPersonaCatalogVisibility( - storage?: Pick | null, -) { - let targetStorage = storage; - if (targetStorage === undefined) { - if (typeof window === "undefined") return; - - try { - targetStorage = window.localStorage; - } catch { - return; - } - } - if (!targetStorage) return; - - try { - targetStorage.removeItem(LEGACY_PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); - } catch { - // Catalog cleanup is best-effort and should not block the agents view. - } -} diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs new file mode 100644 index 000000000..5259271e0 --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + readSharedCatalogPersonaIds, + writeSharedCatalogPersonaIds, +} from "./personaCatalogVisibility.ts"; + +test("catalog visibility reads stored persona ids", () => { + const storage = { + getItem: () => JSON.stringify(["custom:analyst", 42, "custom:writer"]), + }; + + assert.deepEqual(readSharedCatalogPersonaIds(storage), [ + "custom:analyst", + "custom:writer", + ]); +}); + +test("catalog visibility tolerates unavailable and invalid storage", () => { + assert.deepEqual(readSharedCatalogPersonaIds(null), []); + assert.deepEqual( + readSharedCatalogPersonaIds({ getItem: () => "not-json" }), + [], + ); + assert.deepEqual(readSharedCatalogPersonaIds({ getItem: () => "{}" }), []); + assert.deepEqual( + readSharedCatalogPersonaIds({ + getItem: () => { + throw new Error("unavailable"); + }, + }), + [], + ); +}); + +test("catalog visibility persists persona ids without blocking on storage errors", () => { + let storedKey = ""; + let storedValue = ""; + writeSharedCatalogPersonaIds(["custom:analyst"], { + setItem: (key, value) => { + storedKey = key; + storedValue = value; + }, + }); + + assert.equal(storedKey, "buzz-persona-catalog-visibility-v1"); + assert.equal(storedValue, '["custom:analyst"]'); + assert.doesNotThrow(() => + writeSharedCatalogPersonaIds(["custom:analyst"], { + setItem: () => { + throw new Error("unavailable"); + }, + }), + ); +}); diff --git a/desktop/src/features/agents/lib/personaCatalogVisibility.ts b/desktop/src/features/agents/lib/personaCatalogVisibility.ts new file mode 100644 index 000000000..43b30036e --- /dev/null +++ b/desktop/src/features/agents/lib/personaCatalogVisibility.ts @@ -0,0 +1,51 @@ +const PERSONA_CATALOG_VISIBILITY_STORAGE_KEY = + "buzz-persona-catalog-visibility-v1"; + +function resolveStorage( + storage: Pick | null | undefined, +): Pick | null { + if (storage !== undefined) return storage; + if (typeof window === "undefined") return null; + + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readSharedCatalogPersonaIds( + storage?: Pick | null, +): string[] { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return []; + + try { + const raw = targetStorage.getItem(PERSONA_CATALOG_VISIBILITY_STORAGE_KEY); + if (!raw) return []; + + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + + return parsed.filter((id): id is string => typeof id === "string"); + } catch { + return []; + } +} + +export function writeSharedCatalogPersonaIds( + ids: readonly string[], + storage?: Pick | null, +): void { + const targetStorage = resolveStorage(storage); + if (!targetStorage) return; + + try { + targetStorage.setItem( + PERSONA_CATALOG_VISIBILITY_STORAGE_KEY, + JSON.stringify(ids), + ); + } catch { + // Catalog visibility is a convenience setting and should not block sharing. + } +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d69028cfd..049fa8230 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -8,7 +8,6 @@ import type { UpdatePersonaInput, } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Dialog } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; @@ -83,6 +82,7 @@ import { } from "./agentAiConfigurationPolicy"; import { useProviderApiKeyFieldState } from "./providerApiKeyFieldState"; import { buildRuntimeModelProviderPayload } from "./agentDefinitionSubmitPayload"; +import { AgentDefinitionDialogFooter } from "./AgentDefinitionDialogFooter"; type AgentDefinitionDialogProps = { open: boolean; @@ -734,41 +734,14 @@ export function AgentDefinitionDialog({ headerClassName="pb-2" title={title} footer={ -
-
- {submitBlockReason ? ( -

- {submitBlockReason} -

- ) : null} -
- -
- - -
-
+ handleOpenChange(false)} + submitBlockReason={displayName.trim() ? submitBlockReason : null} + submitLabel={submitLabel} + /> } >
void; + submitBlockReason: string | null; + submitLabel: string; +}; + +export function AgentDefinitionDialogFooter({ + canSubmit, + isAvatarUploadPending, + isPending, + onCancel, + submitBlockReason, + submitLabel, +}: AgentDefinitionDialogFooterProps) { + return ( +
+
+ {submitBlockReason ? ( +

+ {submitBlockReason} +

+ ) : null} +
+ +
+ + +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx new file mode 100644 index 000000000..50109143c --- /dev/null +++ b/desktop/src/features/agents/ui/AgentDefinitionMetadata.tsx @@ -0,0 +1,55 @@ +import { cn } from "@/shared/lib/cn"; + +export function AgentDefinitionMetadata({ + className, + isBuiltIn, + model, + runtime, +}: { + className?: string; + isBuiltIn: boolean; + model: string | null; + runtime: string | null; +}) { + const items = [ + { + label: "Type", + value: isBuiltIn ? "Built-in agent" : "Custom agent", + }, + { + label: "Preferred model", + value: model ?? "Use app default", + }, + { + label: "Preferred runtime", + value: runtime ?? "Use app default", + }, + ]; + + return ( +
+
+ {items.map((item, index) => ( +
0 && + "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", + )} + key={item.label} + > +

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ ); +} diff --git a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx index ad1219310..4a9584dfb 100644 --- a/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx +++ b/desktop/src/features/agents/ui/AgentSnapshotImportDialog.tsx @@ -15,6 +15,8 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; + // ── Types ───────────────────────────────────────────────────────────────────── type ImportPhase = "preview" | "confirming" | "result"; @@ -164,6 +166,12 @@ function PreviewBody({ ) : null} + +

A new agent will be created with a fresh keypair. The imported agent is independent of the source — identity never travels. diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f750e266d..352566f1a 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX } from "lucide-react"; +import { OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -21,7 +21,10 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { + AGENT_CARD_GRID_COLUMNS_CLASS, + UnifiedAgentsSection, +} from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -71,11 +74,14 @@ export function AgentsView() { const runningAgentCount = agents.managedAgents.filter((agent) => isManagedAgentActive(agent), ).length; - // Show the resolved effective model, not just the structured `model` field: - // most providers persist the model as a provider env var (e.g. DATABRICKS_MODEL) - // or inherit a baked build default, leaving `globalConfig.model` null. - const configuredGlobalModel = inheritedDefaults.model.value; - + const hasSavedAgentDefaults = Boolean( + globalConfig.preferred_runtime?.trim() || + globalConfig.provider?.trim() || + globalConfig.model?.trim() || + Object.values(globalConfig.env_vars).some( + (value) => value.trim().length > 0, + ), + ); // biome-ignore lint/correctness/useExhaustiveDependencies: mount-only; personas.handleImportSnapshotFile and teamActions.handleImportTeamSnapshotFile are stable React.useEffect(() => { // Consume a snapshot import that was enqueued before navigation (e.g. from @@ -107,18 +113,23 @@ export function AgentsView() { return ( <>

-
+
{runningAgentCount > 0 ? ( @@ -139,7 +150,7 @@ export function AgentsView() { description="Set up and manage your agents." title="Agents" /> -
+
0} personas={personas.libraryPersonas} personasError={ personas.personasQuery.error instanceof Error @@ -186,10 +196,8 @@ export function AgentsView() { } isPersonasLoading={personas.personasQuery.isLoading} isPersonasPending={personas.isPending} - onCreatePersona={() => { - openUnifiedCreate(); - }} - onChooseCatalog={personas.openCatalog} + onCreatePersona={openUnifiedCreate} + onDiscoverPersonas={personas.openCatalog} onDuplicatePersona={personas.openDuplicate} onEditPersona={personas.openEdit} onSharePersona={personas.openShare} @@ -341,8 +349,19 @@ export function AgentsView() { ) : null} {personas.personaToShare ? ( { + const shareTarget = personas.personaToShare; + if (!shareTarget) return; + personas.setPersonaCatalogVisibility(shareTarget.persona, visible); + }} onExport={() => { const shareTarget = personas.personaToShare; if (!shareTarget) return; diff --git a/desktop/src/features/agents/ui/CreateIdentityCard.tsx b/desktop/src/features/agents/ui/CreateIdentityCard.tsx index 70d063098..4fdd6db26 100644 --- a/desktop/src/features/agents/ui/CreateIdentityCard.tsx +++ b/desktop/src/features/agents/ui/CreateIdentityCard.tsx @@ -6,7 +6,7 @@ import { cn } from "@/shared/lib/cn"; type CreateIdentityCardProps = React.ButtonHTMLAttributes & { ariaLabel: string; dataTestId: string; - label: string; + label?: string; }; export const CreateIdentityCard = React.forwardRef< @@ -30,7 +30,9 @@ export const CreateIdentityCard = React.forwardRef< > - {label} + {label ? ( + {label} + ) : null} ); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx index 6aab3096c..34d8fd8b1 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -11,6 +11,7 @@ import { SheetTitle, } from "@/shared/ui/sheet"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; import { getPersonaCatalogDetailSelectionCopy, @@ -121,30 +122,11 @@ export function PersonaCatalogDetailsSheet({

) : null} -
-
-

- Type -

-

Built-in agent

-
-
-

- Preferred model -

-

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

-
-
-

- Preferred runtime -

-

- {persona.runtime ?? "Use app default"} -

-
-
+

diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index 0d6b5583f..7d6afc564 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -11,6 +11,8 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content"; import { Markdown } from "@/shared/ui/markdown"; import { Skeleton } from "@/shared/ui/skeleton"; +import agentOutlineUrl from "../assets/agent-outline.svg"; +import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata"; import { PersonaAddedBy } from "./PersonaAddedBy"; import { personaCatalogCopy } from "./personaLibraryCopy"; @@ -28,7 +30,7 @@ type PersonaCatalogDialogProps = { }; const agentInstructionMarkdownClassName = [ - "mt-3 leading-6 text-muted-foreground [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", + "mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground", "[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground", "[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground", "[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground", @@ -154,6 +156,31 @@ function PersonaCatalogChooser({ selectedPersona, selectedPersonaId, }: PersonaCatalogChooserProps) { + if (!isLoading && personas.length === 0 && !error) { + return ( +

+
+ +

+ {personaCatalogCopy.emptyCatalogTitle} +

+

+ {personaCatalogCopy.emptyCatalogDescription} +

+
+
+ ); + } + return (
@@ -200,9 +227,9 @@ function PersonaCatalogChooser({
-
+
{isLoading ? : null} @@ -211,19 +238,6 @@ function PersonaCatalogChooser({ ) : null} - {!isLoading && personas.length === 0 && !error ? ( -
-
-

- {personaCatalogCopy.emptyCatalogTitle} -

-

- {personaCatalogCopy.emptyCatalogDescription} -

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

{error.message} @@ -263,7 +277,7 @@ function PersonaCatalogChooser({ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { return ( -

+
- -
+

Agent instruction

@@ -309,36 +312,6 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { ); } -function PersonaCatalogMetaGroup({ - items, -}: { - items: { label: string; value: string }[]; -}) { - return ( -
-
- {items.map((item, index) => ( -
0 && - "border-t border-border/60 sm:border-t-0 sm:before:absolute sm:before:bottom-3 sm:before:left-0 sm:before:top-3 sm:before:w-px sm:before:bg-border/70", - )} - key={item.label} - > -

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- ); -} - function PersonaCatalogListSkeleton() { return (
diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index b6d3fafd3..8f34ef196 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { AlertCircle, + BookUser, Check, ChevronRight, Download, @@ -41,6 +42,7 @@ import { } from "@/shared/ui/dialog"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -51,8 +53,10 @@ import { resolveSnapshotAvatarPng } from "./snapshotAvatarPng"; import { useSnapshotSendController } from "./useSnapshotSendController"; type PersonaShareDialogProps = { + isCatalogVisible: boolean; isPending: boolean; linkedAgentPubkey: string | null; + onCatalogVisibilityChange: (visible: boolean) => void; onExport: () => void; onOpenChange: (open: boolean) => void; open: boolean; @@ -60,6 +64,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -231,6 +236,7 @@ function ShareLevelControl({ } export function SnapshotShareDialog({ + beforeExport, displayName, encodeSnapshot, hasMemoryOptions, @@ -691,6 +697,7 @@ export function SnapshotShareDialog({
+ {beforeExport}
@@ -419,50 +418,37 @@ function firstAvatarUrl( } function NewAgentCard({ - canChooseCatalog, - isPersonasPending, - openFilePicker, - onChooseCatalog, - onCreatePersona, + isPending, + onCreate, + onDiscover, + onImport, }: { - canChooseCatalog: boolean; - isPersonasPending: boolean; - openFilePicker: () => void; - onChooseCatalog: () => void; - onCreatePersona: () => void; + isPending: boolean; + onCreate: () => void; + onDiscover: () => void; + onImport: () => void; }) { return ( - + event.preventDefault()} > - - Create from scratch + + Create agent + + + Discover agents - {canChooseCatalog ? ( - - Choose from catalog - - ) : null} - Import agent snapshot + Import diff --git a/desktop/src/features/agents/ui/personaLibraryCopy.ts b/desktop/src/features/agents/ui/personaLibraryCopy.ts index 53c5e7a16..79ddad1c3 100644 --- a/desktop/src/features/agents/ui/personaLibraryCopy.ts +++ b/desktop/src/features/agents/ui/personaLibraryCopy.ts @@ -14,14 +14,13 @@ export const personaLibraryCopy = { export const personaCatalogCopy = { title: "Agent Catalog", - description: "Browse built-in agents and add them to My Agents.", + description: "Browse agents shared to this relay.", dialogTitle: "Agent Catalog", - dialogDescription: "Browse built-in agents and add them to My Agents.", + dialogDescription: "Browse agents shared to this relay.", emptyTitle: "You're all set", emptyDescription: "Everything in Agent Catalog is already in My Agents.", - emptyCatalogDescription: - "New agents will show up here when the app ships more options.", - emptyCatalogTitle: "No agents in the catalog yet", + emptyCatalogDescription: "Shared agents will appear here.", + emptyCatalogTitle: "No agents are being shared", detailsAction: "View details", selectAction: "Choose", deselectAction: "Deselect", diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 54535d121..494b4ae1c 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -18,7 +18,10 @@ import { type AgentSnapshotImportResult, } from "@/features/agents/hooks"; import { getPersonaLibraryState } from "@/features/agents/lib/catalog"; -import { clearLegacyPersonaCatalogVisibility } from "@/features/agents/lib/legacyPersonaCatalogVisibility"; +import { + readSharedCatalogPersonaIds, + writeSharedCatalogPersonaIds, +} from "@/features/agents/lib/personaCatalogVisibility"; import { useCreatedAgentChannelAttachment } from "@/features/agents/useCreatedAgentChannelAttachment"; import type { SnapshotFormat, @@ -88,6 +91,9 @@ export function usePersonaActions() { const [snapshotImportConfirmError, setSnapshotImportConfirmError] = React.useState(null); const [isCatalogDialogOpen, setIsCatalogDialogOpen] = React.useState(false); + const [sharedCatalogPersonaIds, setSharedCatalogPersonaIds] = React.useState< + string[] + >(readSharedCatalogPersonaIds); const [personaNoticeMessage, setPersonaNoticeMessage] = React.useState< string | null >(null); @@ -101,9 +107,10 @@ export function usePersonaActions() { React.useState(false); const personas = personasQuery.data ?? []; - React.useEffect(() => { - clearLegacyPersonaCatalogVisibility(); - }, []); + const sharedCatalogPersonaIdSet = React.useMemo( + () => new Set(sharedCatalogPersonaIds), + [sharedCatalogPersonaIds], + ); const availableRuntimes = React.useMemo( () => (acpRuntimesQuery.data ?? []).filter( @@ -113,8 +120,8 @@ export function usePersonaActions() { [acpRuntimesQuery.data], ); const { catalogPersonas, libraryPersonas, personaLabelsById } = React.useMemo( - () => getPersonaLibraryState(personas), - [personas], + () => getPersonaLibraryState(personas, sharedCatalogPersonaIdSet), + [personas, sharedCatalogPersonaIdSet], ); function clearFeedback( @@ -386,6 +393,27 @@ export function usePersonaActions() { ); } + function setPersonaCatalogVisibility( + persona: AgentPersona, + visible: boolean, + ) { + if (persona.isBuiltIn) return; + + clearFeedback("library"); + setSharedCatalogPersonaIds((current) => { + const next = new Set(current); + if (visible) { + next.add(persona.id); + } else { + next.delete(persona.id); + } + + const ids = Array.from(next); + writeSharedCatalogPersonaIds(ids); + return ids; + }); + } + const isPending = isPersonaSubmitPending || createPersonaMutation.isPending || @@ -431,6 +459,8 @@ export function usePersonaActions() { personaToExportSnapshot, setPersonaToExportSnapshot, handleExportSnapshot, + setPersonaCatalogVisibility, + sharedCatalogPersonaIdSet, clearFeedback, snapshotImportState, snapshotImportResult, diff --git a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs index 8503a7934..d4e0b1d5f 100644 --- a/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs +++ b/desktop/src/shared/api/tauriPersonas.snapshotImport.test.mjs @@ -12,6 +12,9 @@ import test from "node:test"; function makePreview(overrides = {}) { return { displayName: "Test Agent", + isBuiltIn: false, + model: null, + runtime: null, systemPrompt: "You are helpful.", avatarUrl: null, memoryLevel: "none", diff --git a/desktop/src/shared/api/tauriPersonas.ts b/desktop/src/shared/api/tauriPersonas.ts index bd3be9240..aa5bc8ed9 100644 --- a/desktop/src/shared/api/tauriPersonas.ts +++ b/desktop/src/shared/api/tauriPersonas.ts @@ -172,6 +172,10 @@ export async function encodeAgentSnapshotForSend( /** Preview returned by `preview_agent_snapshot_import` before any write. */ export type AgentSnapshotImportPreview = { displayName: string; + /** Source classification shown in the preview; imports remain custom. */ + isBuiltIn: boolean; + model: string | null; + runtime: string | null; systemPrompt: string | null; /** Effective avatar: data URL if present, source URL fallback otherwise. */ avatarUrl: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 33e9e02ba..3c5f8f8a4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -7282,9 +7282,7 @@ function ensureMockPersonaIsActive(personaId: string) { throw new Error(`agent ${personaId} not found`); } if (!persona.is_active) { - throw new Error( - `${persona.display_name} is not in My Agents. Choose it from Agent Catalog first.`, - ); + throw new Error(`${persona.display_name} is not in My Agents.`); } } @@ -9957,6 +9955,9 @@ export function maybeInstallE2eTauriMocks() { // Return a minimal preview — no writes performed. return { displayName: "Imported Agent", + isBuiltIn: true, + model: "claude-opus-4-5", + runtime: "goose", systemPrompt: null, avatarUrl: null, memoryLevel: "none", diff --git a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts index a20c649df..9ce80593f 100644 --- a/desktop/tests/e2e/agent-snapshot-recipient.spec.ts +++ b/desktop/tests/e2e/agent-snapshot-recipient.spec.ts @@ -271,6 +271,13 @@ test("recipient_import_navigates_to_agents_and_opens_preview", async ({ // Decoded display name must appear. await expect(dialog).toContainText("Imported Agent"); + const metadata = dialog.getByTestId("agent-definition-metadata"); + await expect(metadata).toContainText("Type"); + await expect(metadata).toContainText("Built-in agent"); + await expect(metadata).toContainText("Preferred model"); + await expect(metadata).toContainText("claude-opus-4-5"); + await expect(metadata).toContainText("Preferred runtime"); + await expect(metadata).toContainText("goose"); }); // ── Confirm imports the agent ───────────────────────────────────────────────── diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 3c0c60d93..25d29adea 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -32,7 +32,9 @@ async function gotoApp(page: import("@playwright/test").Page) { async function openPersonaCatalog(page: import("@playwright/test").Page) { await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Choose from catalog" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); } async function getCatalogOrder(page: import("@playwright/test").Page) { @@ -50,12 +52,16 @@ async function selectCatalogPersona( await page.getByTestId(`persona-catalog-list-item-${personaId}`).click(); } -async function useCatalogPersona( +async function sharePersonaToCatalog( page: import("@playwright/test").Page, - personaId: string, + displayName: string, ) { + await page.getByLabel(`Open actions for ${displayName}`).click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await page.getByTestId("persona-share-show-in-catalog").click(); await page - .getByTestId(`persona-catalog-use-agent-target-${personaId}`) + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) .click(); } @@ -154,78 +160,72 @@ async function invokeTauriExpectError( ); } -test("built-in personas are used from the catalog dialog", async ({ page }) => { +test("catalog hides built-ins and shows the shared-agent empty state", async ({ + page, +}) => { await page.setViewportSize({ width: 1280, height: 420 }); + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); await expect(page.getByTestId("agents-library-personas")).toBeVisible(); - await openPersonaCatalog(page); - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( - "Fizz", - ); for (const personaName of ["Fizz", "Honey", "Bumble"]) { - await expect(page.getByTestId("persona-catalog-dialog")).toContainText( + await expect(page.getByTestId("agents-library-personas")).toContainText( personaName, ); } - for (const retiredPersonaName of [ - "Product Strategist", - "Implementation Partner", - "QA Reviewer", - "Work Coordinator", - "Support Guide", - "Experiment Designer", - ]) { + + await openPersonaCatalog(page); + for (const personaName of ["Fizz", "Honey", "Bumble"]) { await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - retiredPersonaName, + personaName, ); } 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"); - const catalogScrollAreaMetrics = await page - .getByTestId("persona-catalog-dialog-scroll-area") - .evaluate((element) => ({ - clientHeight: element.clientHeight, - scrollHeight: element.scrollHeight, - })); - expect(catalogScrollAreaMetrics.clientHeight).toBeGreaterThan(0); - expect(catalogScrollAreaMetrics.scrollHeight).toBeGreaterThanOrEqual( - catalogScrollAreaMetrics.clientHeight, - ); await expect(page.getByTestId("persona-catalog-dialog-body")).toBeVisible(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Done", - ); - await expect(page.getByRole("tooltip")).toHaveCount(0); - const initialCatalogOrder = await getCatalogOrder(page); - - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); + const emptyState = page.getByTestId("persona-catalog-empty-state"); + await expect(emptyState).toContainText("No agents are being shared"); await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), + emptyState.getByTestId("persona-catalog-empty-agent-artwork"), ).toBeVisible(); + await expect( + page.locator('[data-testid^="persona-catalog-list-item-"]'), + ).toHaveCount(0); + await expect( + page.getByTestId("persona-catalog-use-agent-target"), + ).toHaveCount(0); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await page.getByLabel("Open actions for Fizz").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(page.getByTestId("persona-share-catalog")).toHaveCount(0); + await expect(page.getByTestId("persona-share-show-in-catalog")).toHaveCount( + 0, ); - await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toHaveText("Added to My Agents"); - await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); - await expect(page.getByTestId("persona-catalog-dialog")).not.toContainText( - "Delete", +}); + +test("catalog empty state remains available after reopening", async ({ + page, +}) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toBeVisible(); + + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await expect(page.getByTestId("persona-catalog-dialog")).not.toBeVisible(); + await openPersonaCatalog(page); + await expect(page.getByTestId("persona-catalog-empty-state")).toContainText( + "No agents are being shared", ); - await expect.poll(() => getCatalogOrder(page)).toEqual(initialCatalogOrder); }); test("built-in persona edits persist", async ({ page }) => { @@ -269,7 +269,9 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ await gotoApp(page); await page.getByTestId("open-agents-view").click(); await page.getByTestId("new-agent-card").click(); - await page.getByRole("menuitem", { name: "Create from scratch" }).click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); await expect(page.getByTestId("persona-dialog")).toBeVisible(); await page.getByLabel("Add avatar").click(); @@ -306,70 +308,239 @@ test("agent avatar emoji picker scrolls inside its popover", async ({ .toBeGreaterThan(before); }); -test("agent catalog can reopen from the populated library header", async ({ +test("the new agent card offers create, discover, and import", async ({ page, }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:fizz", "builtin:honey", "builtin:bumble"], + personas: [ + { + id: "custom:code-reviewer", + displayName: "Code Reviewer", + systemPrompt: "Review code changes.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + const newAgentCard = page.getByTestId("new-agent-card"); + await expect(newAgentCard).toHaveText(""); + await expect(newAgentCard.locator(".lucide-plus")).toBeVisible(); + + const personaCards = page.locator('[data-testid^="persona-agent-row-"]'); + await expect(personaCards.first()).toBeVisible(); + const headerBox = await page + .getByRole("heading", { level: 1, name: "Agents" }) + .locator("../..") + .boundingBox(); + const cardBoxes = await personaCards.evaluateAll((cards) => + cards.map((card) => { + const box = card.getBoundingClientRect(); + return { right: box.right, top: box.top }; + }), ); + const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); + const rightmostFirstRowCard = Math.max( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ right }) => right), + ); + expect(headerBox).not.toBeNull(); + expect( + Math.abs( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, + ), + ).toBeLessThan(1); - await page.keyboard.press("Escape"); - await openPersonaCatalog(page); - - await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible(); - await selectCatalogPersona(page, "builtin:fizz"); + await newAgentCard.click(); await expect( - page.getByTestId("persona-catalog-use-agent-target-builtin:fizz"), - ).toBeDisabled(); + page.getByRole("menuitem", { exact: true, name: "Create agent" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Discover agents" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); + await page + .getByRole("menuitem", { exact: true, name: "Discover agents" }) + .click(); + await expect(page.getByTestId("persona-catalog-dialog")).toBeVisible(); + await page + .getByTestId("persona-catalog-dialog") + .getByRole("button", { name: "Close" }) + .click(); + await newAgentCard.click(); + await page + .getByRole("menuitem", { exact: true, name: "Create agent" }) + .click(); + + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByTestId("import-agent-snapshot-dialog-action"), + ).toHaveCount(0); + await expect(dialog).not.toContainText("Enter a name for this agent."); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await newAgentCard.click(); + const fileChooserPromise = page.waitForEvent("filechooser"); + await page.getByRole("menuitem", { exact: true, name: "Import" }).click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ + buffer: Buffer.from("{}"), + mimeType: "application/json", + name: "imported.agent.json", + }); + await expect(page.getByTestId("agent-snapshot-import-dialog")).toBeVisible(); +}); + +test("the new team card offers create and import", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const newTeamCard = page.getByTestId("new-team-card"); + await expect(newTeamCard).toHaveText(""); + await expect(newTeamCard.locator(".lucide-plus")).toBeVisible(); + + await newTeamCard.click(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Create team" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { exact: true, name: "Import" }), + ).toBeVisible(); +}); + +test("agent defaults stays in the header without an actions menu", async ({ + page, +}) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + auth_status: { status: "logged_in" }, + availability: "available", + avatar_url: "", + binary_path: "/usr/local/bin/codex", + can_auto_install: false, + command: "codex", + default_args: [], + id: "codex", + install_hint: "", + install_instructions_url: "https://example.com", + label: "Codex", + login_hint: null, + mcp_command: null, + node_required: false, + underlying_cli_path: null, + }, + ], + globalAgentConfig: { + env_vars: {}, + model: "gpt-5.5[high]", + preferred_runtime: "codex", + provider: null, + }, + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-header-actions-button")).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Import agent" }), + ).toHaveCount(0); + + const defaultsButton = page.getByTestId("agent-defaults-button"); + await expect(defaultsButton).toHaveText("Agent defaults"); + await defaultsButton.click(); + const defaultsDialog = page.getByTestId("agent-ai-defaults-dialog"); + await expect(defaultsDialog).toBeVisible(); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toHaveAttribute("data-value", "codex"); + await expect( + defaultsDialog.getByTestId("global-agent-default-harness"), + ).toContainText("Codex"); + await expect( + defaultsDialog.getByTestId("global-agent-model"), + ).toHaveAttribute("data-value", "gpt-5.5[high]"); + await expect(defaultsDialog.getByTestId("global-agent-model")).toContainText( + "gpt-5.5[high]", + ); + await page.keyboard.press("Escape"); + await expect(defaultsDialog).toHaveCount(0); +}); + +test("unconfigured agent defaults use the setup label", async ({ page }) => { + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + await expect(page.getByTestId("agent-defaults-button")).toHaveText( + "Set agent defaults", + ); }); test("agent catalog chooser order stays stable when selection changes", async ({ page, }) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:builder", + displayName: "Builder", + systemPrompt: "Build the requested change.", + }, + { + id: "custom:reviewer", + displayName: "Reviewer", + systemPrompt: "Review the requested change.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Builder"); + await sharePersonaToCatalog(page, "Reviewer"); await openPersonaCatalog(page); const before = await getCatalogOrder(page); - - await selectCatalogPersona(page, "builtin:fizz"); - await useCatalogPersona(page, "builtin:fizz"); - await expect( - page - .locator("[data-sonner-toast]") - .filter({ hasText: "Selected Fizz for My Agents." }), - ).toBeVisible(); - + await selectCatalogPersona(page, "custom:reviewer"); expect(await getCatalogOrder(page)).toEqual(before); }); test("catalog detail pane shows the full persona details", async ({ page }) => { + const personaId = "custom:researcher"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Researcher", + systemPrompt: "Research the question and cite the evidence.", + }, + ], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); + await sharePersonaToCatalog(page, "Researcher"); await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:fizz"); + await selectCatalogPersona(page, personaId); const useAgentTarget = page.getByTestId( - "persona-catalog-use-agent-target-builtin:fizz", + `persona-catalog-use-agent-target-${personaId}`, ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Fizz", - ); - await expect( - page.getByTestId("persona-catalog-detail-pane"), - ).not.toContainText("Added by You"); - await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "You are Fizz.", + "Researcher", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Built-in agent", + "Added by You", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Research the question and cite the evidence.", + ); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Custom agent", ); await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( "Preferred model", @@ -382,14 +553,10 @@ test("catalog detail pane shows the full persona details", async ({ page }) => { ); await expect(useAgentTarget).toHaveAttribute( "aria-label", - "Add Fizz from Agent Catalog", - ); - await expect(useAgentTarget).toHaveText("Add agent"); - - await useAgentTarget.click(); - await expect(page.getByTestId("agents-library-personas")).toContainText( - "Fizz", + "Researcher is already in My Agents", ); + await expect(useAgentTarget).toHaveText("Added to My Agents"); + await expect(useAgentTarget).toBeDisabled(); }); type AgentShareCommand = { command: string; payload: unknown }; @@ -667,18 +834,25 @@ test("custom personas share with people and keep export separate", async ({ ).toHaveCount(0); await expect(shareDialog.getByText("Memories")).toHaveCount(0); await expect(shareDialog.getByText("File format")).toHaveCount(0); - await expect(page.getByText("Show in my catalog")).toHaveCount(0); const shareMainCard = page.getByTestId("persona-share-main-card"); const exportAgentRow = page.getByTestId("persona-share-export"); + const catalogSection = page.getByTestId("persona-share-catalog"); + const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); await expect(exportAgentRow).toHaveText("Export agent"); + await expect(catalogSection).toContainText("Share to catalog"); + await expect(catalogSection).toContainText( + "Let anyone on this relay find and use this agent.", + ); + await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( 0, ); await waitForAnimations(page); const shareMainCardBox = await shareMainCard.boundingBox(); const exportAgentRowBox = await exportAgentRow.boundingBox(); + const catalogSectionBox = await catalogSection.boundingBox(); const shareCardGap = - (exportAgentRowBox?.y ?? 0) - + (catalogSectionBox?.y ?? 0) - ((shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0)); expect(shareCardGap).toBeGreaterThanOrEqual(12); expect(shareCardGap).toBeLessThan(16); @@ -700,6 +874,9 @@ test("custom personas share with people and keep export separate", async ({ expect(exportAgentRowShadow).toBe(shareMainCardShadow); expect(exportAgentRowShadow).not.toBe("none"); await expect(exportAgentRow).toHaveCSS("position", "relative"); + expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 12, + ); await expect(page.getByTestId("agent-snapshot-export-dialog")).toHaveCount(0); await exportAgentRow.click(); @@ -1034,6 +1211,83 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog).toHaveCount(0); }); +test("custom personas can be shared to the relay catalog", async ({ page }) => { + const personaId = "custom:catalog-analyst"; + await installMockBridge(page, { + personas: [ + { + id: personaId, + displayName: "Catalog Analyst", + systemPrompt: `## Design System And Styling + +- For design-system changes, check the local guidance in \`DESIGN.md\`, \`docs/color-token-mapping.md\`, \`src/shared/ui/AGENTS.md\`, and \`src/features/design-system/AGENTS.md\` before judging the implementation. +- Check every changed visual surface in both light and dark mode. Missing dark-mode support is a review issue, not visual polish. +- Review the selected changes and explain whether \`git diff --cached --name-only --some-extremely-long-inline-option-that-must-wrap\` stays inside the catalog detail column. + +\`\`\`text +This deliberately long fenced-code example must not establish the minimum width of the full custom-agent instruction document or force earlier prose outside the catalog detail pane. +\`\`\``, + }, + ], + }); + await gotoApp(page); + await page.evaluate(() => { + document.documentElement.style.fontSize = "24px"; + }); + + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + const catalogToggle = page.getByTestId("persona-share-show-in-catalog"); + await expect(catalogToggle).toHaveAttribute("aria-checked", "false"); + await catalogToggle.click(); + await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toContainText("Catalog Analyst"); + await selectCatalogPersona(page, personaId); + const catalogDetailPane = page.getByTestId("persona-catalog-detail-pane"); + await expect(catalogDetailPane).toContainText("Design System And Styling"); + expect( + await catalogDetailPane.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + const catalogInstruction = catalogDetailPane.locator(".message-markdown"); + expect( + await catalogInstruction.evaluate( + (element) => element.scrollWidth - element.clientWidth, + ), + ).toBeLessThanOrEqual(1); + await page.keyboard.press("Escape"); + + await page.getByLabel("Open actions for Catalog Analyst").click(); + await page.getByRole("menuitem", { name: "Share" }).click(); + await expect(catalogToggle).toHaveAttribute("aria-checked", "true"); + await catalogToggle.click(); + await page + .getByTestId("persona-share-dialog") + .getByRole("button", { name: "Close" }) + .click(); + + await openPersonaCatalog(page); + await expect( + page.getByTestId(`persona-catalog-list-item-${personaId}`), + ).toHaveCount(0); +}); + test("share access controls include the selected memories", async ({ page, }) => { @@ -1571,19 +1825,16 @@ test("inactive built-ins cannot be used to create teams", async ({ page }) => { }, }); - expect(error).toBe( - "Honey is not in My Agents. Choose it from Agent Catalog first.", - ); + expect(error).toBe("Honey is not in My Agents."); }); test("built-in removal failures show up from My Agents", async ({ page }) => { + await installMockBridge(page, { + activePersonaIds: ["builtin:honey"], + }); await gotoApp(page); await page.getByTestId("open-agents-view").click(); - await openPersonaCatalog(page); - await selectCatalogPersona(page, "builtin:honey"); - await useCatalogPersona(page, "builtin:honey"); - await invokeTauri(page, "create_team", { input: { name: "Honeys", @@ -1591,7 +1842,6 @@ test("built-in removal failures show up from My Agents", async ({ page }) => { }, }); - await page.keyboard.press("Escape"); await page.getByLabel("Open actions for Honey").click(); await page.getByRole("menuitem", { name: "Delete" }).click();