improve goose2 agent management flows (#8737)

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi 2026-04-21 17:54:48 -07:00 committed by GitHub
parent 23b3b3dcac
commit 7e2fb3ee5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1219 additions and 524 deletions

View file

@ -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<u8>,
pub file_name: String,
}
fn validate_import_persona_path(source_path: &str) -> Result<PathBuf, String> {
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<ImportFileReadResult, String> {
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);
}
}

View file

@ -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,

View file

@ -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<PathBuf, String> {
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<Persona> {
@ -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"));
}
}

View file

@ -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<Promise<ChatSession | null> | 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}

View file

@ -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]);
}

View file

@ -12,6 +12,7 @@ interface AppShellContentProps {
activeView: AppView;
activeSession?: ChatSession;
homeSessionId: string | null;
onCreatePersona: () => void;
onArchiveChat: (sessionId: string) => Promise<void>;
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({
<ChatView
key={activeSession.id}
sessionId={activeSession.id}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
/>
) : (
<HomeScreen
sessionId={homeSessionId}
onActivateSession={onActivateHomeSession}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
/>
);
@ -75,6 +79,7 @@ export function AppShellContent({
<HomeScreen
sessionId={homeSessionId}
onActivateSession={onActivateHomeSession}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
/>
);

View file

@ -81,6 +81,7 @@ describe("usePersonas", () => {
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
});
});

View file

@ -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<string, unknown>;
}
export function validatePersonaImportFile(
file: Pick<File, "name" | "type">,
): 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;
}

View file

@ -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";
}

View file

@ -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 ───────────────────────────────────────────────────────

View file

@ -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<AgentStore>((set, get) => ({
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
// Persona CRUD
setPersonas: (personas) => set({ personas }),
@ -187,16 +192,18 @@ export const useAgentStore = create<AgentStore>((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

View file

@ -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<AgentStatus, { dot: string; labelKey: string }> = {
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 (
<li className="flex items-center justify-between rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/50">
<div className="flex items-center gap-3 min-w-0">
<Bot className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{agent.name}</p>
{agent.persona && (
<p className="text-xs text-muted-foreground truncate">
{agent.persona.displayName}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Circle
className={cn("h-2.5 w-2.5 fill-current", status.dot)}
aria-hidden="true"
/>
<span className="text-xs text-muted-foreground">
{t(status.labelKey)}
</span>
</div>
</li>
);
}
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<Persona | null>(null);
const [notification, setNotification] = useState<string | null>(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<HTMLInputElement>(null);
const handleImportError = useCallback((message: string) => {
toast.error(message);
}, []);
const handleImportFile = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
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<File, "name" | "type">) => {
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 (
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
@ -228,18 +233,11 @@ export function AgentsView() {
</p>
</div>
<div className="flex items-center gap-2">
<input
ref={importInputRef}
type="file"
accept=".persona.json,.json"
className="hidden"
onChange={handleImportFile}
/>
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => importInputRef.current?.click()}
onClick={() => void handleImportPicker()}
>
<Upload className="w-3.5 h-3.5" />
{t("common:actions.import")}
@ -267,45 +265,18 @@ export function AgentsView() {
<section aria-labelledby="personas-heading">
<PersonaGallery
personas={filteredPersonas}
onSelectPersona={(p) => openPersonaEditor(p)}
onEditPersona={(p) => openPersonaEditor(p)}
onSelectPersona={(p) => openPersonaEditor(p, "details")}
onEditPersona={(p) => openPersonaEditor(p, "edit")}
onDuplicatePersona={handleDuplicatePersona}
onDeletePersona={handleDeletePersona}
onExportPersona={handleExportPersona}
onCreatePersona={() => openPersonaEditor()}
onImportFile={handleImportFileBytes}
validateImportFile={validateImportFile}
onImportError={handleImportError}
isLoading={personasLoading}
/>
</section>
{/* Active Agents section */}
<section aria-labelledby="agents-heading">
<h2
id="agents-heading"
className="text-lg font-semibold font-display tracking-tight mb-3"
>
{t("view.activeAgents")}
</h2>
{filteredAgents.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
<Bot className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{t("view.emptyAgentsTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{t("view.emptyAgentsDescription")}
</p>
</div>
</div>
) : (
<ul className="space-y-2" aria-label={t("view.activeAgentsAria")}>
{filteredAgents.map((agent) => (
<AgentRow key={agent.id} agent={agent} />
))}
</ul>
)}
</section>
</div>
</div>
@ -313,9 +284,12 @@ export function AgentsView() {
<PersonaEditor
persona={editingPersona ?? undefined}
isOpen={personaEditorOpen}
mode={personaEditorMode}
onClose={closePersonaEditor}
onSave={handleSavePersona}
onDuplicate={handleDuplicatePersona}
onEdit={(persona) => openPersonaEditor(persona, "edit")}
onDelete={handleDeletePersona}
/>
{/* Delete confirmation dialog */}
@ -343,13 +317,6 @@ export function AgentsView() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Export notification toast */}
{notification && (
<div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 shadow-popover text-sm animate-in fade-in slide-in-from-bottom-2">
{notification}
</div>
)}
</div>
);
}

View file

@ -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<HTMLDivElement>) => {
if (event.target !== event.currentTarget || menuOpen) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect?.(persona);
}
};
return (
<section
// biome-ignore lint/a11y/useSemanticElements: card contains nested menu buttons, so a native button is not valid here
<div
aria-label={t("card.ariaLabel", { name: persona.displayName })}
role="button"
onClick={() => !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({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
{canEditPersona && (
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
<Copy className="size-3.5" />
{t("common:actions.duplicate")}
@ -89,7 +105,7 @@ export function PersonaCard({
<Download className="size-3.5" />
{t("common:actions.export")}
</DropdownMenuItem>
{!persona.isBuiltin && !persona.isFromDisk && (
{canDeletePersona && (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete?.(persona)}
@ -116,11 +132,16 @@ export function PersonaCard({
</h3>
{/* Built-in badge */}
{persona.isBuiltin && (
{personaSource === "builtin" && (
<Badge variant="secondary" className="text-[10px]">
{t("common:labels.builtIn")}
</Badge>
)}
{personaSource === "file" && (
<Badge variant="secondary" className="text-[10px]">
{t("card.fileBacked")}
</Badge>
)}
{/* System prompt preview */}
<p className="text-xs text-muted-foreground text-center line-clamp-2 w-full">
@ -128,15 +149,13 @@ export function PersonaCard({
</p>
{/* Provider/model badge */}
{(persona.provider || persona.model) && (
<Badge variant="secondary" className="text-[10px]">
{persona.provider && <span>{persona.provider}</span>}
{persona.provider && persona.model && (
<span aria-hidden="true">/</span>
)}
{persona.model && <span>{persona.model}</span>}
{providerModelLabel && (
<Badge variant="secondary" className="max-w-full min-w-0 text-[10px]">
<span className="block max-w-full truncate">
{providerModelLabel}
</span>
</Badge>
)}
</section>
</div>
);
}

View file

@ -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 (
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">
<div className="space-y-4">
<section className="rounded-xl border border-border bg-muted/20 p-4">
<div className="flex items-start gap-4">
<AvatarRoot className="h-16 w-16 border border-border bg-background">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.displayName")}
</p>
<h2 className="text-base font-semibold tracking-tight">
{displayName}
</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
{personaSource === "builtin" ? (
<Badge variant="secondary">
{t("common:labels.builtIn")}
</Badge>
) : null}
{personaSource === "file" ? (
<Badge variant="secondary">{t("card.fileBacked")}</Badge>
) : null}
</div>
</div>
</div>
</section>
<section className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2 rounded-xl border border-border bg-background p-4">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.provider")}
</p>
<p className="text-sm font-medium text-foreground">
{providerLabel}
</p>
</div>
<div className="space-y-2 rounded-xl border border-border bg-background p-4">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.model")}
</p>
<p className="text-sm font-medium text-foreground">{modelLabel}</p>
</div>
</section>
<section className="space-y-2 rounded-xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.systemPrompt")}
</p>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
</div>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3">
<MessageResponse className="min-w-0 text-sm leading-6">
{systemPrompt}
</MessageResponse>
</div>
</section>
</div>
</div>
);
}

View file

@ -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<AcpProvider[]>([]);
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<Avatar | null>(null);
@ -72,6 +87,36 @@ export function PersonaEditor({
const [provider, setProvider] = useState<ProviderType | "">("");
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({
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-5 py-4">
<DialogTitle className="text-sm">
{isReadOnly
{detailsMode
? persona?.displayName
: isEditing
? t("editor.editTitle")
: t("editor.newTitle")}
</DialogTitle>
{readOnlyDescription ? (
<p className="text-xs text-muted-foreground">
{readOnlyDescription}
</p>
) : null}
</DialogHeader>
<form
id="persona-form"
onSubmit={handleSubmit}
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
>
{/* Avatar drop zone */}
<div className="flex justify-center">
{isReadOnly ? (
<AvatarRoot className="h-16 w-16 border border-border">
<AvatarImage
src={avatar?.type === "url" ? avatar.value : undefined}
alt={t("avatar.previewAlt")}
{detailsMode ? (
<PersonaDetails
avatar={avatar}
displayName={displayName}
modelLabel={modelLabel}
personaSource={personaSource}
providerLabel={providerLabel}
systemPrompt={systemPrompt}
/>
) : (
<form
id="persona-form"
onSubmit={handleSubmit}
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
>
<div className="flex justify-center">
{isReadOnly ? (
<AvatarRoot className="h-16 w-16 border border-border">
<AvatarImage
src={avatarSrc ?? undefined}
alt={t("avatar.previewAlt")}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
) : (
<AvatarDropZone
personaId={avatarPersonaId}
avatar={avatar}
onChange={setAvatar}
disabled={isReadOnly}
/>
<AvatarFallback className="text-lg font-semibold">
{initials}
</AvatarFallback>
</AvatarRoot>
) : (
<AvatarDropZone
personaId={avatarPersonaId}
avatar={avatar}
onChange={setAvatar}
disabled={isReadOnly}
/>
)}
</div>
)}
</div>
{/* Display Name */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.displayName")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
readOnly={isReadOnly}
required
placeholder={t("editor.displayNamePlaceholder")}
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
/>
</div>
{/* System Prompt */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.systemPrompt")}{" "}
{t("editor.displayName")}{" "}
<span className="text-destructive">*</span>
</Label>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
<Input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
readOnly={isReadOnly}
required
placeholder={t("editor.displayNamePlaceholder")}
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
/>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
readOnly={isReadOnly}
required
rows={6}
placeholder={t("editor.systemPromptPlaceholder")}
className={cn(
"leading-relaxed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
/>
</div>
{/* Provider */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.provider")}
</Label>
<Select
value={provider || "__none__"}
onValueChange={(v: string) =>
setProvider(
v === "__none__"
? ("" as ProviderType | "")
: (v as ProviderType),
)
}
disabled={isReadOnly}
>
<SelectTrigger
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.systemPrompt")}{" "}
<span className="text-destructive">*</span>
</Label>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
</div>
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
readOnly={isReadOnly}
required
rows={6}
placeholder={t("editor.systemPromptPlaceholder")}
className={cn(
"w-full",
"leading-relaxed",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue placeholder={t("common:labels.none")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
{acpProviders.map((providerOption) => (
<SelectItem key={providerOption.id} value={providerOption.id}>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
/>
</div>
{/* Model */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.model")}
</Label>
<Input
value={model}
onChange={(e) => setModel(e.target.value)}
readOnly={isReadOnly}
placeholder={t("editor.modelPlaceholder")}
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
/>
</div>
</form>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.provider")}
</Label>
<Select
value={provider || "__none__"}
onValueChange={(v: string) => {
const nextProvider =
v === "__none__"
? ("" as ProviderType | "")
: (v as ProviderType);
setProvider(nextProvider);
if (nextProvider !== provider) {
setModel("");
}
}}
disabled={isReadOnly}
>
<SelectTrigger
className={cn(
"w-full",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue placeholder={t("common:labels.none")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
{acpProviders.map((providerOption) => (
<SelectItem
key={providerOption.id}
value={providerOption.id}
>
{providerOption.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("editor.model")}
</Label>
<Select
value={modelSelectValue}
onValueChange={(value: string) => {
if (value === "__none__") {
setModel("");
return;
}
if (value.startsWith("__saved__:")) {
setModel(value.slice("__saved__:".length));
return;
}
setModel(value);
}}
disabled={isReadOnly || !provider}
>
<SelectTrigger
className={cn(
"w-full",
isReadOnly && "opacity-70 cursor-not-allowed",
)}
>
<SelectValue
placeholder={
provider
? t("editor.modelPlaceholder")
: t("editor.chooseProviderFirst")
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
{t("common:labels.none")}
</SelectItem>
{hasSavedModelOutsideInventory && (
<SelectItem value={`__saved__:${model}`}>
{t("editor.savedModelUnavailable", { model })}
</SelectItem>
)}
{availableModels.map((modelOption) => (
<SelectItem key={modelOption.id} value={modelOption.id}>
{modelOption.displayName ?? modelOption.name}
</SelectItem>
))}
</SelectContent>
</Select>
{hasSavedModelOutsideInventory ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.savedModelUnavailableHelp")}
</p>
) : !provider ? (
<p className="text-[11px] text-muted-foreground">
{t("editor.chooseProviderFirst")}
</p>
) : availableModels.length === 0 ? (
<p className="text-[11px] text-muted-foreground">
{modelStatusMessage ?? t("editor.noModelsAvailable")}
</p>
) : null}
</div>
</form>
)}
<DialogFooter className="shrink-0 border-t px-5 py-4">
{isReadOnly && onDuplicate && persona ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => onDuplicate(persona)}
>
<Copy className="h-3.5 w-3.5" />
{t("editor.duplicate")}
</Button>
{detailsMode && persona ? (
<>
{onEdit && canEditPersona ? (
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => onEdit(persona)}
>
<Pencil className="h-3.5 w-3.5" />
{t("common:actions.edit")}
</Button>
) : null}
{onDuplicate ? (
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => onDuplicate(persona)}
>
<Copy className="h-3.5 w-3.5" />
{t("editor.duplicate")}
</Button>
) : null}
{onDelete && canDeletePersona ? (
<Button
type="button"
variant="destructive-flat"
size="sm"
onClick={() => onDelete(persona)}
>
<Trash2 className="h-3.5 w-3.5" />
{t("common:actions.delete")}
</Button>
) : null}
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
{t("common:actions.close")}
</Button>
</>
) : isReadOnly && onDuplicate && persona ? (
<>
<Button
type="button"
variant="outline-flat"
size="sm"
onClick={() => onDuplicate(persona)}
>
<Copy className="h-3.5 w-3.5" />
{t("editor.duplicate")}
</Button>
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
{t("common:actions.close")}
</Button>
</>
) : (
<>
<Button type="button" variant="ghost" size="sm" onClick={onClose}>

View file

@ -18,6 +18,8 @@ interface PersonaGalleryProps {
onExportPersona?: (persona: Persona) => void;
onCreatePersona: () => void;
onImportFile?: (fileBytes: number[], fileName: string) => void;
validateImportFile?: (file: Pick<File, "name" | "type">) => string | null;
onImportError?: (message: string) => void;
isLoading?: boolean;
}
@ -45,12 +47,16 @@ export function PersonaGallery({
onExportPersona,
onCreatePersona,
onImportFile,
validateImportFile,
onImportError,
isLoading = false,
}: PersonaGalleryProps) {
const { t } = useTranslation("agents");
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
useFileImportZone({
onImportFile: onImportFile ?? (() => {}),
validateFile: validateImportFile,
onImportError,
});
const sorted = useMemo(() => {
const builtins = personas
@ -120,7 +126,7 @@ export function PersonaGallery({
<input
ref={fileInputRef}
type="file"
accept=".persona.json,.json"
accept=".json,application/json"
className="hidden"
onChange={handleFileChange}
/>

View file

@ -52,7 +52,7 @@ describe("PersonaCard", () => {
const persona = makePersona();
render(<PersonaCard persona={persona} onSelect={onSelect} />);
await user.click(screen.getByLabelText(/^persona: /i));
await user.click(screen.getByLabelText(/^agent: /i));
expect(onSelect).toHaveBeenCalledWith(persona);
});
@ -67,7 +67,7 @@ describe("PersonaCard", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /persona options/i }));
await user.click(screen.getByRole("button", { name: /agent options/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
expect(
@ -87,8 +87,26 @@ describe("PersonaCard", () => {
/>,
);
await user.click(screen.getByRole("button", { name: /persona options/i }));
await user.click(screen.getByRole("button", { name: /agent options/i }));
const deleteBtn = screen.queryByRole("menuitem", { name: /delete/i });
expect(deleteBtn).toBeNull();
});
it("does not trigger selection when keyboard opens the options menu", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(
<PersonaCard
persona={makePersona()}
onSelect={onSelect}
onDuplicate={vi.fn()}
/>,
);
screen.getByRole("button", { name: /agent options/i }).focus();
await user.keyboard("{Enter}");
expect(screen.getByRole("menu")).toBeInTheDocument();
expect(onSelect).not.toHaveBeenCalled();
});
});

View file

@ -43,6 +43,7 @@ describe("useChat attachments", () => {
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
});
mockAcpCancelSession.mockResolvedValue(true);
mockAcpPrepareSession.mockResolvedValue(undefined);

View file

@ -108,6 +108,7 @@ describe("useChat", () => {
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
});
mockAcpSendMessage.mockResolvedValue(undefined);
mockAcpCancelSession.mockResolvedValue(true);

View file

@ -136,6 +136,7 @@ describe("useChatSessionController", () => {
isLoading: false,
personaEditorOpen: false,
editingPersona: null,
personaEditorMode: "create",
});
useProjectStore.setState({

View file

@ -25,6 +25,7 @@ import {
interface UseChatSessionControllerOptions {
sessionId: string | null;
onMessageAccepted?: (sessionId: string) => void;
onCreatePersonaRequested?: () => void;
}
const PENDING_HOME_SESSION_ID = "__home_pending__";
@ -32,6 +33,7 @@ const PENDING_HOME_SESSION_ID = "__home_pending__";
export function useChatSessionController({
sessionId,
onMessageAccepted,
onCreatePersonaRequested,
}: UseChatSessionControllerOptions) {
const stateSessionId = sessionId ?? PENDING_HOME_SESSION_ID;
const {
@ -291,7 +293,12 @@ export function useChatSessionController({
const handlePersonaChange = useCallback(
(personaId: string | null) => {
if (personaId === selectedPersonaId) {
return;
}
const persona = personas.find((candidate) => candidate.id === personaId);
if (persona?.provider) {
const matchingProvider = providers.find(
(provider) =>
@ -328,6 +335,7 @@ export function useChatSessionController({
personas,
providers,
sessionId,
selectedPersonaId,
setGlobalSelectedProvider,
],
);
@ -386,7 +394,6 @@ export function useChatSessionController({
sessionId ? chatState : "thinking",
sendMessage,
);
const chatStore = useChatStore();
const handleSend = useCallback(
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
@ -398,24 +405,6 @@ export function useChatSessionController({
}
if (personaId && personaId !== selectedPersonaId) {
const nextPersona = personas.find(
(persona) => persona.id === personaId,
);
if (nextPersona) {
chatStore.addMessage(sessionId, {
id: crypto.randomUUID(),
role: "system",
created: Date.now(),
content: [
{
type: "systemNotification",
notificationType: "info",
text: `Switched to ${nextPersona.displayName}`,
},
],
metadata: { userVisible: true, agentVisible: false },
});
}
handlePersonaChange(personaId);
deferredSend.current = { text, attachments };
return;
@ -430,9 +419,7 @@ export function useChatSessionController({
},
[
chatState,
chatStore,
handlePersonaChange,
personas,
queue,
sessionId,
selectedPersonaId,
@ -449,8 +436,12 @@ export function useChatSessionController({
}, [selectedPersona, sendMessage]);
const handleCreatePersona = useCallback(() => {
if (onCreatePersonaRequested) {
onCreatePersonaRequested();
return;
}
useAgentStore.getState().openPersonaEditor();
}, []);
}, [onCreatePersonaRequested]);
const sessionDraftValue = useChatStore((s) =>
sessionId ? (s.draftsBySession[sessionId] ?? "") : "",

View file

@ -32,6 +32,7 @@ interface AgentModelPickerProps {
onModelChange?: (modelId: string) => void;
loading?: boolean;
isCompact?: boolean;
showSelectedModelInTrigger?: boolean;
}
function getModelDisplayName(model: ModelOption) {
@ -321,6 +322,7 @@ export function AgentModelPicker({
onModelChange,
loading = false,
isCompact = false,
showSelectedModelInTrigger = true,
}: AgentModelPickerProps) {
const { t } = useTranslation("chat");
const [open, setOpen] = useState(false);
@ -332,7 +334,9 @@ export function AgentModelPicker({
const selectedAgentLabel =
agents.find((agent) => agent.id === selectedAgentId)?.label ??
formatProviderLabel(selectedAgentId);
const hasSelectedModel = currentModelName !== null || currentModelId !== null;
const hasSelectedModel =
showSelectedModelInTrigger &&
(currentModelName !== null || currentModelId !== null);
const triggerModelLabel = hasSelectedModel
? (currentModelName ?? currentModelId)
: null;

View file

@ -220,6 +220,7 @@ export function ChatInputToolbar({
onModelChange={onModelChange}
loading={providersLoading}
isCompact={isCompact}
showSelectedModelInTrigger={selectedPersonaId === null}
/>
)}

View file

@ -14,12 +14,17 @@ import { useChatSessionController } from "../hooks/useChatSessionController";
interface ChatViewProps {
sessionId: string;
onCreatePersona?: () => void;
onCreateProject?: (options?: {
onCreated?: (projectId: string) => void;
}) => void;
}
export function ChatView({ sessionId, onCreateProject }: ChatViewProps) {
export function ChatView({
sessionId,
onCreatePersona,
onCreateProject,
}: ChatViewProps) {
const { t } = useTranslation("chat");
const mountStart = useRef(performance.now());
const isContextPanelOpen = useChatSessionStore(
@ -29,7 +34,10 @@ export function ChatView({ sessionId, onCreateProject }: ChatViewProps) {
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
null,
);
const controller = useChatSessionController({ sessionId });
const controller = useChatSessionController({
sessionId,
onCreatePersonaRequested: onCreatePersona,
});
const contextPanelLabel = isContextPanelOpen
? t("context.closePanel")
: t("context.openPanel");

View file

@ -89,7 +89,7 @@ describe("ChatInput", () => {
it("renders with default placeholder", () => {
render(<ChatInput onSend={vi.fn()} />);
expect(
screen.getByPlaceholderText("Message Goose, @ to mention personas"),
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
).toBeInTheDocument();
});

View file

@ -178,7 +178,7 @@ describe("HomeScreen", () => {
it("renders the chat input placeholder with default agent name when no persona selected", () => {
renderHome();
expect(
screen.getByPlaceholderText("Message Goose, @ to mention personas"),
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
).toBeInTheDocument();
});

View file

@ -41,6 +41,7 @@ function getGreetingKey(hour: number): "morning" | "afternoon" | "evening" {
interface HomeScreenProps {
sessionId: string | null;
onActivateSession: (sessionId: string) => void;
onCreatePersona?: () => void;
onCreateProject?: (options?: {
onCreated?: (projectId: string) => void;
}) => void;
@ -49,15 +50,18 @@ interface HomeScreenProps {
function HomeComposer({
sessionId,
onActivateSession,
onCreatePersona,
onCreateProject,
}: {
sessionId: string | null;
onActivateSession: (sessionId: string) => void;
onCreatePersona?: () => void;
onCreateProject?: HomeScreenProps["onCreateProject"];
}) {
const controller = useChatSessionController({
sessionId,
onMessageAccepted: onActivateSession,
onCreatePersonaRequested: onCreatePersona,
});
return (
@ -107,6 +111,7 @@ function HomeComposer({
export function HomeScreen({
sessionId,
onActivateSession,
onCreatePersona,
onCreateProject,
}: HomeScreenProps) {
const { t } = useTranslation("home");
@ -126,6 +131,7 @@ export function HomeScreen({
<HomeComposer
sessionId={sessionId}
onActivateSession={onActivateSession}
onCreatePersona={onCreatePersona}
onCreateProject={onCreateProject}
/>
</div>

View file

@ -47,7 +47,7 @@ describe("SkillsView", () => {
render(<SkillsView />);
expect(screen.getByText("Skills")).toBeInTheDocument();
expect(
screen.getByText("Reusable instructions for your AI personas"),
screen.getByText("Reusable instructions for your AI agents"),
).toBeInTheDocument();
});

View file

@ -46,6 +46,17 @@ export async function importPersonas(
return invoke("import_personas", { fileBytes, fileName });
}
export interface ImportFileReadResult {
fileBytes: number[];
fileName: string;
}
export async function readImportPersonaFile(
sourcePath: string,
): Promise<ImportFileReadResult> {
return invoke("read_import_persona_file", { sourcePath });
}
export async function savePersonaAvatar(
personaId: string,
sourcePath: string,

View file

@ -2,23 +2,34 @@ import { useCallback, useRef, useState } from "react";
interface FileImportZoneOptions {
onImportFile: (fileBytes: number[], fileName: string) => void;
validateFile?: (file: Pick<File, "name" | "type">) => string | null;
onImportError?: (message: string) => void;
}
/**
* Shared drag-and-drop + file-picker infrastructure for import zones.
* Returns state, handlers, and a ref for the hidden `<input type="file">`.
*/
export function useFileImportZone({ onImportFile }: FileImportZoneOptions) {
export function useFileImportZone({
onImportFile,
validateFile,
onImportError,
}: FileImportZoneOptions) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const importFile = useCallback(
async (file: File) => {
const validationMessage = validateFile?.(file);
if (validationMessage) {
onImportError?.(validationMessage);
return;
}
const buffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(buffer));
onImportFile(bytes, file.name);
},
[onImportFile],
[onImportFile, onImportError, validateFile],
);
const dropHandlers = {

View file

@ -10,43 +10,54 @@
"uploadAria": "Drop an image or click to upload avatar"
},
"card": {
"ariaLabel": "Persona: {{name}}",
"options": "Persona options"
"ariaLabel": "Agent: {{name}}",
"fileBacked": "File-backed",
"options": "Agent options"
},
"config": {
"ariaLabel": "Agent configuration",
"createAgent": "Create Agent",
"fromPersona": "(from persona: {{value}})",
"fromPersona": "(from agent: {{value}})",
"model": "Model",
"modelPlaceholder": "e.g. claude-sonnet-4-20250514",
"name": "Name",
"namePlaceholder": "My Agent",
"persona": "Persona",
"persona": "Agent",
"provider": "Provider",
"systemPromptOverrideCollapsed": "System Prompt Override [+]",
"systemPromptOverrideExpanded": "System Prompt Override [-]",
"systemPromptPlaceholder": "Override the persona system prompt...",
"systemPromptPlaceholder": "Override the agent system prompt...",
"updateAgent": "Update Agent"
},
"editor": {
"create": "Create",
"created": "Agent created.",
"displayName": "Display Name",
"displayNamePlaceholder": "e.g. Code Reviewer",
"duplicate": "Duplicate",
"editTitle": "Edit Persona",
"duplicated": "Agent duplicated.",
"chooseProviderFirst": "Select a provider to choose a model.",
"editTitle": "Edit Agent",
"model": "Model",
"modelPlaceholder": "e.g. claude-sonnet-4-20250514",
"newTitle": "New Persona",
"modelPlaceholder": "Select a model",
"newTitle": "New Agent",
"noModelsAvailable": "No models are available for this provider yet.",
"provider": "Provider",
"readOnlyBuiltIn": "Built-in agents are read-only. Duplicate to customize one.",
"readOnlyFile": "This agent is loaded from a file. You can review it here, but editing is disabled.",
"saveFailed": "Failed to save agent.",
"savedModelUnavailable": "{{model}} (saved, unavailable)",
"savedModelUnavailableHelp": "This agent uses a saved model that is not in the current provider inventory.",
"saving": "Saving...",
"systemPrompt": "System Prompt",
"systemPromptPlaceholder": "You are a helpful assistant that..."
"systemPromptPlaceholder": "You are a helpful assistant that...",
"updated": "Agent updated."
},
"gallery": {
"createAria": "Create new persona",
"createAria": "Create new agent",
"dropFile": "or drop a file",
"loading": "Loading personas",
"new": "New Persona"
"loading": "Loading agents",
"new": "New Agent"
},
"statuses": {
"error": "Error",
@ -55,18 +66,24 @@
"starting": "Starting"
},
"view": {
"activeAgents": "Active Agents",
"activeAgentsAria": "Active agents",
"copyName": "{{name}} (Copy)",
"deleteFailed": "Failed to delete agent.",
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
"deleteTitle": "Delete persona?",
"description": "Custom persona configurations for specific workflows",
"emptyAgentsDescription": "Create an agent from a persona to get started.",
"emptyAgentsTitle": "No active agents",
"deleteTitle": "Delete agent?",
"deleted": "\"{{name}}\" deleted.",
"description": "Custom agent configurations for specific workflows",
"emptyAgentsDescription": "Create an agent to get started.",
"emptyAgentsTitle": "No agents yet",
"exportFailed": "Failed to export agent.",
"exportedTo": "Exported to {{filename}}",
"newPersona": "New Persona",
"importInvalidExtension": "Unsupported file type. Choose a .json file.",
"importInvalidMimeType": "Unsupported file type. Choose a JSON file.",
"importFailed": "Failed to import agent.",
"imported_one": "Imported {{count}} agent.",
"imported_other": "Imported {{count}} agents.",
"newPersona": "New Agent",
"optionsAria": "Options for {{name}}",
"searchPlaceholder": "Search personas...",
"title": "Personas"
"searchPlaceholder": "Search agents...",
"title": "Agents"
}
}

View file

@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Chat message input",
"placeholder": "Message {{agent}}, @ to mention personas"
"placeholder": "Message {{agent}}, @ to mention agents"
},
"loading": {
"compacting": "Compacting conversation...",
@ -117,7 +117,7 @@
},
"mention": {
"ariaLabel": "Mention suggestions",
"title": "Mention a persona",
"title": "Mention an agent",
"filesTitle": "Files"
},
"message": {
@ -127,9 +127,9 @@
},
"persona": {
"chooseAssistant": "Choose assistant",
"create": "Create persona...",
"create": "Create agent...",
"clearActive": "Clear active assistant",
"defaultDescription": "No persona - chat directly with the agent"
"defaultDescription": "No agent selected - chat directly with Goose"
},
"queue": {
"dismiss": "Dismiss queued message",

View file

@ -18,7 +18,7 @@
"emptyNoMatchesHint": "Try a different search term.",
"emptyTitle": "No sessions yet",
"searchArchivedPlaceholder": "Search archived sessions...",
"searchError": "Message search failed. Showing title, persona, and project matches only.",
"searchError": "Message search failed. Showing title, agent, and project matches only.",
"searchPlaceholder": "Search conversations",
"searching": "Searching sessions...",
"subtitle": "Browse and search past sessions",

View file

@ -11,7 +11,7 @@
"optionsFor": "Options for {{label}}"
},
"navigation": {
"agents": "Personas",
"agents": "Agents",
"home": "Home",
"sessionHistory": "Session History",
"skills": "Skills"

View file

@ -16,7 +16,7 @@
"view": {
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
"deleteTitle": "Delete skill?",
"description": "Reusable instructions for your AI personas",
"description": "Reusable instructions for your AI agents",
"dropFile": "or drop a file",
"emptyDescription": "Create a skill or drop a .skill.json file here.",
"emptyTitle": "No skills yet",

View file

@ -10,43 +10,54 @@
"uploadAria": "Suelta una imagen o haz clic para subir un avatar"
},
"card": {
"ariaLabel": "Persona: {{name}}",
"options": "Opciones de la persona"
"ariaLabel": "Agente: {{name}}",
"fileBacked": "Desde archivo",
"options": "Opciones del agente"
},
"config": {
"ariaLabel": "Configuración del agente",
"createAgent": "Crear agente",
"fromPersona": "(desde la persona: {{value}})",
"fromPersona": "(desde el agente: {{value}})",
"model": "Modelo",
"modelPlaceholder": "p. ej. claude-sonnet-4-20250514",
"name": "Nombre",
"namePlaceholder": "Mi agente",
"persona": "Persona",
"persona": "Agente",
"provider": "Proveedor",
"systemPromptOverrideCollapsed": "Sobrescribir prompt del sistema [+]",
"systemPromptOverrideExpanded": "Sobrescribir prompt del sistema [-]",
"systemPromptPlaceholder": "Sobrescribe el prompt del sistema de la persona...",
"systemPromptPlaceholder": "Sobrescribe el prompt del sistema del agente...",
"updateAgent": "Actualizar agente"
},
"editor": {
"create": "Crear",
"created": "Agente creado.",
"displayName": "Nombre para mostrar",
"displayNamePlaceholder": "p. ej. Revisor de código",
"duplicate": "Duplicar",
"editTitle": "Editar persona",
"duplicated": "Agente duplicado.",
"chooseProviderFirst": "Selecciona un proveedor para elegir un modelo.",
"editTitle": "Editar agente",
"model": "Modelo",
"modelPlaceholder": "p. ej. claude-sonnet-4-20250514",
"newTitle": "Nueva persona",
"modelPlaceholder": "Selecciona un modelo",
"newTitle": "Nuevo agente",
"noModelsAvailable": "Todavía no hay modelos disponibles para este proveedor.",
"provider": "Proveedor",
"readOnlyBuiltIn": "Los agentes integrados son de solo lectura. Duplícalo para personalizarlo.",
"readOnlyFile": "Este agente se cargó desde un archivo. Puedes revisarlo aquí, pero la edición está deshabilitada.",
"saveFailed": "No se pudo guardar el agente.",
"savedModelUnavailable": "{{model}} (guardado, no disponible)",
"savedModelUnavailableHelp": "Este agente usa un modelo guardado que no está en el inventario actual del proveedor.",
"saving": "Guardando...",
"systemPrompt": "Prompt del sistema",
"systemPromptPlaceholder": "Eres un asistente útil que..."
"systemPromptPlaceholder": "Eres un asistente útil que...",
"updated": "Agente actualizado."
},
"gallery": {
"createAria": "Crear nueva persona",
"createAria": "Crear nuevo agente",
"dropFile": "o suelta un archivo",
"loading": "Cargando personas",
"new": "Nueva persona"
"loading": "Cargando agentes",
"new": "Nuevo agente"
},
"statuses": {
"error": "Error",
@ -55,18 +66,24 @@
"starting": "Iniciando"
},
"view": {
"activeAgents": "Agentes activos",
"activeAgentsAria": "Agentes activos",
"copyName": "{{name}} (Copia)",
"deleteFailed": "No se pudo eliminar el agente.",
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
"deleteTitle": "¿Eliminar persona?",
"description": "Configuraciones de persona personalizadas para flujos de trabajo específicos",
"emptyAgentsDescription": "Crea un agente desde una persona para empezar.",
"emptyAgentsTitle": "No hay agentes activos",
"deleteTitle": "¿Eliminar agente?",
"deleted": "Se eliminó \"{{name}}\".",
"description": "Configuraciones de agente personalizadas para flujos de trabajo específicos",
"emptyAgentsDescription": "Crea un agente para empezar.",
"emptyAgentsTitle": "Aún no hay agentes",
"exportFailed": "No se pudo exportar el agente.",
"exportedTo": "Exportado a {{filename}}",
"newPersona": "Nueva persona",
"importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .json.",
"importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo JSON.",
"importFailed": "No se pudo importar el agente.",
"imported_one": "Se importó {{count}} agente.",
"imported_other": "Se importaron {{count}} agentes.",
"newPersona": "Nuevo agente",
"optionsAria": "Opciones de {{name}}",
"searchPlaceholder": "Buscar personas...",
"title": "Personas"
"searchPlaceholder": "Buscar agentes...",
"title": "Agentes"
}
}

View file

@ -108,7 +108,7 @@
},
"input": {
"ariaLabel": "Entrada de mensaje del chat",
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar personas"
"placeholder": "Enviar mensaje a {{agent}}, usa @ para mencionar agentes"
},
"loading": {
"compacting": "Compactando conversación...",
@ -117,7 +117,7 @@
},
"mention": {
"ariaLabel": "Sugerencias de menciones",
"title": "Menciona una persona",
"title": "Menciona un agente",
"filesTitle": "Archivos"
},
"message": {
@ -127,9 +127,9 @@
},
"persona": {
"chooseAssistant": "Elegir asistente",
"create": "Crear persona...",
"create": "Crear agente...",
"clearActive": "Quitar asistente activo",
"defaultDescription": "Sin persona - chatea directamente con el agente"
"defaultDescription": "Sin agente seleccionado: chatea directamente con Goose"
},
"queue": {
"dismiss": "Descartar mensaje en cola",

View file

@ -18,7 +18,7 @@
"emptyNoMatchesHint": "Prueba con otro término de búsqueda.",
"emptyTitle": "Aún no hay sesiones",
"searchArchivedPlaceholder": "Buscar sesiones archivadas...",
"searchError": "La búsqueda de mensajes falló. Mostrando solo coincidencias por título, persona y proyecto.",
"searchError": "La búsqueda de mensajes falló. Mostrando solo coincidencias por título, agente y proyecto.",
"searchPlaceholder": "Buscar conversaciones",
"searching": "Buscando sesiones...",
"subtitle": "Explora y busca sesiones anteriores",

View file

@ -11,7 +11,7 @@
"optionsFor": "Opciones para {{label}}"
},
"navigation": {
"agents": "Personas",
"agents": "Agentes",
"home": "Inicio",
"sessionHistory": "Historial de sesiones",
"skills": "Habilidades"

View file

@ -16,7 +16,7 @@
"view": {
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
"deleteTitle": "¿Eliminar skill?",
"description": "Instrucciones reutilizables para tus personas de IA",
"description": "Instrucciones reutilizables para tus agentes de IA",
"dropFile": "o suelta un archivo",
"emptyDescription": "Crea una skill o suelta aquí un archivo .skill.json.",
"emptyTitle": "Aún no hay skills",

View file

@ -13,6 +13,8 @@ const buttonVariants = cva(
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
"destructive-flat":
"bg-destructive text-destructive-foreground shadow-none hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
"outline-flat":

View file

@ -13,9 +13,9 @@ test.describe("Draft persistence", () => {
// Wait for the 300ms debounce to persist the draft
await page.waitForTimeout(500);
// Navigate away to Personas
await page.getByRole("button", { name: "Personas" }).click();
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
// Navigate away to Agents
await page.getByRole("button", { name: "Agents" }).click();
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
// Navigate back to Home
await page.getByRole("button", { name: "Home" }).click();

View file

@ -36,6 +36,58 @@ export function buildInitScript(options?: {
const PROJECTS = ${projects};
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
const ACP_SESSIONS = [];
const PROVIDER_INVENTORY = [
{
providerId: "claude",
providerName: "Claude",
description: "Claude provider",
defaultModel: "claude-sonnet-4-20250514",
configured: true,
providerType: "Preferred",
configKeys: [],
setupSteps: [],
supportsRefresh: true,
refreshing: false,
lastUpdatedAt: null,
lastRefreshAttemptAt: null,
lastRefreshError: null,
stale: false,
modelSelectionHint: null,
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
family: "Claude",
recommended: true,
},
],
},
{
providerId: "openai",
providerName: "OpenAI",
description: "OpenAI provider",
defaultModel: "gpt-4.1",
configured: true,
providerType: "Preferred",
configKeys: [],
setupSteps: [],
supportsRefresh: true,
refreshing: false,
lastUpdatedAt: null,
lastRefreshAttemptAt: null,
lastRefreshError: null,
stale: false,
modelSelectionHint: null,
models: [
{
id: "gpt-4.1",
name: "GPT-4.1",
family: "OpenAI",
recommended: true,
},
],
},
];
const skillToSourceEntry = (s) => ({
type: "skill",
@ -126,7 +178,7 @@ export function buildInitScript(options?: {
return jsonRpcResult(message.id, { stopReason: "end_turn" });
}
case "_goose/providers/list":
return jsonRpcResult(message.id, { entries: [] });
return jsonRpcResult(message.id, { entries: PROVIDER_INVENTORY });
case "_goose/providers/inventory/refresh":
return jsonRpcResult(message.id, { started: [], skipped: [] });
case "_goose/working_dir/update":
@ -222,7 +274,7 @@ export function buildInitScript(options?: {
case "create_persona":
return Promise.resolve({
id: "mock-" + Math.random().toString(36).slice(2, 10),
displayName: args?.displayName ?? "New Persona",
displayName: args?.displayName ?? "New Agent",
systemPrompt: args?.systemPrompt ?? "",
isBuiltin: false,
createdAt: new Date().toISOString(),
@ -233,7 +285,7 @@ export function buildInitScript(options?: {
case "update_persona":
return Promise.resolve({
id: args?.id ?? "mock-updated",
displayName: args?.displayName ?? "Updated Persona",
displayName: args?.displayName ?? "Updated Agent",
systemPrompt: args?.systemPrompt ?? "",
isBuiltin: false,
createdAt: new Date().toISOString(),
@ -348,13 +400,13 @@ export async function waitForHome(page: Page) {
});
}
export async function navigateToPersonas(page: Page) {
export async function navigateToAgents(page: Page) {
await page.goto("/");
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: "Personas" }).click();
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
await page.getByRole("button", { name: "Agents" }).click();
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
}
export async function navigateToSkills(page: Page) {

View file

@ -1,150 +1,155 @@
import {
test,
expect,
navigateToPersonas,
navigateToAgents,
buildInitScript,
} from "./fixtures/tauri-mock";
test.describe("Personas view", () => {
test("navigates to personas view from sidebar", async ({
test.describe("Agents view", () => {
test("navigates to agents view from sidebar", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
// Assert heading, subtitle, and sections are visible
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
await expect(page.getByText("Custom persona configurations")).toBeVisible();
await navigateToAgents(page);
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
await expect(
page.getByRole("heading", { name: "Active Agents" }),
page.getByText("Custom agent configurations for specific workflows"),
).toBeVisible();
await expect(page.getByText("No active agents")).toBeVisible();
});
test("displays persona cards from mock data", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
// All 3 persona cards should be visible with their aria-labels
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
await expect(page.getByLabel("Persona: Scout")).toBeVisible();
await expect(page.getByLabel("Persona: Code Reviewer")).toBeVisible();
test("displays agent cards from mock data", async ({ tauriMocked: page }) => {
await navigateToAgents(page);
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
await expect(page.getByLabel("Agent: Scout")).toBeVisible();
await expect(page.getByLabel("Agent: Code Reviewer")).toBeVisible();
});
test("shows Built-in badge on builtin personas", async ({
test("shows Built-in badge on built-in agents", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
// Solo and Scout are builtin — their cards should contain "Built-in" text
const soloCard = page.getByLabel("Persona: Solo");
await navigateToAgents(page);
const soloCard = page.getByLabel("Agent: Solo");
await expect(soloCard.getByText("Built-in")).toBeVisible();
const reviewerCard = page.getByLabel("Persona: Code Reviewer");
const reviewerCard = page.getByLabel("Agent: Code Reviewer");
await expect(reviewerCard.getByText("Built-in")).not.toBeVisible();
});
test("shows create new persona button", async ({ tauriMocked: page }) => {
await navigateToPersonas(page);
await expect(page.getByLabel("Create new persona")).toBeVisible();
test("shows create new agent button", async ({ tauriMocked: page }) => {
await navigateToAgents(page);
await expect(page.getByLabel("Create new agent")).toBeVisible();
});
test("opens create persona dialog via New Persona button", async ({
test("opens create agent dialog via New Agent button", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await navigateToAgents(page);
await page
.getByRole("button", { name: "New Persona", exact: true })
.getByRole("button", { name: "New Agent", exact: true })
.first()
.click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(
dialog.locator("h2", { hasText: "New Persona" }),
).toBeVisible();
// Check form fields
await expect(dialog.locator("h2", { hasText: "New Agent" })).toBeVisible();
await expect(dialog.getByPlaceholder("e.g. Code Reviewer")).toBeVisible();
await expect(
dialog.getByPlaceholder("You are a helpful assistant that..."),
).toBeVisible();
});
test("opens create persona dialog via plus card", async ({
test("opens create agent dialog via plus card", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await page.getByLabel("Create new persona").click();
await navigateToAgents(page);
await page.getByLabel("Create new agent").click();
await expect(page.getByRole("dialog")).toBeVisible();
});
test("create dialog has disabled Create button when fields are empty", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await navigateToAgents(page);
await page
.getByRole("button", { name: "New Persona", exact: true })
.getByRole("button", { name: "New Agent", exact: true })
.first()
.click();
const dialog = page.getByRole("dialog");
// Create button should be disabled
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
});
test("create dialog enables Create button when name and prompt are filled", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await navigateToAgents(page);
await page
.getByRole("button", { name: "New Persona", exact: true })
.getByRole("button", { name: "New Agent", exact: true })
.first()
.click();
const dialog = page.getByRole("dialog");
await dialog.getByPlaceholder("e.g. Code Reviewer").fill("Test Persona");
await dialog.getByPlaceholder("e.g. Code Reviewer").fill("Test Agent");
await dialog
.getByPlaceholder("You are a helpful assistant that...")
.fill("You are a test persona");
.fill("You are a test agent");
await expect(dialog.getByRole("button", { name: "Create" })).toBeEnabled();
});
test("closes create persona dialog via Close button", async ({
test("closes create agent dialog via Close button", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await navigateToAgents(page);
await page
.getByRole("button", { name: "New Persona", exact: true })
.getByRole("button", { name: "New Agent", exact: true })
.first()
.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.getByRole("button", { name: "Close" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("dialog")).not.toBeVisible();
});
test("opens edit dialog when clicking a custom persona card", async ({
test("clicking a custom agent card opens details with edit actions", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await page.getByLabel("Persona: Code Reviewer").click();
await navigateToAgents(page);
await page.getByLabel("Agent: Code Reviewer").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
await expect(
dialog.locator("h2", { hasText: "Edit Persona" }),
dialog.locator("[data-slot='dialog-title']").filter({
hasText: "Code Reviewer",
}),
).toBeVisible();
// Fields should be pre-filled
await expect(dialog.getByPlaceholder("e.g. Code Reviewer")).toHaveValue(
"Code Reviewer",
);
await expect(dialog.getByText(/^Provider$/)).toBeVisible();
await expect(dialog.getByText("claude-sonnet-4-20250514")).toBeVisible();
await expect(dialog.getByRole("button", { name: "Edit" })).toBeVisible();
});
test("builtin persona opens read-only dialog with Duplicate button", async ({
test("built-in agent opens read-only details with Duplicate button", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
await page.getByLabel("Persona: Solo").click();
await navigateToAgents(page);
await page.getByLabel("Agent: Solo").click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
// Header shows persona name for read-only
await expect(dialog.locator("h2", { hasText: "Solo" })).toBeVisible();
// Duplicate button instead of Create/Save
await expect(
dialog.locator("[data-slot='dialog-title']").filter({
hasText: "Solo",
}),
).toBeVisible();
await expect(
dialog.getByRole("button", { name: /Duplicate/ }),
).toBeVisible();
// Should NOT have Create or Save buttons
await expect(
dialog.getByRole("button", { name: "Edit" }),
).not.toBeVisible();
await expect(
dialog.getByRole("button", { name: "Create" }),
).not.toBeVisible();
@ -153,13 +158,14 @@ test.describe("Personas view", () => {
).not.toBeVisible();
});
test("persona card dropdown menu shows correct items", async ({
test("custom agent card dropdown menu shows correct items", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
// Open dropdown for Code Reviewer (custom persona)
const card = page.getByLabel("Persona: Code Reviewer");
await card.getByLabel("Persona options").click();
await navigateToAgents(page);
const card = page.getByLabel("Agent: Code Reviewer");
await card.getByLabel("Agent options").click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible();
@ -170,15 +176,19 @@ test.describe("Personas view", () => {
await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
});
test("builtin persona dropdown menu does not show Delete", async ({
test("built-in agent dropdown menu does not show Edit or Delete", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
const card = page.getByLabel("Persona: Solo");
await card.getByLabel("Persona options").click();
await navigateToAgents(page);
const card = page.getByLabel("Agent: Solo");
await card.getByLabel("Agent options").click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible();
await expect(
menu.getByRole("menuitem", { name: "Edit" }),
).not.toBeVisible();
await expect(
menu.getByRole("menuitem", { name: "Duplicate" }),
).toBeVisible();
@ -189,12 +199,13 @@ test.describe("Personas view", () => {
});
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
await navigateToPersonas(page);
const card = page.getByLabel("Persona: Code Reviewer");
await card.getByLabel("Persona options").click();
await navigateToAgents(page);
const card = page.getByLabel("Agent: Code Reviewer");
await card.getByLabel("Agent options").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
// Confirmation dialog
await expect(page.getByText("Delete persona?")).toBeVisible();
await expect(page.getByText("Delete agent?")).toBeVisible();
await expect(
page.getByText(/Are you sure you want to delete.*Code Reviewer/),
).toBeVisible();
@ -205,46 +216,45 @@ test.describe("Personas view", () => {
test("Cancel in delete confirmation closes dialog", async ({
tauriMocked: page,
}) => {
await navigateToPersonas(page);
const card = page.getByLabel("Persona: Code Reviewer");
await card.getByLabel("Persona options").click();
await navigateToAgents(page);
const card = page.getByLabel("Agent: Code Reviewer");
await card.getByLabel("Agent options").click();
await page.getByRole("menuitem", { name: "Delete" }).click();
await expect(page.getByText("Delete persona?")).toBeVisible();
// Click Cancel within the delete confirmation dialog container
await page
.locator("text=Delete persona?")
.locator("..")
.locator("..")
.getByRole("button", { name: "Cancel" })
.click();
await expect(page.getByText("Delete persona?")).not.toBeVisible();
// Persona card should still be there
await expect(page.getByLabel("Persona: Code Reviewer")).toBeVisible();
await expect(page.getByText("Delete agent?")).toBeVisible();
const confirmDialog = page.locator(".max-w-sm", {
has: page.getByText("Delete agent?"),
});
await confirmDialog.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByText("Delete agent?")).not.toBeVisible();
await expect(page.getByLabel("Agent: Code Reviewer")).toBeVisible();
});
test("search filters personas", async ({ tauriMocked: page }) => {
await navigateToPersonas(page);
await page.getByPlaceholder("Search personas...").fill("Solo");
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
await expect(page.getByLabel("Persona: Scout")).not.toBeVisible();
await expect(page.getByLabel("Persona: Code Reviewer")).not.toBeVisible();
// Clear search
await page.getByPlaceholder("Search personas...").clear();
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
await expect(page.getByLabel("Persona: Scout")).toBeVisible();
await expect(page.getByLabel("Persona: Code Reviewer")).toBeVisible();
test("search filters agents", async ({ tauriMocked: page }) => {
await navigateToAgents(page);
await page.getByPlaceholder("Search agents...").fill("Solo");
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
await expect(page.getByLabel("Agent: Scout")).not.toBeVisible();
await expect(page.getByLabel("Agent: Code Reviewer")).not.toBeVisible();
await page.getByPlaceholder("Search agents...").clear();
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
await expect(page.getByLabel("Agent: Scout")).toBeVisible();
await expect(page.getByLabel("Agent: Code Reviewer")).toBeVisible();
});
test("empty persona state shows only create button", async ({
test("empty agent state shows only create button", async ({
tauriMocked: page,
}) => {
// Override mock data with empty personas before navigation
await page.addInitScript({
content: buildInitScript({ personas: [], skills: [] }),
});
await navigateToPersonas(page);
await expect(page.getByLabel("Create new persona")).toBeVisible();
// No persona cards should be visible
await expect(page.getByLabel(/^Persona: /)).not.toBeVisible();
await navigateToAgents(page);
await expect(page.getByLabel("Create new agent")).toBeVisible();
await expect(page.getByLabel(/^Agent: /)).not.toBeVisible();
});
});

View file

@ -12,7 +12,7 @@ test.describe("Skills view", () => {
await navigateToSkills(page);
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
await expect(
page.getByText("Reusable instructions for your AI personas"),
page.getByText("Reusable instructions for your AI agents"),
).toBeVisible();
});

View file

@ -21,7 +21,7 @@ test.describe("Smoke tests", () => {
await page.goto("/");
await expect(
page.getByPlaceholder(/Message .*, @ to mention personas/),
page.getByPlaceholder(/Message .*, @ to mention agents/),
).toBeVisible({ timeout: 10_000 });
});
});