mirror of
https://github.com/block/goose.git
synced 2026-04-26 10:40:45 +00:00
improve goose2 agent management flows (#8737)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
parent
23b3b3dcac
commit
7e2fb3ee5c
46 changed files with 1219 additions and 524 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
17
ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts
Normal file
17
ui/goose2/src/app/hooks/useCreatePersonaNavigation.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -81,6 +81,7 @@ describe("usePersonas", () => {
|
|||
isLoading: false,
|
||||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
personaEditorMode: "create",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
58
ui/goose2/src/features/agents/lib/personaImport.ts
Normal file
58
ui/goose2/src/features/agents/lib/personaImport.ts
Normal 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;
|
||||
}
|
||||
17
ui/goose2/src/features/agents/lib/personaPresentation.ts
Normal file
17
ui/goose2/src/features/agents/lib/personaPresentation.ts
Normal 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";
|
||||
}
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
108
ui/goose2/src/features/agents/ui/PersonaDetails.tsx
Normal file
108
ui/goose2/src/features/agents/ui/PersonaDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ describe("useChat attachments", () => {
|
|||
isLoading: false,
|
||||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
personaEditorMode: "create",
|
||||
});
|
||||
mockAcpCancelSession.mockResolvedValue(true);
|
||||
mockAcpPrepareSession.mockResolvedValue(undefined);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ describe("useChat", () => {
|
|||
isLoading: false,
|
||||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
personaEditorMode: "create",
|
||||
});
|
||||
mockAcpSendMessage.mockResolvedValue(undefined);
|
||||
mockAcpCancelSession.mockResolvedValue(true);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ describe("useChatSessionController", () => {
|
|||
isLoading: false,
|
||||
personaEditorOpen: false,
|
||||
editingPersona: null,
|
||||
personaEditorMode: "create",
|
||||
});
|
||||
|
||||
useProjectStore.setState({
|
||||
|
|
|
|||
|
|
@ -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] ?? "") : "",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export function ChatInputToolbar({
|
|||
onModelChange={onModelChange}
|
||||
loading={providersLoading}
|
||||
isCompact={isCompact}
|
||||
showSelectedModelInTrigger={selectedPersonaId === null}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"optionsFor": "Options for {{label}}"
|
||||
},
|
||||
"navigation": {
|
||||
"agents": "Personas",
|
||||
"agents": "Agents",
|
||||
"home": "Home",
|
||||
"sessionHistory": "Session History",
|
||||
"skills": "Skills"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"optionsFor": "Opciones para {{label}}"
|
||||
},
|
||||
"navigation": {
|
||||
"agents": "Personas",
|
||||
"agents": "Agentes",
|
||||
"home": "Inicio",
|
||||
"sessionHistory": "Historial de sesiones",
|
||||
"skills": "Habilidades"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue