diff --git a/ui/goose2/src-tauri/src/commands/agents.rs b/ui/goose2/src-tauri/src/commands/agents.rs index a83499f205..3d3564c4cb 100644 --- a/ui/goose2/src-tauri/src/commands/agents.rs +++ b/ui/goose2/src-tauri/src/commands/agents.rs @@ -1,6 +1,7 @@ use crate::services::personas::PersonaStore; use crate::types::agents::*; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; use tauri::State; #[tauri::command] @@ -59,6 +60,57 @@ pub fn get_avatars_dir() -> String { PersonaStore::avatars_dir().to_string_lossy().to_string() } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportFileReadResult { + pub file_bytes: Vec, + pub file_name: String, +} + +fn validate_import_persona_path(source_path: &str) -> Result { + let path = PathBuf::from(source_path); + + if path.as_os_str().is_empty() { + return Err("Selected file path is empty".to_string()); + } + + let extension = path + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| "Unsupported file type. Expected a .json file.".to_string())?; + if !extension.eq_ignore_ascii_case("json") { + return Err("Unsupported file type. Expected a .json file.".to_string()); + } + + let metadata = std::fs::metadata(&path) + .map_err(|err| format!("Failed to access import file '{}': {}", path.display(), err))?; + if !metadata.is_file() { + return Err(format!( + "Selected import path '{}' is not a file", + path.display() + )); + } + + Ok(path) +} + +#[tauri::command] +pub fn read_import_persona_file(source_path: String) -> Result { + let path = validate_import_persona_path(&source_path)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| "Selected file is missing a valid filename".to_string())? + .to_string(); + let file_bytes = std::fs::read(&path) + .map_err(|err| format!("Failed to read import file '{}': {}", path.display(), err))?; + + Ok(ImportFileReadResult { + file_bytes, + file_name, + }) +} + // --- Sprout-compatible persona import/export --- /// Sprout-compatible persona export format (version 1, camelCase keys). @@ -208,3 +260,41 @@ pub fn import_personas( let persona = store.create(request)?; Ok(vec![persona]) } + +#[cfg(test)] +mod tests { + use super::validate_import_persona_path; + + #[test] + fn validate_import_persona_path_rejects_non_json_files() { + let path = std::env::temp_dir().join("persona-import.txt"); + std::fs::write(&path, b"{}").unwrap(); + + let result = validate_import_persona_path(path.to_str().unwrap()); + + assert!(result.is_err()); + let _ = std::fs::remove_file(path); + } + + #[test] + fn validate_import_persona_path_rejects_directories() { + let dir = std::env::temp_dir().join(format!("persona-import-dir-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let result = validate_import_persona_path(dir.to_str().unwrap()); + + assert!(result.is_err()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn validate_import_persona_path_accepts_json_files() { + let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id())); + std::fs::write(&path, b"{}").unwrap(); + + let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap(); + + assert_eq!(validated, path); + let _ = std::fs::remove_file(validated); + } +} diff --git a/ui/goose2/src-tauri/src/lib.rs b/ui/goose2/src-tauri/src/lib.rs index 7ec19d7b26..f473c45d0b 100644 --- a/ui/goose2/src-tauri/src/lib.rs +++ b/ui/goose2/src-tauri/src/lib.rs @@ -40,6 +40,7 @@ pub fn run() { commands::agents::refresh_personas, commands::agents::export_persona, commands::agents::import_personas, + commands::agents::read_import_persona_file, commands::agents::save_persona_avatar, commands::agents::save_persona_avatar_bytes, commands::agents::get_avatars_dir, diff --git a/ui/goose2/src-tauri/src/services/personas.rs b/ui/goose2/src-tauri/src/services/personas.rs index 8a3f1c4c72..5a2f0fb487 100644 --- a/ui/goose2/src-tauri/src/services/personas.rs +++ b/ui/goose2/src-tauri/src/services/personas.rs @@ -3,7 +3,7 @@ use crate::types::agents::{ }; use log::warn; use std::collections::HashSet; -use std::path::PathBuf; +use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; pub struct PersonaStore { @@ -197,6 +197,26 @@ impl PersonaStore { }) } + fn markdown_persona_path(id: &str) -> Result { + let slug = id + .strip_prefix("md-") + .ok_or_else(|| format!("Persona '{}' is not a file-backed persona", id))?; + Self::validate_markdown_persona_slug(slug)?; + Ok(Self::agents_dir().join(format!("{}.md", slug))) + } + + fn validate_markdown_persona_slug(slug: &str) -> Result<(), String> { + if slug.chars().any(|c| matches!(c, '/' | '\\')) { + return Err(format!("Persona '{}' has an invalid file-backed ID", slug)); + } + + let mut components = Path::new(slug).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(()), + _ => Err(format!("Persona '{}' has an invalid file-backed ID", slug)), + } + } + /// Re-scan markdown personas and update the in-memory list. /// Returns the full updated persona list. pub fn refresh_markdown(&self) -> Vec { @@ -298,13 +318,29 @@ impl PersonaStore { let persona = personas .iter() .find(|p| p.id == id) + .cloned() .ok_or_else(|| format!("Persona '{}' not found", id))?; if persona.is_builtin { return Err("Cannot delete a built-in persona".to_string()); } if persona.is_from_disk { - return Err("Cannot delete a markdown persona — delete the file directly".to_string()); + let path = Self::markdown_persona_path(id)?; + match std::fs::remove_file(&path) { + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "Failed to delete file-backed persona '{}': {}", + path.display(), + err + )); + } + } + + personas.retain(|p| p.id != id); + self.save_to_disk(&personas); + return Ok(()); } // Clean up local avatar file if present @@ -395,3 +431,27 @@ impl PersonaStore { let _ = std::fs::remove_file(path); } } + +#[cfg(test)] +mod tests { + use super::PersonaStore; + + #[test] + fn markdown_persona_path_rejects_parent_segments() { + assert!(PersonaStore::markdown_persona_path("md-../secret").is_err()); + assert!(PersonaStore::markdown_persona_path("md-..").is_err()); + } + + #[test] + fn markdown_persona_path_rejects_path_separators() { + assert!(PersonaStore::markdown_persona_path("md-nested/slug").is_err()); + assert!(PersonaStore::markdown_persona_path(r"md-nested\slug").is_err()); + } + + #[test] + fn markdown_persona_path_accepts_normal_slug() { + let path = PersonaStore::markdown_persona_path("md-scout").unwrap(); + let file_name = path.file_name().and_then(|name| name.to_str()); + assert_eq!(file_name, Some("scout.md")); + } +} diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index ce33962fd5..f61b4b516a 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -20,6 +20,7 @@ import { useAppStartup } from "./hooks/useAppStartup"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { loadStoredHomeSessionId } from "./lib/homeSessionStorage"; import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference"; +import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation"; import { AppShellContent } from "./ui/AppShellContent"; import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; import { @@ -66,14 +67,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const agentStore = useAgentStore(); const projectStore = useProjectStore(); const providerInventoryEntries = useProviderInventoryStore((s) => s.entries); - const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); const homeSessionRequestRef = useRef | null>( null, ); - const loadSessionMessages = useCallback(async (sessionId: string) => { const sid = sessionId.slice(0, 8); const existingMsgs = useChatStore.getState().messagesBySession[sessionId]; @@ -502,6 +501,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) { [sessionStore], ); + const handleCreatePersona = useCreatePersonaNavigation(() => + handleNavigate("agents"), + ); + const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); const handleResizeStart = useCallback( @@ -659,6 +662,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) { activeView={activeView} activeSession={activeSession} homeSessionId={homeSessionId} + onCreatePersona={handleCreatePersona} onArchiveChat={handleArchiveChat} onCreateProject={openCreateProjectDialog} onActivateHomeSession={activateHomeSession} diff --git a/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts b/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts new file mode 100644 index 0000000000..9f25f485ef --- /dev/null +++ b/ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts @@ -0,0 +1,17 @@ +import { useCallback } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; + +export function useCreatePersonaNavigation(navigateToAgents: () => void) { + return useCallback(() => { + navigateToAgents(); + const agentStoreState = useAgentStore.getState(); + if ( + agentStoreState.personaEditorOpen && + agentStoreState.personaEditorMode === "create" && + agentStoreState.editingPersona === null + ) { + return; + } + agentStoreState.openPersonaEditor(undefined, "create"); + }, [navigateToAgents]); +} diff --git a/ui/goose2/src/app/ui/AppShellContent.tsx b/ui/goose2/src/app/ui/AppShellContent.tsx index c07cfa4aa8..c7b1fde8a6 100644 --- a/ui/goose2/src/app/ui/AppShellContent.tsx +++ b/ui/goose2/src/app/ui/AppShellContent.tsx @@ -12,6 +12,7 @@ interface AppShellContentProps { activeView: AppView; activeSession?: ChatSession; homeSessionId: string | null; + onCreatePersona: () => void; onArchiveChat: (sessionId: string) => Promise; onCreateProject: (options?: { initialWorkingDir?: string | null; @@ -32,6 +33,7 @@ export function AppShellContent({ activeView, activeSession, homeSessionId, + onCreatePersona, onArchiveChat, onCreateProject, onActivateHomeSession, @@ -61,12 +63,14 @@ export function AppShellContent({ ) : ( ); @@ -75,6 +79,7 @@ export function AppShellContent({ ); diff --git a/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts b/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts index a480588305..d1507c253e 100644 --- a/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts +++ b/ui/goose2/src/features/agents/hooks/__tests__/usePersonas.test.ts @@ -81,6 +81,7 @@ describe("usePersonas", () => { isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }); }); diff --git a/ui/goose2/src/features/agents/lib/personaImport.ts b/ui/goose2/src/features/agents/lib/personaImport.ts new file mode 100644 index 0000000000..99c0d3b135 --- /dev/null +++ b/ui/goose2/src/features/agents/lib/personaImport.ts @@ -0,0 +1,58 @@ +const JSON_MIME_TYPES = new Set([ + "", + "application/json", + "application/x-json", + "text/json", + "text/plain", +]); + +export interface ImportMessageDescriptor { + key: + | "view.importInvalidExtension" + | "view.importInvalidMimeType" + | "view.imported_one" + | "view.imported_other"; + options?: Record; +} + +export function validatePersonaImportFile( + file: Pick, +): ImportMessageDescriptor | null { + const lowerName = file.name.toLowerCase(); + if (!lowerName.endsWith(".json")) { + return { + key: "view.importInvalidExtension", + } satisfies ImportMessageDescriptor; + } + + if (!JSON_MIME_TYPES.has(file.type)) { + return { + key: "view.importInvalidMimeType", + } satisfies ImportMessageDescriptor; + } + + return null; +} + +export function formatImportSuccessMessage( + importedCount: number, +): ImportMessageDescriptor { + if (importedCount === 1) { + return { key: "view.imported_one", options: { count: importedCount } }; + } + + return { + key: "view.imported_other", + options: { count: importedCount }, + }; +} + +export function formatAgentError(error: unknown, fallback: string): string { + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return fallback; +} diff --git a/ui/goose2/src/features/agents/lib/personaPresentation.ts b/ui/goose2/src/features/agents/lib/personaPresentation.ts new file mode 100644 index 0000000000..ee44c3585b --- /dev/null +++ b/ui/goose2/src/features/agents/lib/personaPresentation.ts @@ -0,0 +1,17 @@ +import type { Persona } from "@/shared/types/agents"; + +export type PersonaSource = "builtin" | "file" | "custom"; + +export function getPersonaSource(persona: Persona): PersonaSource { + if (persona.isBuiltin) { + return "builtin"; + } + if (persona.isFromDisk) { + return "file"; + } + return "custom"; +} + +export function isPersonaReadOnly(persona: Persona): boolean { + return getPersonaSource(persona) !== "custom"; +} diff --git a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts index 53275e93b3..5887c5877f 100644 --- a/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts +++ b/ui/goose2/src/features/agents/stores/__tests__/agentStore.test.ts @@ -44,6 +44,7 @@ describe("agentStore", () => { isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }); }); @@ -140,12 +141,14 @@ describe("agentStore", () => { useAgentStore.getState().openPersonaEditor(p); expect(useAgentStore.getState().personaEditorOpen).toBe(true); expect(useAgentStore.getState().editingPersona).toEqual(p); + expect(useAgentStore.getState().personaEditorMode).toBe("edit"); }); it("openPersonaEditor without persona sets editingPersona to null", () => { useAgentStore.getState().openPersonaEditor(); expect(useAgentStore.getState().personaEditorOpen).toBe(true); expect(useAgentStore.getState().editingPersona).toBeNull(); + expect(useAgentStore.getState().personaEditorMode).toBe("create"); }); it("closePersonaEditor clears editing state", () => { @@ -153,6 +156,7 @@ describe("agentStore", () => { useAgentStore.getState().closePersonaEditor(); expect(useAgentStore.getState().personaEditorOpen).toBe(false); expect(useAgentStore.getState().editingPersona).toBeNull(); + expect(useAgentStore.getState().personaEditorMode).toBe("create"); }); // ── helpers ─────────────────────────────────────────────────────── diff --git a/ui/goose2/src/features/agents/stores/agentStore.ts b/ui/goose2/src/features/agents/stores/agentStore.ts index e0f535529c..e0e3eb2531 100644 --- a/ui/goose2/src/features/agents/stores/agentStore.ts +++ b/ui/goose2/src/features/agents/stores/agentStore.ts @@ -56,6 +56,7 @@ interface AgentStoreState { // UI state personaEditorOpen: boolean; editingPersona: Persona | null; + personaEditorMode: "create" | "edit" | "details"; } interface AgentStoreActions { @@ -83,7 +84,10 @@ interface AgentStoreActions { getActiveAgent: () => Agent | null; // Persona editor - openPersonaEditor: (persona?: Persona) => void; + openPersonaEditor: ( + persona?: Persona, + mode?: "create" | "edit" | "details", + ) => void; closePersonaEditor: () => void; // Loading @@ -112,6 +116,7 @@ export const useAgentStore = create((set, get) => ({ isLoading: false, personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", // Persona CRUD setPersonas: (personas) => set({ personas }), @@ -187,16 +192,18 @@ export const useAgentStore = create((set, get) => ({ }, // Persona editor - openPersonaEditor: (persona) => + openPersonaEditor: (persona, mode) => set({ personaEditorOpen: true, editingPersona: persona ?? null, + personaEditorMode: mode ?? (persona ? "edit" : "create"), }), closePersonaEditor: () => set({ personaEditorOpen: false, editingPersona: null, + personaEditorMode: "create", }), // Loading diff --git a/ui/goose2/src/features/agents/ui/AgentsView.tsx b/ui/goose2/src/features/agents/ui/AgentsView.tsx index 282f190318..f48942481f 100644 --- a/ui/goose2/src/features/agents/ui/AgentsView.tsx +++ b/ui/goose2/src/features/agents/ui/AgentsView.tsx @@ -1,7 +1,8 @@ -import { useState, useMemo, useCallback, useRef } from "react"; +import { useState, useMemo, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Bot, Plus, Circle, Upload } from "lucide-react"; -import { cn } from "@/shared/lib/cn"; +import { open } from "@tauri-apps/plugin-dialog"; +import { Plus, Upload } from "lucide-react"; +import { toast } from "sonner"; import { SearchBar } from "@/shared/ui/SearchBar"; import { Button, buttonVariants } from "@/shared/ui/button"; import { @@ -17,66 +18,36 @@ import { import { useAgentStore } from "@/features/agents/stores/agentStore"; import { PersonaGallery } from "@/features/agents/ui/PersonaGallery"; import { PersonaEditor } from "@/features/agents/ui/PersonaEditor"; -import { exportPersona, importPersonas } from "@/shared/api/agents"; +import { + exportPersona, + importPersonas, + readImportPersonaFile, +} from "@/shared/api/agents"; import { usePersonas } from "@/features/agents/hooks/usePersonas"; import type { Persona, - Agent, - AgentStatus, CreatePersonaRequest, UpdatePersonaRequest, } from "@/shared/types/agents"; - -const STATUS_STYLES: Record = { - online: { dot: "text-green-500", labelKey: "statuses.online" }, - offline: { dot: "text-muted-foreground", labelKey: "statuses.offline" }, - starting: { dot: "text-yellow-500", labelKey: "statuses.starting" }, - error: { dot: "text-red-500", labelKey: "statuses.error" }, -}; - -function AgentRow({ agent }: { agent: Agent }) { - const { t } = useTranslation("agents"); - const status = STATUS_STYLES[agent.status]; - return ( -
  • -
    - -
    -

    {agent.name}

    - {agent.persona && ( -

    - {agent.persona.displayName} -

    - )} -
    -
    -
    -
    -
  • - ); -} +import { + formatAgentError, + formatImportSuccessMessage, + validatePersonaImportFile, +} from "@/features/agents/lib/personaImport"; +import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; export function AgentsView() { const { t } = useTranslation(["agents", "common"]); const [search, setSearch] = useState(""); const [deletingPersona, setDeletingPersona] = useState(null); - const [notification, setNotification] = useState(null); const personas = useAgentStore((s) => s.personas); const personasLoading = useAgentStore((s) => s.personasLoading); - const agents = useAgentStore((s) => s.agents); const personaEditorOpen = useAgentStore((s) => s.personaEditorOpen); const editingPersona = useAgentStore((s) => s.editingPersona); + const personaEditorMode = useAgentStore((s) => s.personaEditorMode); const openPersonaEditor = useAgentStore((s) => s.openPersonaEditor); const closePersonaEditor = useAgentStore((s) => s.closePersonaEditor); - const addPersona = useAgentStore((s) => s.addPersona); const { createPersona, @@ -97,48 +68,54 @@ export function AgentsView() { [personas, lowerSearch], ); - const filteredAgents = useMemo( - () => - agents.filter( - (a) => - a.name.toLowerCase().includes(lowerSearch) || - a.persona?.displayName.toLowerCase().includes(lowerSearch), - ), - [agents, lowerSearch], - ); - const handleSavePersona = useCallback( async (data: CreatePersonaRequest | UpdatePersonaRequest) => { - if (editingPersona) { - await updatePersonaViaHook( - editingPersona.id, - data as UpdatePersonaRequest, - ); - } else { - await createPersona(data as CreatePersonaRequest); + try { + if (editingPersona && personaEditorMode === "edit") { + await updatePersonaViaHook( + editingPersona.id, + data as UpdatePersonaRequest, + ); + toast.success(t("editor.updated")); + } else { + await createPersona(data as CreatePersonaRequest); + toast.success(t("editor.created")); + } + closePersonaEditor(); + } catch (error) { + toast.error(formatAgentError(error, t("editor.saveFailed"))); } - closePersonaEditor(); }, - [editingPersona, createPersona, updatePersonaViaHook, closePersonaEditor], + [ + closePersonaEditor, + createPersona, + editingPersona, + personaEditorMode, + t, + updatePersonaViaHook, + ], ); const handleDuplicatePersona = useCallback( - (persona: Persona) => { - const duplicate: Persona = { - ...persona, - id: crypto.randomUUID(), - displayName: t("view.copyName", { name: persona.displayName }), - isBuiltin: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - addPersona(duplicate); + async (persona: Persona) => { + try { + await createPersona({ + displayName: t("view.copyName", { name: persona.displayName }), + avatar: persona.avatar ?? undefined, + systemPrompt: persona.systemPrompt, + provider: persona.provider, + model: persona.model, + }); + toast.success(t("editor.duplicated")); + } catch (error) { + toast.error(formatAgentError(error, t("editor.saveFailed"))); + } }, - [addPersona, t], + [createPersona, t], ); const handleDeletePersona = useCallback((persona: Persona) => { - if (persona.isBuiltin) return; + if (getPersonaSource(persona) === "builtin") return; setDeletingPersona(persona); }, []); @@ -146,11 +123,15 @@ export function AgentsView() { if (!deletingPersona) return; try { await deletePersona(deletingPersona.id); + if (editingPersona?.id === deletingPersona.id) { + closePersonaEditor(); + } + toast.success(t("view.deleted", { name: deletingPersona.displayName })); } catch (err) { - console.error("Failed to delete persona:", err); + toast.error(formatAgentError(err, t("view.deleteFailed"))); } setDeletingPersona(null); - }, [deletingPersona, deletePersona]); + }, [closePersonaEditor, deletingPersona, deletePersona, editingPersona, t]); const handleExportPersona = useCallback( async (persona: Persona) => { @@ -166,53 +147,77 @@ export function AgentsView() { a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); - setNotification( + toast.success( t("view.exportedTo", { filename: result.suggestedFilename }), ); - setTimeout(() => setNotification(null), 3000); } catch (err) { - console.error("Failed to export persona:", err); + toast.error(formatAgentError(err, t("view.exportFailed"))); } }, [t], ); - const importInputRef = useRef(null); + const handleImportError = useCallback((message: string) => { + toast.error(message); + }, []); - const handleImportFile = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const arrayBuffer = await file.arrayBuffer(); - const bytes = Array.from(new Uint8Array(arrayBuffer)); - await importPersonas(bytes, file.name); - await refreshFromDisk(); - } catch (err) { - console.error("Failed to import persona:", err); - } - - // Reset the input so the same file can be re-selected - if (importInputRef.current) { - importInputRef.current.value = ""; - } + const validateImportFile = useCallback( + (file: Pick) => { + const message = validatePersonaImportFile(file); + return message ? t(message.key, message.options) : null; }, - [refreshFromDisk], + [t], ); const handleImportFileBytes = useCallback( async (fileBytes: number[], fileName: string) => { try { - await importPersonas(fileBytes, fileName); + const imported = await importPersonas(fileBytes, fileName); await refreshFromDisk(); + const message = formatImportSuccessMessage(imported.length); + toast.success(t(message.key, message.options)); } catch (err) { - console.error("Failed to import persona:", err); + toast.error(formatAgentError(err, t("view.importFailed"))); } }, - [refreshFromDisk], + [refreshFromDisk, t], ); + const handleImportPicker = useCallback(async () => { + try { + const selected = await open({ + multiple: false, + directory: false, + title: t("common:actions.import"), + filters: [ + { + name: "JSON", + extensions: ["json"], + }, + ], + }); + + if (!selected || Array.isArray(selected)) { + return; + } + + const { fileBytes, fileName } = await readImportPersonaFile(selected); + const validationMessage = validateImportFile({ + name: fileName, + type: "", + }); + + if (validationMessage) { + toast.error(validationMessage); + return; + } + + await handleImportFileBytes(fileBytes, fileName); + } catch (err) { + toast.error(formatAgentError(err, t("view.importFailed"))); + } + }, [handleImportFileBytes, t, validateImportFile]); + return (
    @@ -228,18 +233,11 @@ export function AgentsView() {

    -
    @@ -313,9 +284,12 @@ export function AgentsView() { openPersonaEditor(persona, "edit")} + onDelete={handleDeletePersona} /> {/* Delete confirmation dialog */} @@ -343,13 +317,6 @@ export function AgentsView() { - - {/* Export notification toast */} - {notification && ( -
    - {notification} -
    - )} ); } diff --git a/ui/goose2/src/features/agents/ui/PersonaCard.tsx b/ui/goose2/src/features/agents/ui/PersonaCard.tsx index 5128b8c2b6..83e2c9cb5f 100644 --- a/ui/goose2/src/features/agents/ui/PersonaCard.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaCard.tsx @@ -13,6 +13,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import type { Persona } from "@/shared/types/agents"; +import { getPersonaSource } from "@/features/agents/lib/personaPresentation"; interface PersonaCardProps { persona: Persona; @@ -38,18 +39,30 @@ export function PersonaCard({ const initials = persona.displayName.charAt(0).toUpperCase(); const avatarSrc = useAvatarSrc(persona.avatar); + const personaSource = getPersonaSource(persona); + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const providerModelLabel = [persona.provider, persona.model] + .filter(Boolean) + .join(" / "); + + const handleCardKeyDown = (event: React.KeyboardEvent) => { + if (event.target !== event.currentTarget || menuOpen) { + return; + } + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect?.(persona); + } + }; return ( -
    !menuOpen && onSelect?.(persona)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onSelect?.(persona); - } - }} - // biome-ignore lint/a11y/noNoninteractiveTabindex: card needs keyboard focus but contains nested interactive buttons + onKeyDown={handleCardKeyDown} tabIndex={0} className={cn( "group relative flex flex-col items-center gap-3 rounded-xl border p-5 cursor-pointer", @@ -68,6 +81,7 @@ export function PersonaCard({ size="icon-xs" aria-label={t("card.options")} onClick={(e) => e.stopPropagation()} + onKeyDown={(event) => event.stopPropagation()} className={cn( "size-6 rounded-md text-muted-foreground hover:text-foreground", menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100", @@ -77,10 +91,12 @@ export function PersonaCard({ - onEdit?.(persona)}> - - {t("common:actions.edit")} - + {canEditPersona && ( + onEdit?.(persona)}> + + {t("common:actions.edit")} + + )} onDuplicate?.(persona)}> {t("common:actions.duplicate")} @@ -89,7 +105,7 @@ export function PersonaCard({ {t("common:actions.export")} - {!persona.isBuiltin && !persona.isFromDisk && ( + {canDeletePersona && ( onDelete?.(persona)} @@ -116,11 +132,16 @@ export function PersonaCard({ {/* Built-in badge */} - {persona.isBuiltin && ( + {personaSource === "builtin" && ( {t("common:labels.builtIn")} )} + {personaSource === "file" && ( + + {t("card.fileBacked")} + + )} {/* System prompt preview */}

    @@ -128,15 +149,13 @@ export function PersonaCard({

    {/* Provider/model badge */} - {(persona.provider || persona.model) && ( - - {persona.provider && {persona.provider}} - {persona.provider && persona.model && ( - - )} - {persona.model && {persona.model}} + {providerModelLabel && ( + + + {providerModelLabel} + )} -
    + ); } diff --git a/ui/goose2/src/features/agents/ui/PersonaDetails.tsx b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx new file mode 100644 index 0000000000..6022c35f54 --- /dev/null +++ b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx @@ -0,0 +1,108 @@ +import { useTranslation } from "react-i18next"; +import { + Avatar as AvatarRoot, + AvatarFallback, + AvatarImage, +} from "@/shared/ui/avatar"; +import { Badge } from "@/shared/ui/badge"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; +import type { Avatar } from "@/shared/types/agents"; +import type { PersonaSource } from "@/features/agents/lib/personaPresentation"; + +interface PersonaDetailsProps { + avatar: Avatar | null; + displayName: string; + modelLabel: string; + personaSource: PersonaSource; + providerLabel: string; + systemPrompt: string; +} + +export function PersonaDetails({ + avatar, + displayName, + modelLabel, + personaSource, + providerLabel, + systemPrompt, +}: PersonaDetailsProps) { + const { t } = useTranslation(["agents", "common"]); + const avatarSrc = useAvatarSrc(avatar); + const initials = displayName.charAt(0).toUpperCase() || "?"; + + return ( +
    +
    +
    +
    + + + + {initials} + + +
    +
    +

    + {t("editor.displayName")} +

    +

    + {displayName} +

    +
    +
    + {personaSource === "builtin" ? ( + + {t("common:labels.builtIn")} + + ) : null} + {personaSource === "file" ? ( + {t("card.fileBacked")} + ) : null} +
    +
    +
    +
    + +
    +
    +

    + {t("editor.provider")} +

    +

    + {providerLabel} +

    +
    +
    +

    + {t("editor.model")} +

    +

    {modelLabel}

    +
    +
    + +
    +
    +

    + {t("editor.systemPrompt")} +

    + + {t("common:labels.characterCount", { + count: systemPrompt.length, + })} + +
    +
    + + {systemPrompt} + +
    +
    +
    +
    + ); +} diff --git a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx index 8c460d3a22..7655f6afe5 100644 --- a/ui/goose2/src/features/agents/ui/PersonaEditor.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaEditor.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { Copy } from "lucide-react"; +import { Copy, Pencil, Trash2 } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Avatar as AvatarRoot, @@ -11,6 +11,7 @@ import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Label } from "@/shared/ui/label"; import { Textarea } from "@/shared/ui/textarea"; +import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc"; import { Dialog, DialogContent, @@ -25,46 +26,60 @@ import { SelectTrigger, SelectValue, } from "@/shared/ui/select"; +import type { Persona, ProviderType, Avatar } from "@/shared/types/agents"; import type { - Persona, - ProviderType, - Avatar, CreatePersonaRequest, UpdatePersonaRequest, } from "@/shared/types/agents"; -import { discoverAcpProviders, type AcpProvider } from "@/shared/api/acp"; +import { discoverAcpProviders } from "@/shared/api/acp"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { + getPersonaSource, + isPersonaReadOnly, +} from "@/features/agents/lib/personaPresentation"; import { AvatarDropZone } from "./AvatarDropZone"; +import { PersonaDetails } from "./PersonaDetails"; interface PersonaEditorProps { persona?: Persona; isOpen: boolean; + mode?: "create" | "edit" | "details"; onClose: () => void; onSave: (data: CreatePersonaRequest | UpdatePersonaRequest) => void; onDuplicate?: (persona: Persona) => void; + onEdit?: (persona: Persona) => void; + onDelete?: (persona: Persona) => void; isPending?: boolean; } export function PersonaEditor({ persona, isOpen, + mode = "create", onClose, onSave, onDuplicate, + onEdit, + onDelete, isPending = false, }: PersonaEditorProps) { const { t } = useTranslation(["agents", "common"]); - const isEditing = !!persona; - const isReadOnly = persona?.isBuiltin ?? false; - - const [acpProviders, setAcpProviders] = useState([]); - - useEffect(() => { - if (isOpen) { - discoverAcpProviders() - .then(setAcpProviders) - .catch(() => setAcpProviders([])); - } - }, [isOpen]); + const isEditing = mode === "edit"; + const detailsMode = mode === "details"; + const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false; + const isReadOnly = detailsMode || readOnlyBySource; + const personaSource = persona ? getPersonaSource(persona) : "custom"; + const canEditPersona = personaSource === "custom"; + const canDeletePersona = personaSource !== "builtin"; + const acpProviders = useAgentStore((s) => s.providers); + const setProviders = useAgentStore((s) => s.setProviders); + const mergeInventoryEntries = useProviderInventoryStore( + (s) => s.mergeEntries, + ); + const { getEntry, getModelsForProvider } = useProviderInventory(); const [displayName, setDisplayName] = useState(""); const [avatar, setAvatar] = useState(null); @@ -72,6 +87,36 @@ export function PersonaEditor({ const [provider, setProvider] = useState(""); const [model, setModel] = useState(""); + useEffect(() => { + if (!isOpen) { + return; + } + + let cancelled = false; + + const syncProviderOptions = async () => { + try { + const providers = await discoverAcpProviders(); + if (!cancelled) { + setProviders(providers); + } + } catch {} + + try { + const entries = await getProviderInventory(); + if (!cancelled) { + mergeInventoryEntries(entries); + } + } catch {} + }; + + void syncProviderOptions(); + + return () => { + cancelled = true; + }; + }, [isOpen, mergeInventoryEntries, setProviders]); + useEffect(() => { if (isOpen && persona) { setDisplayName(persona.displayName); @@ -90,6 +135,29 @@ export function PersonaEditor({ const isValid = displayName.trim().length > 0 && systemPrompt.trim().length > 0; + const avatarSrc = useAvatarSrc(avatar); + + const availableModels = provider ? getModelsForProvider(provider) : []; + const providerInventory = provider ? getEntry(provider) : undefined; + const modelStatusMessage = + providerInventory?.modelSelectionHint ?? + providerInventory?.lastRefreshError; + const hasSavedModelOutsideInventory = + Boolean(model) && !availableModels.some((entry) => entry.id === model); + const modelSelectValue = hasSavedModelOutsideInventory + ? `__saved__:${model}` + : model || "__none__"; + + const readOnlyDescription = readOnlyBySource + ? personaSource === "builtin" + ? t("editor.readOnlyBuiltIn") + : t("editor.readOnlyFile") + : null; + const providerLabel = provider + ? (acpProviders.find((providerOption) => providerOption.id === provider) + ?.label ?? provider) + : t("common:labels.none"); + const modelLabel = model || t("common:labels.none"); const handleSubmit = useCallback( (e: React.FormEvent) => { @@ -127,147 +195,259 @@ export function PersonaEditor({ - {isReadOnly + {detailsMode ? persona?.displayName : isEditing ? t("editor.editTitle") : t("editor.newTitle")} + {readOnlyDescription ? ( +

    + {readOnlyDescription} +

    + ) : null}
    -
    - {/* Avatar drop zone */} -
    - {isReadOnly ? ( - - + ) : ( + +
    + {isReadOnly ? ( + + + + {initials} + + + ) : ( + - - {initials} - - - ) : ( - - )} -
    + )} +
    - {/* Display Name */} -
    - - setDisplayName(e.target.value)} - readOnly={isReadOnly} - required - placeholder={t("editor.displayNamePlaceholder")} - className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} - /> -
    - - {/* System Prompt */} -
    -
    +
    - - {t("common:labels.characterCount", { - count: systemPrompt.length, - })} - + setDisplayName(e.target.value)} + readOnly={isReadOnly} + required + placeholder={t("editor.displayNamePlaceholder")} + className={cn(isReadOnly && "opacity-70 cursor-not-allowed")} + />
    -