mirror of
https://github.com/block/goose.git
synced 2026-04-28 03:29:36 +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::services::personas::PersonaStore;
|
||||||
use crate::types::agents::*;
|
use crate::types::agents::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|
@ -59,6 +60,57 @@ pub fn get_avatars_dir() -> String {
|
||||||
PersonaStore::avatars_dir().to_string_lossy().to_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 import/export ---
|
||||||
|
|
||||||
/// Sprout-compatible persona export format (version 1, camelCase keys).
|
/// Sprout-compatible persona export format (version 1, camelCase keys).
|
||||||
|
|
@ -208,3 +260,41 @@ pub fn import_personas(
|
||||||
let persona = store.create(request)?;
|
let persona = store.create(request)?;
|
||||||
Ok(vec![persona])
|
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::refresh_personas,
|
||||||
commands::agents::export_persona,
|
commands::agents::export_persona,
|
||||||
commands::agents::import_personas,
|
commands::agents::import_personas,
|
||||||
|
commands::agents::read_import_persona_file,
|
||||||
commands::agents::save_persona_avatar,
|
commands::agents::save_persona_avatar,
|
||||||
commands::agents::save_persona_avatar_bytes,
|
commands::agents::save_persona_avatar_bytes,
|
||||||
commands::agents::get_avatars_dir,
|
commands::agents::get_avatars_dir,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use crate::types::agents::{
|
||||||
};
|
};
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::path::PathBuf;
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
pub struct PersonaStore {
|
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.
|
/// Re-scan markdown personas and update the in-memory list.
|
||||||
/// Returns the full updated persona list.
|
/// Returns the full updated persona list.
|
||||||
pub fn refresh_markdown(&self) -> Vec<Persona> {
|
pub fn refresh_markdown(&self) -> Vec<Persona> {
|
||||||
|
|
@ -298,13 +318,29 @@ impl PersonaStore {
|
||||||
let persona = personas
|
let persona = personas
|
||||||
.iter()
|
.iter()
|
||||||
.find(|p| p.id == id)
|
.find(|p| p.id == id)
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
||||||
|
|
||||||
if persona.is_builtin {
|
if persona.is_builtin {
|
||||||
return Err("Cannot delete a built-in persona".to_string());
|
return Err("Cannot delete a built-in persona".to_string());
|
||||||
}
|
}
|
||||||
if persona.is_from_disk {
|
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
|
// Clean up local avatar file if present
|
||||||
|
|
@ -395,3 +431,27 @@ impl PersonaStore {
|
||||||
let _ = std::fs::remove_file(path);
|
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 { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync";
|
||||||
import { loadStoredHomeSessionId } from "./lib/homeSessionStorage";
|
import { loadStoredHomeSessionId } from "./lib/homeSessionStorage";
|
||||||
import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference";
|
import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference";
|
||||||
|
import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation";
|
||||||
import { AppShellContent } from "./ui/AppShellContent";
|
import { AppShellContent } from "./ui/AppShellContent";
|
||||||
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
|
import { acpPrepareSession, acpSetModel } from "@/shared/api/acp";
|
||||||
import {
|
import {
|
||||||
|
|
@ -66,14 +67,12 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||||
const agentStore = useAgentStore();
|
const agentStore = useAgentStore();
|
||||||
const projectStore = useProjectStore();
|
const projectStore = useProjectStore();
|
||||||
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);
|
const providerInventoryEntries = useProviderInventoryStore((s) => s.entries);
|
||||||
|
|
||||||
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
|
const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const homeSessionRequestRef = useRef<Promise<ChatSession | null> | null>(
|
const homeSessionRequestRef = useRef<Promise<ChatSession | null> | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadSessionMessages = useCallback(async (sessionId: string) => {
|
const loadSessionMessages = useCallback(async (sessionId: string) => {
|
||||||
const sid = sessionId.slice(0, 8);
|
const sid = sessionId.slice(0, 8);
|
||||||
const existingMsgs = useChatStore.getState().messagesBySession[sessionId];
|
const existingMsgs = useChatStore.getState().messagesBySession[sessionId];
|
||||||
|
|
@ -502,6 +501,10 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||||
[sessionStore],
|
[sessionStore],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCreatePersona = useCreatePersonaNavigation(() =>
|
||||||
|
handleNavigate("agents"),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev);
|
const toggleSidebar = () => setSidebarCollapsed((prev) => !prev);
|
||||||
|
|
||||||
const handleResizeStart = useCallback(
|
const handleResizeStart = useCallback(
|
||||||
|
|
@ -659,6 +662,7 @@ export function AppShell({ children }: { children?: React.ReactNode }) {
|
||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
activeSession={activeSession}
|
activeSession={activeSession}
|
||||||
homeSessionId={homeSessionId}
|
homeSessionId={homeSessionId}
|
||||||
|
onCreatePersona={handleCreatePersona}
|
||||||
onArchiveChat={handleArchiveChat}
|
onArchiveChat={handleArchiveChat}
|
||||||
onCreateProject={openCreateProjectDialog}
|
onCreateProject={openCreateProjectDialog}
|
||||||
onActivateHomeSession={activateHomeSession}
|
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;
|
activeView: AppView;
|
||||||
activeSession?: ChatSession;
|
activeSession?: ChatSession;
|
||||||
homeSessionId: string | null;
|
homeSessionId: string | null;
|
||||||
|
onCreatePersona: () => void;
|
||||||
onArchiveChat: (sessionId: string) => Promise<void>;
|
onArchiveChat: (sessionId: string) => Promise<void>;
|
||||||
onCreateProject: (options?: {
|
onCreateProject: (options?: {
|
||||||
initialWorkingDir?: string | null;
|
initialWorkingDir?: string | null;
|
||||||
|
|
@ -32,6 +33,7 @@ export function AppShellContent({
|
||||||
activeView,
|
activeView,
|
||||||
activeSession,
|
activeSession,
|
||||||
homeSessionId,
|
homeSessionId,
|
||||||
|
onCreatePersona,
|
||||||
onArchiveChat,
|
onArchiveChat,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
onActivateHomeSession,
|
onActivateHomeSession,
|
||||||
|
|
@ -61,12 +63,14 @@ export function AppShellContent({
|
||||||
<ChatView
|
<ChatView
|
||||||
key={activeSession.id}
|
key={activeSession.id}
|
||||||
sessionId={activeSession.id}
|
sessionId={activeSession.id}
|
||||||
|
onCreatePersona={onCreatePersona}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HomeScreen
|
<HomeScreen
|
||||||
sessionId={homeSessionId}
|
sessionId={homeSessionId}
|
||||||
onActivateSession={onActivateHomeSession}
|
onActivateSession={onActivateHomeSession}
|
||||||
|
onCreatePersona={onCreatePersona}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -75,6 +79,7 @@ export function AppShellContent({
|
||||||
<HomeScreen
|
<HomeScreen
|
||||||
sessionId={homeSessionId}
|
sessionId={homeSessionId}
|
||||||
onActivateSession={onActivateHomeSession}
|
onActivateSession={onActivateHomeSession}
|
||||||
|
onCreatePersona={onCreatePersona}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ describe("usePersonas", () => {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
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,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -140,12 +141,14 @@ describe("agentStore", () => {
|
||||||
useAgentStore.getState().openPersonaEditor(p);
|
useAgentStore.getState().openPersonaEditor(p);
|
||||||
expect(useAgentStore.getState().personaEditorOpen).toBe(true);
|
expect(useAgentStore.getState().personaEditorOpen).toBe(true);
|
||||||
expect(useAgentStore.getState().editingPersona).toEqual(p);
|
expect(useAgentStore.getState().editingPersona).toEqual(p);
|
||||||
|
expect(useAgentStore.getState().personaEditorMode).toBe("edit");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("openPersonaEditor without persona sets editingPersona to null", () => {
|
it("openPersonaEditor without persona sets editingPersona to null", () => {
|
||||||
useAgentStore.getState().openPersonaEditor();
|
useAgentStore.getState().openPersonaEditor();
|
||||||
expect(useAgentStore.getState().personaEditorOpen).toBe(true);
|
expect(useAgentStore.getState().personaEditorOpen).toBe(true);
|
||||||
expect(useAgentStore.getState().editingPersona).toBeNull();
|
expect(useAgentStore.getState().editingPersona).toBeNull();
|
||||||
|
expect(useAgentStore.getState().personaEditorMode).toBe("create");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("closePersonaEditor clears editing state", () => {
|
it("closePersonaEditor clears editing state", () => {
|
||||||
|
|
@ -153,6 +156,7 @@ describe("agentStore", () => {
|
||||||
useAgentStore.getState().closePersonaEditor();
|
useAgentStore.getState().closePersonaEditor();
|
||||||
expect(useAgentStore.getState().personaEditorOpen).toBe(false);
|
expect(useAgentStore.getState().personaEditorOpen).toBe(false);
|
||||||
expect(useAgentStore.getState().editingPersona).toBeNull();
|
expect(useAgentStore.getState().editingPersona).toBeNull();
|
||||||
|
expect(useAgentStore.getState().personaEditorMode).toBe("create");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ interface AgentStoreState {
|
||||||
// UI state
|
// UI state
|
||||||
personaEditorOpen: boolean;
|
personaEditorOpen: boolean;
|
||||||
editingPersona: Persona | null;
|
editingPersona: Persona | null;
|
||||||
|
personaEditorMode: "create" | "edit" | "details";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AgentStoreActions {
|
interface AgentStoreActions {
|
||||||
|
|
@ -83,7 +84,10 @@ interface AgentStoreActions {
|
||||||
getActiveAgent: () => Agent | null;
|
getActiveAgent: () => Agent | null;
|
||||||
|
|
||||||
// Persona editor
|
// Persona editor
|
||||||
openPersonaEditor: (persona?: Persona) => void;
|
openPersonaEditor: (
|
||||||
|
persona?: Persona,
|
||||||
|
mode?: "create" | "edit" | "details",
|
||||||
|
) => void;
|
||||||
closePersonaEditor: () => void;
|
closePersonaEditor: () => void;
|
||||||
|
|
||||||
// Loading
|
// Loading
|
||||||
|
|
@ -112,6 +116,7 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
|
|
||||||
// Persona CRUD
|
// Persona CRUD
|
||||||
setPersonas: (personas) => set({ personas }),
|
setPersonas: (personas) => set({ personas }),
|
||||||
|
|
@ -187,16 +192,18 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
// Persona editor
|
// Persona editor
|
||||||
openPersonaEditor: (persona) =>
|
openPersonaEditor: (persona, mode) =>
|
||||||
set({
|
set({
|
||||||
personaEditorOpen: true,
|
personaEditorOpen: true,
|
||||||
editingPersona: persona ?? null,
|
editingPersona: persona ?? null,
|
||||||
|
personaEditorMode: mode ?? (persona ? "edit" : "create"),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
closePersonaEditor: () =>
|
closePersonaEditor: () =>
|
||||||
set({
|
set({
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// Loading
|
// Loading
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useState, useMemo, useCallback, useRef } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Bot, Plus, Circle, Upload } from "lucide-react";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { Plus, Upload } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { SearchBar } from "@/shared/ui/SearchBar";
|
import { SearchBar } from "@/shared/ui/SearchBar";
|
||||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -17,66 +18,36 @@ import {
|
||||||
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
import { useAgentStore } from "@/features/agents/stores/agentStore";
|
||||||
import { PersonaGallery } from "@/features/agents/ui/PersonaGallery";
|
import { PersonaGallery } from "@/features/agents/ui/PersonaGallery";
|
||||||
import { PersonaEditor } from "@/features/agents/ui/PersonaEditor";
|
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 { usePersonas } from "@/features/agents/hooks/usePersonas";
|
||||||
import type {
|
import type {
|
||||||
Persona,
|
Persona,
|
||||||
Agent,
|
|
||||||
AgentStatus,
|
|
||||||
CreatePersonaRequest,
|
CreatePersonaRequest,
|
||||||
UpdatePersonaRequest,
|
UpdatePersonaRequest,
|
||||||
} from "@/shared/types/agents";
|
} from "@/shared/types/agents";
|
||||||
|
import {
|
||||||
const STATUS_STYLES: Record<AgentStatus, { dot: string; labelKey: string }> = {
|
formatAgentError,
|
||||||
online: { dot: "text-green-500", labelKey: "statuses.online" },
|
formatImportSuccessMessage,
|
||||||
offline: { dot: "text-muted-foreground", labelKey: "statuses.offline" },
|
validatePersonaImportFile,
|
||||||
starting: { dot: "text-yellow-500", labelKey: "statuses.starting" },
|
} from "@/features/agents/lib/personaImport";
|
||||||
error: { dot: "text-red-500", labelKey: "statuses.error" },
|
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentsView() {
|
export function AgentsView() {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
|
const [deletingPersona, setDeletingPersona] = useState<Persona | null>(null);
|
||||||
const [notification, setNotification] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const personas = useAgentStore((s) => s.personas);
|
const personas = useAgentStore((s) => s.personas);
|
||||||
const personasLoading = useAgentStore((s) => s.personasLoading);
|
const personasLoading = useAgentStore((s) => s.personasLoading);
|
||||||
const agents = useAgentStore((s) => s.agents);
|
|
||||||
const personaEditorOpen = useAgentStore((s) => s.personaEditorOpen);
|
const personaEditorOpen = useAgentStore((s) => s.personaEditorOpen);
|
||||||
const editingPersona = useAgentStore((s) => s.editingPersona);
|
const editingPersona = useAgentStore((s) => s.editingPersona);
|
||||||
|
const personaEditorMode = useAgentStore((s) => s.personaEditorMode);
|
||||||
const openPersonaEditor = useAgentStore((s) => s.openPersonaEditor);
|
const openPersonaEditor = useAgentStore((s) => s.openPersonaEditor);
|
||||||
const closePersonaEditor = useAgentStore((s) => s.closePersonaEditor);
|
const closePersonaEditor = useAgentStore((s) => s.closePersonaEditor);
|
||||||
const addPersona = useAgentStore((s) => s.addPersona);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createPersona,
|
createPersona,
|
||||||
|
|
@ -97,48 +68,54 @@ export function AgentsView() {
|
||||||
[personas, lowerSearch],
|
[personas, lowerSearch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const filteredAgents = useMemo(
|
|
||||||
() =>
|
|
||||||
agents.filter(
|
|
||||||
(a) =>
|
|
||||||
a.name.toLowerCase().includes(lowerSearch) ||
|
|
||||||
a.persona?.displayName.toLowerCase().includes(lowerSearch),
|
|
||||||
),
|
|
||||||
[agents, lowerSearch],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSavePersona = useCallback(
|
const handleSavePersona = useCallback(
|
||||||
async (data: CreatePersonaRequest | UpdatePersonaRequest) => {
|
async (data: CreatePersonaRequest | UpdatePersonaRequest) => {
|
||||||
if (editingPersona) {
|
try {
|
||||||
|
if (editingPersona && personaEditorMode === "edit") {
|
||||||
await updatePersonaViaHook(
|
await updatePersonaViaHook(
|
||||||
editingPersona.id,
|
editingPersona.id,
|
||||||
data as UpdatePersonaRequest,
|
data as UpdatePersonaRequest,
|
||||||
);
|
);
|
||||||
|
toast.success(t("editor.updated"));
|
||||||
} else {
|
} else {
|
||||||
await createPersona(data as CreatePersonaRequest);
|
await createPersona(data as CreatePersonaRequest);
|
||||||
|
toast.success(t("editor.created"));
|
||||||
}
|
}
|
||||||
closePersonaEditor();
|
closePersonaEditor();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(formatAgentError(error, t("editor.saveFailed")));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[editingPersona, createPersona, updatePersonaViaHook, closePersonaEditor],
|
[
|
||||||
|
closePersonaEditor,
|
||||||
|
createPersona,
|
||||||
|
editingPersona,
|
||||||
|
personaEditorMode,
|
||||||
|
t,
|
||||||
|
updatePersonaViaHook,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDuplicatePersona = useCallback(
|
const handleDuplicatePersona = useCallback(
|
||||||
(persona: Persona) => {
|
async (persona: Persona) => {
|
||||||
const duplicate: Persona = {
|
try {
|
||||||
...persona,
|
await createPersona({
|
||||||
id: crypto.randomUUID(),
|
|
||||||
displayName: t("view.copyName", { name: persona.displayName }),
|
displayName: t("view.copyName", { name: persona.displayName }),
|
||||||
isBuiltin: false,
|
avatar: persona.avatar ?? undefined,
|
||||||
createdAt: new Date().toISOString(),
|
systemPrompt: persona.systemPrompt,
|
||||||
updatedAt: new Date().toISOString(),
|
provider: persona.provider,
|
||||||
};
|
model: persona.model,
|
||||||
addPersona(duplicate);
|
});
|
||||||
|
toast.success(t("editor.duplicated"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(formatAgentError(error, t("editor.saveFailed")));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[addPersona, t],
|
[createPersona, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeletePersona = useCallback((persona: Persona) => {
|
const handleDeletePersona = useCallback((persona: Persona) => {
|
||||||
if (persona.isBuiltin) return;
|
if (getPersonaSource(persona) === "builtin") return;
|
||||||
setDeletingPersona(persona);
|
setDeletingPersona(persona);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -146,11 +123,15 @@ export function AgentsView() {
|
||||||
if (!deletingPersona) return;
|
if (!deletingPersona) return;
|
||||||
try {
|
try {
|
||||||
await deletePersona(deletingPersona.id);
|
await deletePersona(deletingPersona.id);
|
||||||
|
if (editingPersona?.id === deletingPersona.id) {
|
||||||
|
closePersonaEditor();
|
||||||
|
}
|
||||||
|
toast.success(t("view.deleted", { name: deletingPersona.displayName }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete persona:", err);
|
toast.error(formatAgentError(err, t("view.deleteFailed")));
|
||||||
}
|
}
|
||||||
setDeletingPersona(null);
|
setDeletingPersona(null);
|
||||||
}, [deletingPersona, deletePersona]);
|
}, [closePersonaEditor, deletingPersona, deletePersona, editingPersona, t]);
|
||||||
|
|
||||||
const handleExportPersona = useCallback(
|
const handleExportPersona = useCallback(
|
||||||
async (persona: Persona) => {
|
async (persona: Persona) => {
|
||||||
|
|
@ -166,53 +147,77 @@ export function AgentsView() {
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
setNotification(
|
toast.success(
|
||||||
t("view.exportedTo", { filename: result.suggestedFilename }),
|
t("view.exportedTo", { filename: result.suggestedFilename }),
|
||||||
);
|
);
|
||||||
setTimeout(() => setNotification(null), 3000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to export persona:", err);
|
toast.error(formatAgentError(err, t("view.exportFailed")));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const importInputRef = useRef<HTMLInputElement>(null);
|
const handleImportError = useCallback((message: string) => {
|
||||||
|
toast.error(message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleImportFile = useCallback(
|
const validateImportFile = useCallback(
|
||||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
(file: Pick<File, "name" | "type">) => {
|
||||||
const file = e.target.files?.[0];
|
const message = validatePersonaImportFile(file);
|
||||||
if (!file) return;
|
return message ? t(message.key, message.options) : null;
|
||||||
|
|
||||||
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 = "";
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[refreshFromDisk],
|
[t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleImportFileBytes = useCallback(
|
const handleImportFileBytes = useCallback(
|
||||||
async (fileBytes: number[], fileName: string) => {
|
async (fileBytes: number[], fileName: string) => {
|
||||||
try {
|
try {
|
||||||
await importPersonas(fileBytes, fileName);
|
const imported = await importPersonas(fileBytes, fileName);
|
||||||
await refreshFromDisk();
|
await refreshFromDisk();
|
||||||
|
const message = formatImportSuccessMessage(imported.length);
|
||||||
|
toast.success(t(message.key, message.options));
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<div className="flex flex-1 flex-col h-full min-h-0">
|
<div className="flex flex-1 flex-col h-full min-h-0">
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
|
@ -228,18 +233,11 @@ export function AgentsView() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
|
||||||
ref={importInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".persona.json,.json"
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleImportFile}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline-flat"
|
variant="outline-flat"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => importInputRef.current?.click()}
|
onClick={() => void handleImportPicker()}
|
||||||
>
|
>
|
||||||
<Upload className="w-3.5 h-3.5" />
|
<Upload className="w-3.5 h-3.5" />
|
||||||
{t("common:actions.import")}
|
{t("common:actions.import")}
|
||||||
|
|
@ -267,45 +265,18 @@ export function AgentsView() {
|
||||||
<section aria-labelledby="personas-heading">
|
<section aria-labelledby="personas-heading">
|
||||||
<PersonaGallery
|
<PersonaGallery
|
||||||
personas={filteredPersonas}
|
personas={filteredPersonas}
|
||||||
onSelectPersona={(p) => openPersonaEditor(p)}
|
onSelectPersona={(p) => openPersonaEditor(p, "details")}
|
||||||
onEditPersona={(p) => openPersonaEditor(p)}
|
onEditPersona={(p) => openPersonaEditor(p, "edit")}
|
||||||
onDuplicatePersona={handleDuplicatePersona}
|
onDuplicatePersona={handleDuplicatePersona}
|
||||||
onDeletePersona={handleDeletePersona}
|
onDeletePersona={handleDeletePersona}
|
||||||
onExportPersona={handleExportPersona}
|
onExportPersona={handleExportPersona}
|
||||||
onCreatePersona={() => openPersonaEditor()}
|
onCreatePersona={() => openPersonaEditor()}
|
||||||
onImportFile={handleImportFileBytes}
|
onImportFile={handleImportFileBytes}
|
||||||
|
validateImportFile={validateImportFile}
|
||||||
|
onImportError={handleImportError}
|
||||||
isLoading={personasLoading}
|
isLoading={personasLoading}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -313,9 +284,12 @@ export function AgentsView() {
|
||||||
<PersonaEditor
|
<PersonaEditor
|
||||||
persona={editingPersona ?? undefined}
|
persona={editingPersona ?? undefined}
|
||||||
isOpen={personaEditorOpen}
|
isOpen={personaEditorOpen}
|
||||||
|
mode={personaEditorMode}
|
||||||
onClose={closePersonaEditor}
|
onClose={closePersonaEditor}
|
||||||
onSave={handleSavePersona}
|
onSave={handleSavePersona}
|
||||||
onDuplicate={handleDuplicatePersona}
|
onDuplicate={handleDuplicatePersona}
|
||||||
|
onEdit={(persona) => openPersonaEditor(persona, "edit")}
|
||||||
|
onDelete={handleDeletePersona}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete confirmation dialog */}
|
{/* Delete confirmation dialog */}
|
||||||
|
|
@ -343,13 +317,6 @@ export function AgentsView() {
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
} from "@/shared/ui/dropdown-menu";
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||||
import type { Persona } from "@/shared/types/agents";
|
import type { Persona } from "@/shared/types/agents";
|
||||||
|
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||||
|
|
||||||
interface PersonaCardProps {
|
interface PersonaCardProps {
|
||||||
persona: Persona;
|
persona: Persona;
|
||||||
|
|
@ -38,18 +39,30 @@ export function PersonaCard({
|
||||||
|
|
||||||
const initials = persona.displayName.charAt(0).toUpperCase();
|
const initials = persona.displayName.charAt(0).toUpperCase();
|
||||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
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(" / ");
|
||||||
|
|
||||||
return (
|
const handleCardKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
<section
|
if (event.target !== event.currentTarget || menuOpen) {
|
||||||
aria-label={t("card.ariaLabel", { name: persona.displayName })}
|
return;
|
||||||
onClick={() => !menuOpen && onSelect?.(persona)}
|
}
|
||||||
onKeyDown={(e) => {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
event.preventDefault();
|
||||||
e.preventDefault();
|
|
||||||
onSelect?.(persona);
|
onSelect?.(persona);
|
||||||
}
|
}
|
||||||
}}
|
};
|
||||||
// biome-ignore lint/a11y/noNoninteractiveTabindex: card needs keyboard focus but contains nested interactive buttons
|
|
||||||
|
return (
|
||||||
|
// 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={handleCardKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative flex flex-col items-center gap-3 rounded-xl border p-5 cursor-pointer",
|
"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"
|
size="icon-xs"
|
||||||
aria-label={t("card.options")}
|
aria-label={t("card.options")}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-6 rounded-md text-muted-foreground hover:text-foreground",
|
"size-6 rounded-md text-muted-foreground hover:text-foreground",
|
||||||
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
menuOpen ? "opacity-100" : "opacity-0 group-hover:opacity-100",
|
||||||
|
|
@ -77,10 +91,12 @@ export function PersonaCard({
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" sideOffset={4}>
|
<DropdownMenuContent align="end" sideOffset={4}>
|
||||||
|
{canEditPersona && (
|
||||||
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
|
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
|
||||||
<Pencil className="size-3.5" />
|
<Pencil className="size-3.5" />
|
||||||
{t("common:actions.edit")}
|
{t("common:actions.edit")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
|
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
|
||||||
<Copy className="size-3.5" />
|
<Copy className="size-3.5" />
|
||||||
{t("common:actions.duplicate")}
|
{t("common:actions.duplicate")}
|
||||||
|
|
@ -89,7 +105,7 @@ export function PersonaCard({
|
||||||
<Download className="size-3.5" />
|
<Download className="size-3.5" />
|
||||||
{t("common:actions.export")}
|
{t("common:actions.export")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{!persona.isBuiltin && !persona.isFromDisk && (
|
{canDeletePersona && (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onSelect={() => onDelete?.(persona)}
|
onSelect={() => onDelete?.(persona)}
|
||||||
|
|
@ -116,11 +132,16 @@ export function PersonaCard({
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Built-in badge */}
|
{/* Built-in badge */}
|
||||||
{persona.isBuiltin && (
|
{personaSource === "builtin" && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
{t("common:labels.builtIn")}
|
{t("common:labels.builtIn")}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{personaSource === "file" && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{t("card.fileBacked")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* System prompt preview */}
|
{/* System prompt preview */}
|
||||||
<p className="text-xs text-muted-foreground text-center line-clamp-2 w-full">
|
<p className="text-xs text-muted-foreground text-center line-clamp-2 w-full">
|
||||||
|
|
@ -128,15 +149,13 @@ export function PersonaCard({
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Provider/model badge */}
|
{/* Provider/model badge */}
|
||||||
{(persona.provider || persona.model) && (
|
{providerModelLabel && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<Badge variant="secondary" className="max-w-full min-w-0 text-[10px]">
|
||||||
{persona.provider && <span>{persona.provider}</span>}
|
<span className="block max-w-full truncate">
|
||||||
{persona.provider && persona.model && (
|
{providerModelLabel}
|
||||||
<span aria-hidden="true">/</span>
|
</span>
|
||||||
)}
|
|
||||||
{persona.model && <span>{persona.model}</span>}
|
|
||||||
</Badge>
|
</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 { useState, useEffect, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy } from "lucide-react";
|
import { Copy, Pencil, Trash2 } from "lucide-react";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import {
|
import {
|
||||||
Avatar as AvatarRoot,
|
Avatar as AvatarRoot,
|
||||||
|
|
@ -11,6 +11,7 @@ import { Button } from "@/shared/ui/button";
|
||||||
import { Input } from "@/shared/ui/input";
|
import { Input } from "@/shared/ui/input";
|
||||||
import { Label } from "@/shared/ui/label";
|
import { Label } from "@/shared/ui/label";
|
||||||
import { Textarea } from "@/shared/ui/textarea";
|
import { Textarea } from "@/shared/ui/textarea";
|
||||||
|
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -25,46 +26,60 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/ui/select";
|
} from "@/shared/ui/select";
|
||||||
|
import type { Persona, ProviderType, Avatar } from "@/shared/types/agents";
|
||||||
import type {
|
import type {
|
||||||
Persona,
|
|
||||||
ProviderType,
|
|
||||||
Avatar,
|
|
||||||
CreatePersonaRequest,
|
CreatePersonaRequest,
|
||||||
UpdatePersonaRequest,
|
UpdatePersonaRequest,
|
||||||
} from "@/shared/types/agents";
|
} 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 { AvatarDropZone } from "./AvatarDropZone";
|
||||||
|
import { PersonaDetails } from "./PersonaDetails";
|
||||||
|
|
||||||
interface PersonaEditorProps {
|
interface PersonaEditorProps {
|
||||||
persona?: Persona;
|
persona?: Persona;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
mode?: "create" | "edit" | "details";
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (data: CreatePersonaRequest | UpdatePersonaRequest) => void;
|
onSave: (data: CreatePersonaRequest | UpdatePersonaRequest) => void;
|
||||||
onDuplicate?: (persona: Persona) => void;
|
onDuplicate?: (persona: Persona) => void;
|
||||||
|
onEdit?: (persona: Persona) => void;
|
||||||
|
onDelete?: (persona: Persona) => void;
|
||||||
isPending?: boolean;
|
isPending?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PersonaEditor({
|
export function PersonaEditor({
|
||||||
persona,
|
persona,
|
||||||
isOpen,
|
isOpen,
|
||||||
|
mode = "create",
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
isPending = false,
|
isPending = false,
|
||||||
}: PersonaEditorProps) {
|
}: PersonaEditorProps) {
|
||||||
const { t } = useTranslation(["agents", "common"]);
|
const { t } = useTranslation(["agents", "common"]);
|
||||||
const isEditing = !!persona;
|
const isEditing = mode === "edit";
|
||||||
const isReadOnly = persona?.isBuiltin ?? false;
|
const detailsMode = mode === "details";
|
||||||
|
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
|
||||||
const [acpProviders, setAcpProviders] = useState<AcpProvider[]>([]);
|
const isReadOnly = detailsMode || readOnlyBySource;
|
||||||
|
const personaSource = persona ? getPersonaSource(persona) : "custom";
|
||||||
useEffect(() => {
|
const canEditPersona = personaSource === "custom";
|
||||||
if (isOpen) {
|
const canDeletePersona = personaSource !== "builtin";
|
||||||
discoverAcpProviders()
|
const acpProviders = useAgentStore((s) => s.providers);
|
||||||
.then(setAcpProviders)
|
const setProviders = useAgentStore((s) => s.setProviders);
|
||||||
.catch(() => setAcpProviders([]));
|
const mergeInventoryEntries = useProviderInventoryStore(
|
||||||
}
|
(s) => s.mergeEntries,
|
||||||
}, [isOpen]);
|
);
|
||||||
|
const { getEntry, getModelsForProvider } = useProviderInventory();
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [avatar, setAvatar] = useState<Avatar | null>(null);
|
const [avatar, setAvatar] = useState<Avatar | null>(null);
|
||||||
|
|
@ -72,6 +87,36 @@ export function PersonaEditor({
|
||||||
const [provider, setProvider] = useState<ProviderType | "">("");
|
const [provider, setProvider] = useState<ProviderType | "">("");
|
||||||
const [model, setModel] = useState("");
|
const [model, setModel] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const syncProviderOptions = async () => {
|
||||||
|
try {
|
||||||
|
const providers = await discoverAcpProviders();
|
||||||
|
if (!cancelled) {
|
||||||
|
setProviders(providers);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await getProviderInventory();
|
||||||
|
if (!cancelled) {
|
||||||
|
mergeInventoryEntries(entries);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
void syncProviderOptions();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen, mergeInventoryEntries, setProviders]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && persona) {
|
if (isOpen && persona) {
|
||||||
setDisplayName(persona.displayName);
|
setDisplayName(persona.displayName);
|
||||||
|
|
@ -90,6 +135,29 @@ export function PersonaEditor({
|
||||||
|
|
||||||
const isValid =
|
const isValid =
|
||||||
displayName.trim().length > 0 && systemPrompt.trim().length > 0;
|
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(
|
const handleSubmit = useCallback(
|
||||||
(e: React.FormEvent) => {
|
(e: React.FormEvent) => {
|
||||||
|
|
@ -127,25 +195,39 @@ export function PersonaEditor({
|
||||||
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col gap-0 p-0">
|
||||||
<DialogHeader className="shrink-0 px-5 py-4">
|
<DialogHeader className="shrink-0 px-5 py-4">
|
||||||
<DialogTitle className="text-sm">
|
<DialogTitle className="text-sm">
|
||||||
{isReadOnly
|
{detailsMode
|
||||||
? persona?.displayName
|
? persona?.displayName
|
||||||
: isEditing
|
: isEditing
|
||||||
? t("editor.editTitle")
|
? t("editor.editTitle")
|
||||||
: t("editor.newTitle")}
|
: t("editor.newTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
{readOnlyDescription ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{readOnlyDescription}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
|
{detailsMode ? (
|
||||||
|
<PersonaDetails
|
||||||
|
avatar={avatar}
|
||||||
|
displayName={displayName}
|
||||||
|
modelLabel={modelLabel}
|
||||||
|
personaSource={personaSource}
|
||||||
|
providerLabel={providerLabel}
|
||||||
|
systemPrompt={systemPrompt}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<form
|
<form
|
||||||
id="persona-form"
|
id="persona-form"
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
|
className="min-h-0 flex-1 overflow-y-auto space-y-4 px-5 pb-5"
|
||||||
>
|
>
|
||||||
{/* Avatar drop zone */}
|
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<AvatarRoot className="h-16 w-16 border border-border">
|
<AvatarRoot className="h-16 w-16 border border-border">
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
src={avatar?.type === "url" ? avatar.value : undefined}
|
src={avatarSrc ?? undefined}
|
||||||
alt={t("avatar.previewAlt")}
|
alt={t("avatar.previewAlt")}
|
||||||
/>
|
/>
|
||||||
<AvatarFallback className="text-lg font-semibold">
|
<AvatarFallback className="text-lg font-semibold">
|
||||||
|
|
@ -162,7 +244,6 @@ export function PersonaEditor({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Display Name */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
{t("editor.displayName")}{" "}
|
{t("editor.displayName")}{" "}
|
||||||
|
|
@ -178,7 +259,6 @@ export function PersonaEditor({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* System Prompt */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
|
|
@ -205,20 +285,22 @@ export function PersonaEditor({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
{t("editor.provider")}
|
{t("editor.provider")}
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={provider || "__none__"}
|
value={provider || "__none__"}
|
||||||
onValueChange={(v: string) =>
|
onValueChange={(v: string) => {
|
||||||
setProvider(
|
const nextProvider =
|
||||||
v === "__none__"
|
v === "__none__"
|
||||||
? ("" as ProviderType | "")
|
? ("" as ProviderType | "")
|
||||||
: (v as ProviderType),
|
: (v as ProviderType);
|
||||||
)
|
setProvider(nextProvider);
|
||||||
|
if (nextProvider !== provider) {
|
||||||
|
setModel("");
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
|
|
@ -234,7 +316,10 @@ export function PersonaEditor({
|
||||||
{t("common:labels.none")}
|
{t("common:labels.none")}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
{acpProviders.map((providerOption) => (
|
{acpProviders.map((providerOption) => (
|
||||||
<SelectItem key={providerOption.id} value={providerOption.id}>
|
<SelectItem
|
||||||
|
key={providerOption.id}
|
||||||
|
value={providerOption.id}
|
||||||
|
>
|
||||||
{providerOption.label}
|
{providerOption.label}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -242,32 +327,127 @@ export function PersonaEditor({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-xs font-medium text-muted-foreground">
|
<Label className="text-xs font-medium text-muted-foreground">
|
||||||
{t("editor.model")}
|
{t("editor.model")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Select
|
||||||
value={model}
|
value={modelSelectValue}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onValueChange={(value: string) => {
|
||||||
readOnly={isReadOnly}
|
if (value === "__none__") {
|
||||||
placeholder={t("editor.modelPlaceholder")}
|
setModel("");
|
||||||
className={cn(isReadOnly && "opacity-70 cursor-not-allowed")}
|
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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter className="shrink-0 border-t px-5 py-4">
|
<DialogFooter className="shrink-0 border-t px-5 py-4">
|
||||||
{isReadOnly && onDuplicate && persona ? (
|
{detailsMode && persona ? (
|
||||||
|
<>
|
||||||
|
{onEdit && canEditPersona ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
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"
|
size="sm"
|
||||||
onClick={() => onDuplicate(persona)}
|
onClick={() => onDuplicate(persona)}
|
||||||
>
|
>
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
{t("editor.duplicate")}
|
{t("editor.duplicate")}
|
||||||
</Button>
|
</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}>
|
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ interface PersonaGalleryProps {
|
||||||
onExportPersona?: (persona: Persona) => void;
|
onExportPersona?: (persona: Persona) => void;
|
||||||
onCreatePersona: () => void;
|
onCreatePersona: () => void;
|
||||||
onImportFile?: (fileBytes: number[], fileName: string) => void;
|
onImportFile?: (fileBytes: number[], fileName: string) => void;
|
||||||
|
validateImportFile?: (file: Pick<File, "name" | "type">) => string | null;
|
||||||
|
onImportError?: (message: string) => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,12 +47,16 @@ export function PersonaGallery({
|
||||||
onExportPersona,
|
onExportPersona,
|
||||||
onCreatePersona,
|
onCreatePersona,
|
||||||
onImportFile,
|
onImportFile,
|
||||||
|
validateImportFile,
|
||||||
|
onImportError,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: PersonaGalleryProps) {
|
}: PersonaGalleryProps) {
|
||||||
const { t } = useTranslation("agents");
|
const { t } = useTranslation("agents");
|
||||||
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
|
const { fileInputRef, isDragOver, dropHandlers, handleFileChange } =
|
||||||
useFileImportZone({
|
useFileImportZone({
|
||||||
onImportFile: onImportFile ?? (() => {}),
|
onImportFile: onImportFile ?? (() => {}),
|
||||||
|
validateFile: validateImportFile,
|
||||||
|
onImportError,
|
||||||
});
|
});
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
const builtins = personas
|
const builtins = personas
|
||||||
|
|
@ -120,7 +126,7 @@ export function PersonaGallery({
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".persona.json,.json"
|
accept=".json,application/json"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ describe("PersonaCard", () => {
|
||||||
const persona = makePersona();
|
const persona = makePersona();
|
||||||
render(<PersonaCard persona={persona} onSelect={onSelect} />);
|
render(<PersonaCard persona={persona} onSelect={onSelect} />);
|
||||||
|
|
||||||
await user.click(screen.getByLabelText(/^persona: /i));
|
await user.click(screen.getByLabelText(/^agent: /i));
|
||||||
expect(onSelect).toHaveBeenCalledWith(persona);
|
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("menu")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
|
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
|
||||||
expect(
|
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 });
|
const deleteBtn = screen.queryByRole("menuitem", { name: /delete/i });
|
||||||
expect(deleteBtn).toBeNull();
|
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,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
});
|
});
|
||||||
mockAcpCancelSession.mockResolvedValue(true);
|
mockAcpCancelSession.mockResolvedValue(true);
|
||||||
mockAcpPrepareSession.mockResolvedValue(undefined);
|
mockAcpPrepareSession.mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ describe("useChat", () => {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
});
|
});
|
||||||
mockAcpSendMessage.mockResolvedValue(undefined);
|
mockAcpSendMessage.mockResolvedValue(undefined);
|
||||||
mockAcpCancelSession.mockResolvedValue(true);
|
mockAcpCancelSession.mockResolvedValue(true);
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ describe("useChatSessionController", () => {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
personaEditorOpen: false,
|
personaEditorOpen: false,
|
||||||
editingPersona: null,
|
editingPersona: null,
|
||||||
|
personaEditorMode: "create",
|
||||||
});
|
});
|
||||||
|
|
||||||
useProjectStore.setState({
|
useProjectStore.setState({
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import {
|
||||||
interface UseChatSessionControllerOptions {
|
interface UseChatSessionControllerOptions {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onMessageAccepted?: (sessionId: string) => void;
|
onMessageAccepted?: (sessionId: string) => void;
|
||||||
|
onCreatePersonaRequested?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PENDING_HOME_SESSION_ID = "__home_pending__";
|
const PENDING_HOME_SESSION_ID = "__home_pending__";
|
||||||
|
|
@ -32,6 +33,7 @@ const PENDING_HOME_SESSION_ID = "__home_pending__";
|
||||||
export function useChatSessionController({
|
export function useChatSessionController({
|
||||||
sessionId,
|
sessionId,
|
||||||
onMessageAccepted,
|
onMessageAccepted,
|
||||||
|
onCreatePersonaRequested,
|
||||||
}: UseChatSessionControllerOptions) {
|
}: UseChatSessionControllerOptions) {
|
||||||
const stateSessionId = sessionId ?? PENDING_HOME_SESSION_ID;
|
const stateSessionId = sessionId ?? PENDING_HOME_SESSION_ID;
|
||||||
const {
|
const {
|
||||||
|
|
@ -291,7 +293,12 @@ export function useChatSessionController({
|
||||||
|
|
||||||
const handlePersonaChange = useCallback(
|
const handlePersonaChange = useCallback(
|
||||||
(personaId: string | null) => {
|
(personaId: string | null) => {
|
||||||
|
if (personaId === selectedPersonaId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const persona = personas.find((candidate) => candidate.id === personaId);
|
const persona = personas.find((candidate) => candidate.id === personaId);
|
||||||
|
|
||||||
if (persona?.provider) {
|
if (persona?.provider) {
|
||||||
const matchingProvider = providers.find(
|
const matchingProvider = providers.find(
|
||||||
(provider) =>
|
(provider) =>
|
||||||
|
|
@ -328,6 +335,7 @@ export function useChatSessionController({
|
||||||
personas,
|
personas,
|
||||||
providers,
|
providers,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
selectedPersonaId,
|
||||||
setGlobalSelectedProvider,
|
setGlobalSelectedProvider,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
@ -386,7 +394,6 @@ export function useChatSessionController({
|
||||||
sessionId ? chatState : "thinking",
|
sessionId ? chatState : "thinking",
|
||||||
sendMessage,
|
sendMessage,
|
||||||
);
|
);
|
||||||
const chatStore = useChatStore();
|
|
||||||
|
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
|
(text: string, personaId?: string, attachments?: ChatAttachmentDraft[]) => {
|
||||||
|
|
@ -398,24 +405,6 @@ export function useChatSessionController({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (personaId && personaId !== selectedPersonaId) {
|
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);
|
handlePersonaChange(personaId);
|
||||||
deferredSend.current = { text, attachments };
|
deferredSend.current = { text, attachments };
|
||||||
return;
|
return;
|
||||||
|
|
@ -430,9 +419,7 @@ export function useChatSessionController({
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
chatState,
|
chatState,
|
||||||
chatStore,
|
|
||||||
handlePersonaChange,
|
handlePersonaChange,
|
||||||
personas,
|
|
||||||
queue,
|
queue,
|
||||||
sessionId,
|
sessionId,
|
||||||
selectedPersonaId,
|
selectedPersonaId,
|
||||||
|
|
@ -449,8 +436,12 @@ export function useChatSessionController({
|
||||||
}, [selectedPersona, sendMessage]);
|
}, [selectedPersona, sendMessage]);
|
||||||
|
|
||||||
const handleCreatePersona = useCallback(() => {
|
const handleCreatePersona = useCallback(() => {
|
||||||
|
if (onCreatePersonaRequested) {
|
||||||
|
onCreatePersonaRequested();
|
||||||
|
return;
|
||||||
|
}
|
||||||
useAgentStore.getState().openPersonaEditor();
|
useAgentStore.getState().openPersonaEditor();
|
||||||
}, []);
|
}, [onCreatePersonaRequested]);
|
||||||
|
|
||||||
const sessionDraftValue = useChatStore((s) =>
|
const sessionDraftValue = useChatStore((s) =>
|
||||||
sessionId ? (s.draftsBySession[sessionId] ?? "") : "",
|
sessionId ? (s.draftsBySession[sessionId] ?? "") : "",
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ interface AgentModelPickerProps {
|
||||||
onModelChange?: (modelId: string) => void;
|
onModelChange?: (modelId: string) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
isCompact?: boolean;
|
isCompact?: boolean;
|
||||||
|
showSelectedModelInTrigger?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModelDisplayName(model: ModelOption) {
|
function getModelDisplayName(model: ModelOption) {
|
||||||
|
|
@ -321,6 +322,7 @@ export function AgentModelPicker({
|
||||||
onModelChange,
|
onModelChange,
|
||||||
loading = false,
|
loading = false,
|
||||||
isCompact = false,
|
isCompact = false,
|
||||||
|
showSelectedModelInTrigger = true,
|
||||||
}: AgentModelPickerProps) {
|
}: AgentModelPickerProps) {
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -332,7 +334,9 @@ export function AgentModelPicker({
|
||||||
const selectedAgentLabel =
|
const selectedAgentLabel =
|
||||||
agents.find((agent) => agent.id === selectedAgentId)?.label ??
|
agents.find((agent) => agent.id === selectedAgentId)?.label ??
|
||||||
formatProviderLabel(selectedAgentId);
|
formatProviderLabel(selectedAgentId);
|
||||||
const hasSelectedModel = currentModelName !== null || currentModelId !== null;
|
const hasSelectedModel =
|
||||||
|
showSelectedModelInTrigger &&
|
||||||
|
(currentModelName !== null || currentModelId !== null);
|
||||||
const triggerModelLabel = hasSelectedModel
|
const triggerModelLabel = hasSelectedModel
|
||||||
? (currentModelName ?? currentModelId)
|
? (currentModelName ?? currentModelId)
|
||||||
: null;
|
: null;
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ export function ChatInputToolbar({
|
||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
loading={providersLoading}
|
loading={providersLoading}
|
||||||
isCompact={isCompact}
|
isCompact={isCompact}
|
||||||
|
showSelectedModelInTrigger={selectedPersonaId === null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,17 @@ import { useChatSessionController } from "../hooks/useChatSessionController";
|
||||||
|
|
||||||
interface ChatViewProps {
|
interface ChatViewProps {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
onCreatePersona?: () => void;
|
||||||
onCreateProject?: (options?: {
|
onCreateProject?: (options?: {
|
||||||
onCreated?: (projectId: string) => void;
|
onCreated?: (projectId: string) => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatView({ sessionId, onCreateProject }: ChatViewProps) {
|
export function ChatView({
|
||||||
|
sessionId,
|
||||||
|
onCreatePersona,
|
||||||
|
onCreateProject,
|
||||||
|
}: ChatViewProps) {
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const mountStart = useRef(performance.now());
|
const mountStart = useRef(performance.now());
|
||||||
const isContextPanelOpen = useChatSessionStore(
|
const isContextPanelOpen = useChatSessionStore(
|
||||||
|
|
@ -29,7 +34,10 @@ export function ChatView({ sessionId, onCreateProject }: ChatViewProps) {
|
||||||
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
|
const [globalArtifactRoot, setGlobalArtifactRoot] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const controller = useChatSessionController({ sessionId });
|
const controller = useChatSessionController({
|
||||||
|
sessionId,
|
||||||
|
onCreatePersonaRequested: onCreatePersona,
|
||||||
|
});
|
||||||
const contextPanelLabel = isContextPanelOpen
|
const contextPanelLabel = isContextPanelOpen
|
||||||
? t("context.closePanel")
|
? t("context.closePanel")
|
||||||
: t("context.openPanel");
|
: t("context.openPanel");
|
||||||
|
|
|
||||||
|
|
@ -89,7 +89,7 @@ describe("ChatInput", () => {
|
||||||
it("renders with default placeholder", () => {
|
it("renders with default placeholder", () => {
|
||||||
render(<ChatInput onSend={vi.fn()} />);
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByPlaceholderText("Message Goose, @ to mention personas"),
|
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ describe("HomeScreen", () => {
|
||||||
it("renders the chat input placeholder with default agent name when no persona selected", () => {
|
it("renders the chat input placeholder with default agent name when no persona selected", () => {
|
||||||
renderHome();
|
renderHome();
|
||||||
expect(
|
expect(
|
||||||
screen.getByPlaceholderText("Message Goose, @ to mention personas"),
|
screen.getByPlaceholderText("Message Goose, @ to mention agents"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ function getGreetingKey(hour: number): "morning" | "afternoon" | "evening" {
|
||||||
interface HomeScreenProps {
|
interface HomeScreenProps {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onActivateSession: (sessionId: string) => void;
|
onActivateSession: (sessionId: string) => void;
|
||||||
|
onCreatePersona?: () => void;
|
||||||
onCreateProject?: (options?: {
|
onCreateProject?: (options?: {
|
||||||
onCreated?: (projectId: string) => void;
|
onCreated?: (projectId: string) => void;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
@ -49,15 +50,18 @@ interface HomeScreenProps {
|
||||||
function HomeComposer({
|
function HomeComposer({
|
||||||
sessionId,
|
sessionId,
|
||||||
onActivateSession,
|
onActivateSession,
|
||||||
|
onCreatePersona,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
}: {
|
}: {
|
||||||
sessionId: string | null;
|
sessionId: string | null;
|
||||||
onActivateSession: (sessionId: string) => void;
|
onActivateSession: (sessionId: string) => void;
|
||||||
|
onCreatePersona?: () => void;
|
||||||
onCreateProject?: HomeScreenProps["onCreateProject"];
|
onCreateProject?: HomeScreenProps["onCreateProject"];
|
||||||
}) {
|
}) {
|
||||||
const controller = useChatSessionController({
|
const controller = useChatSessionController({
|
||||||
sessionId,
|
sessionId,
|
||||||
onMessageAccepted: onActivateSession,
|
onMessageAccepted: onActivateSession,
|
||||||
|
onCreatePersonaRequested: onCreatePersona,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,6 +111,7 @@ function HomeComposer({
|
||||||
export function HomeScreen({
|
export function HomeScreen({
|
||||||
sessionId,
|
sessionId,
|
||||||
onActivateSession,
|
onActivateSession,
|
||||||
|
onCreatePersona,
|
||||||
onCreateProject,
|
onCreateProject,
|
||||||
}: HomeScreenProps) {
|
}: HomeScreenProps) {
|
||||||
const { t } = useTranslation("home");
|
const { t } = useTranslation("home");
|
||||||
|
|
@ -126,6 +131,7 @@ export function HomeScreen({
|
||||||
<HomeComposer
|
<HomeComposer
|
||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
onActivateSession={onActivateSession}
|
onActivateSession={onActivateSession}
|
||||||
|
onCreatePersona={onCreatePersona}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ describe("SkillsView", () => {
|
||||||
render(<SkillsView />);
|
render(<SkillsView />);
|
||||||
expect(screen.getByText("Skills")).toBeInTheDocument();
|
expect(screen.getByText("Skills")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Reusable instructions for your AI personas"),
|
screen.getByText("Reusable instructions for your AI agents"),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,17 @@ export async function importPersonas(
|
||||||
return invoke("import_personas", { fileBytes, fileName });
|
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(
|
export async function savePersonaAvatar(
|
||||||
personaId: string,
|
personaId: string,
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,34 @@ import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
interface FileImportZoneOptions {
|
interface FileImportZoneOptions {
|
||||||
onImportFile: (fileBytes: number[], fileName: string) => void;
|
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.
|
* Shared drag-and-drop + file-picker infrastructure for import zones.
|
||||||
* Returns state, handlers, and a ref for the hidden `<input type="file">`.
|
* 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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
const importFile = useCallback(
|
const importFile = useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
|
const validationMessage = validateFile?.(file);
|
||||||
|
if (validationMessage) {
|
||||||
|
onImportError?.(validationMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const buffer = await file.arrayBuffer();
|
const buffer = await file.arrayBuffer();
|
||||||
const bytes = Array.from(new Uint8Array(buffer));
|
const bytes = Array.from(new Uint8Array(buffer));
|
||||||
onImportFile(bytes, file.name);
|
onImportFile(bytes, file.name);
|
||||||
},
|
},
|
||||||
[onImportFile],
|
[onImportFile, onImportError, validateFile],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropHandlers = {
|
const dropHandlers = {
|
||||||
|
|
|
||||||
|
|
@ -10,43 +10,54 @@
|
||||||
"uploadAria": "Drop an image or click to upload avatar"
|
"uploadAria": "Drop an image or click to upload avatar"
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"ariaLabel": "Persona: {{name}}",
|
"ariaLabel": "Agent: {{name}}",
|
||||||
"options": "Persona options"
|
"fileBacked": "File-backed",
|
||||||
|
"options": "Agent options"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"ariaLabel": "Agent configuration",
|
"ariaLabel": "Agent configuration",
|
||||||
"createAgent": "Create Agent",
|
"createAgent": "Create Agent",
|
||||||
"fromPersona": "(from persona: {{value}})",
|
"fromPersona": "(from agent: {{value}})",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"modelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
"modelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"namePlaceholder": "My Agent",
|
"namePlaceholder": "My Agent",
|
||||||
"persona": "Persona",
|
"persona": "Agent",
|
||||||
"provider": "Provider",
|
"provider": "Provider",
|
||||||
"systemPromptOverrideCollapsed": "System Prompt Override [+]",
|
"systemPromptOverrideCollapsed": "System Prompt Override [+]",
|
||||||
"systemPromptOverrideExpanded": "System Prompt Override [-]",
|
"systemPromptOverrideExpanded": "System Prompt Override [-]",
|
||||||
"systemPromptPlaceholder": "Override the persona system prompt...",
|
"systemPromptPlaceholder": "Override the agent system prompt...",
|
||||||
"updateAgent": "Update Agent"
|
"updateAgent": "Update Agent"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
|
"created": "Agent created.",
|
||||||
"displayName": "Display Name",
|
"displayName": "Display Name",
|
||||||
"displayNamePlaceholder": "e.g. Code Reviewer",
|
"displayNamePlaceholder": "e.g. Code Reviewer",
|
||||||
"duplicate": "Duplicate",
|
"duplicate": "Duplicate",
|
||||||
"editTitle": "Edit Persona",
|
"duplicated": "Agent duplicated.",
|
||||||
|
"chooseProviderFirst": "Select a provider to choose a model.",
|
||||||
|
"editTitle": "Edit Agent",
|
||||||
"model": "Model",
|
"model": "Model",
|
||||||
"modelPlaceholder": "e.g. claude-sonnet-4-20250514",
|
"modelPlaceholder": "Select a model",
|
||||||
"newTitle": "New Persona",
|
"newTitle": "New Agent",
|
||||||
|
"noModelsAvailable": "No models are available for this provider yet.",
|
||||||
"provider": "Provider",
|
"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...",
|
"saving": "Saving...",
|
||||||
"systemPrompt": "System Prompt",
|
"systemPrompt": "System Prompt",
|
||||||
"systemPromptPlaceholder": "You are a helpful assistant that..."
|
"systemPromptPlaceholder": "You are a helpful assistant that...",
|
||||||
|
"updated": "Agent updated."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
"createAria": "Create new persona",
|
"createAria": "Create new agent",
|
||||||
"dropFile": "or drop a file",
|
"dropFile": "or drop a file",
|
||||||
"loading": "Loading personas",
|
"loading": "Loading agents",
|
||||||
"new": "New Persona"
|
"new": "New Agent"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
|
@ -55,18 +66,24 @@
|
||||||
"starting": "Starting"
|
"starting": "Starting"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"activeAgents": "Active Agents",
|
|
||||||
"activeAgentsAria": "Active agents",
|
|
||||||
"copyName": "{{name}} (Copy)",
|
"copyName": "{{name}} (Copy)",
|
||||||
|
"deleteFailed": "Failed to delete agent.",
|
||||||
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
|
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
|
||||||
"deleteTitle": "Delete persona?",
|
"deleteTitle": "Delete agent?",
|
||||||
"description": "Custom persona configurations for specific workflows",
|
"deleted": "\"{{name}}\" deleted.",
|
||||||
"emptyAgentsDescription": "Create an agent from a persona to get started.",
|
"description": "Custom agent configurations for specific workflows",
|
||||||
"emptyAgentsTitle": "No active agents",
|
"emptyAgentsDescription": "Create an agent to get started.",
|
||||||
|
"emptyAgentsTitle": "No agents yet",
|
||||||
|
"exportFailed": "Failed to export agent.",
|
||||||
"exportedTo": "Exported to {{filename}}",
|
"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}}",
|
"optionsAria": "Options for {{name}}",
|
||||||
"searchPlaceholder": "Search personas...",
|
"searchPlaceholder": "Search agents...",
|
||||||
"title": "Personas"
|
"title": "Agents"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"ariaLabel": "Chat message input",
|
"ariaLabel": "Chat message input",
|
||||||
"placeholder": "Message {{agent}}, @ to mention personas"
|
"placeholder": "Message {{agent}}, @ to mention agents"
|
||||||
},
|
},
|
||||||
"loading": {
|
"loading": {
|
||||||
"compacting": "Compacting conversation...",
|
"compacting": "Compacting conversation...",
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
},
|
},
|
||||||
"mention": {
|
"mention": {
|
||||||
"ariaLabel": "Mention suggestions",
|
"ariaLabel": "Mention suggestions",
|
||||||
"title": "Mention a persona",
|
"title": "Mention an agent",
|
||||||
"filesTitle": "Files"
|
"filesTitle": "Files"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
|
@ -127,9 +127,9 @@
|
||||||
},
|
},
|
||||||
"persona": {
|
"persona": {
|
||||||
"chooseAssistant": "Choose assistant",
|
"chooseAssistant": "Choose assistant",
|
||||||
"create": "Create persona...",
|
"create": "Create agent...",
|
||||||
"clearActive": "Clear active assistant",
|
"clearActive": "Clear active assistant",
|
||||||
"defaultDescription": "No persona - chat directly with the agent"
|
"defaultDescription": "No agent selected - chat directly with Goose"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"dismiss": "Dismiss queued message",
|
"dismiss": "Dismiss queued message",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"emptyNoMatchesHint": "Try a different search term.",
|
"emptyNoMatchesHint": "Try a different search term.",
|
||||||
"emptyTitle": "No sessions yet",
|
"emptyTitle": "No sessions yet",
|
||||||
"searchArchivedPlaceholder": "Search archived sessions...",
|
"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",
|
"searchPlaceholder": "Search conversations",
|
||||||
"searching": "Searching sessions...",
|
"searching": "Searching sessions...",
|
||||||
"subtitle": "Browse and search past sessions",
|
"subtitle": "Browse and search past sessions",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"optionsFor": "Options for {{label}}"
|
"optionsFor": "Options for {{label}}"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Personas",
|
"agents": "Agents",
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"sessionHistory": "Session History",
|
"sessionHistory": "Session History",
|
||||||
"skills": "Skills"
|
"skills": "Skills"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"view": {
|
"view": {
|
||||||
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
|
"deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
|
||||||
"deleteTitle": "Delete skill?",
|
"deleteTitle": "Delete skill?",
|
||||||
"description": "Reusable instructions for your AI personas",
|
"description": "Reusable instructions for your AI agents",
|
||||||
"dropFile": "or drop a file",
|
"dropFile": "or drop a file",
|
||||||
"emptyDescription": "Create a skill or drop a .skill.json file here.",
|
"emptyDescription": "Create a skill or drop a .skill.json file here.",
|
||||||
"emptyTitle": "No skills yet",
|
"emptyTitle": "No skills yet",
|
||||||
|
|
|
||||||
|
|
@ -10,43 +10,54 @@
|
||||||
"uploadAria": "Suelta una imagen o haz clic para subir un avatar"
|
"uploadAria": "Suelta una imagen o haz clic para subir un avatar"
|
||||||
},
|
},
|
||||||
"card": {
|
"card": {
|
||||||
"ariaLabel": "Persona: {{name}}",
|
"ariaLabel": "Agente: {{name}}",
|
||||||
"options": "Opciones de la persona"
|
"fileBacked": "Desde archivo",
|
||||||
|
"options": "Opciones del agente"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"ariaLabel": "Configuración del agente",
|
"ariaLabel": "Configuración del agente",
|
||||||
"createAgent": "Crear agente",
|
"createAgent": "Crear agente",
|
||||||
"fromPersona": "(desde la persona: {{value}})",
|
"fromPersona": "(desde el agente: {{value}})",
|
||||||
"model": "Modelo",
|
"model": "Modelo",
|
||||||
"modelPlaceholder": "p. ej. claude-sonnet-4-20250514",
|
"modelPlaceholder": "p. ej. claude-sonnet-4-20250514",
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
"namePlaceholder": "Mi agente",
|
"namePlaceholder": "Mi agente",
|
||||||
"persona": "Persona",
|
"persona": "Agente",
|
||||||
"provider": "Proveedor",
|
"provider": "Proveedor",
|
||||||
"systemPromptOverrideCollapsed": "Sobrescribir prompt del sistema [+]",
|
"systemPromptOverrideCollapsed": "Sobrescribir prompt del sistema [+]",
|
||||||
"systemPromptOverrideExpanded": "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"
|
"updateAgent": "Actualizar agente"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"create": "Crear",
|
"create": "Crear",
|
||||||
|
"created": "Agente creado.",
|
||||||
"displayName": "Nombre para mostrar",
|
"displayName": "Nombre para mostrar",
|
||||||
"displayNamePlaceholder": "p. ej. Revisor de código",
|
"displayNamePlaceholder": "p. ej. Revisor de código",
|
||||||
"duplicate": "Duplicar",
|
"duplicate": "Duplicar",
|
||||||
"editTitle": "Editar persona",
|
"duplicated": "Agente duplicado.",
|
||||||
|
"chooseProviderFirst": "Selecciona un proveedor para elegir un modelo.",
|
||||||
|
"editTitle": "Editar agente",
|
||||||
"model": "Modelo",
|
"model": "Modelo",
|
||||||
"modelPlaceholder": "p. ej. claude-sonnet-4-20250514",
|
"modelPlaceholder": "Selecciona un modelo",
|
||||||
"newTitle": "Nueva persona",
|
"newTitle": "Nuevo agente",
|
||||||
|
"noModelsAvailable": "Todavía no hay modelos disponibles para este proveedor.",
|
||||||
"provider": "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...",
|
"saving": "Guardando...",
|
||||||
"systemPrompt": "Prompt del sistema",
|
"systemPrompt": "Prompt del sistema",
|
||||||
"systemPromptPlaceholder": "Eres un asistente útil que..."
|
"systemPromptPlaceholder": "Eres un asistente útil que...",
|
||||||
|
"updated": "Agente actualizado."
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
"createAria": "Crear nueva persona",
|
"createAria": "Crear nuevo agente",
|
||||||
"dropFile": "o suelta un archivo",
|
"dropFile": "o suelta un archivo",
|
||||||
"loading": "Cargando personas",
|
"loading": "Cargando agentes",
|
||||||
"new": "Nueva persona"
|
"new": "Nuevo agente"
|
||||||
},
|
},
|
||||||
"statuses": {
|
"statuses": {
|
||||||
"error": "Error",
|
"error": "Error",
|
||||||
|
|
@ -55,18 +66,24 @@
|
||||||
"starting": "Iniciando"
|
"starting": "Iniciando"
|
||||||
},
|
},
|
||||||
"view": {
|
"view": {
|
||||||
"activeAgents": "Agentes activos",
|
|
||||||
"activeAgentsAria": "Agentes activos",
|
|
||||||
"copyName": "{{name}} (Copia)",
|
"copyName": "{{name}} (Copia)",
|
||||||
|
"deleteFailed": "No se pudo eliminar el agente.",
|
||||||
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
|
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
|
||||||
"deleteTitle": "¿Eliminar persona?",
|
"deleteTitle": "¿Eliminar agente?",
|
||||||
"description": "Configuraciones de persona personalizadas para flujos de trabajo específicos",
|
"deleted": "Se eliminó \"{{name}}\".",
|
||||||
"emptyAgentsDescription": "Crea un agente desde una persona para empezar.",
|
"description": "Configuraciones de agente personalizadas para flujos de trabajo específicos",
|
||||||
"emptyAgentsTitle": "No hay agentes activos",
|
"emptyAgentsDescription": "Crea un agente para empezar.",
|
||||||
|
"emptyAgentsTitle": "Aún no hay agentes",
|
||||||
|
"exportFailed": "No se pudo exportar el agente.",
|
||||||
"exportedTo": "Exportado a {{filename}}",
|
"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}}",
|
"optionsAria": "Opciones de {{name}}",
|
||||||
"searchPlaceholder": "Buscar personas...",
|
"searchPlaceholder": "Buscar agentes...",
|
||||||
"title": "Personas"
|
"title": "Agentes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,7 +108,7 @@
|
||||||
},
|
},
|
||||||
"input": {
|
"input": {
|
||||||
"ariaLabel": "Entrada de mensaje del chat",
|
"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": {
|
"loading": {
|
||||||
"compacting": "Compactando conversación...",
|
"compacting": "Compactando conversación...",
|
||||||
|
|
@ -117,7 +117,7 @@
|
||||||
},
|
},
|
||||||
"mention": {
|
"mention": {
|
||||||
"ariaLabel": "Sugerencias de menciones",
|
"ariaLabel": "Sugerencias de menciones",
|
||||||
"title": "Menciona una persona",
|
"title": "Menciona un agente",
|
||||||
"filesTitle": "Archivos"
|
"filesTitle": "Archivos"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
|
|
@ -127,9 +127,9 @@
|
||||||
},
|
},
|
||||||
"persona": {
|
"persona": {
|
||||||
"chooseAssistant": "Elegir asistente",
|
"chooseAssistant": "Elegir asistente",
|
||||||
"create": "Crear persona...",
|
"create": "Crear agente...",
|
||||||
"clearActive": "Quitar asistente activo",
|
"clearActive": "Quitar asistente activo",
|
||||||
"defaultDescription": "Sin persona - chatea directamente con el agente"
|
"defaultDescription": "Sin agente seleccionado: chatea directamente con Goose"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"dismiss": "Descartar mensaje en cola",
|
"dismiss": "Descartar mensaje en cola",
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"emptyNoMatchesHint": "Prueba con otro término de búsqueda.",
|
"emptyNoMatchesHint": "Prueba con otro término de búsqueda.",
|
||||||
"emptyTitle": "Aún no hay sesiones",
|
"emptyTitle": "Aún no hay sesiones",
|
||||||
"searchArchivedPlaceholder": "Buscar sesiones archivadas...",
|
"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",
|
"searchPlaceholder": "Buscar conversaciones",
|
||||||
"searching": "Buscando sesiones...",
|
"searching": "Buscando sesiones...",
|
||||||
"subtitle": "Explora y busca sesiones anteriores",
|
"subtitle": "Explora y busca sesiones anteriores",
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
"optionsFor": "Opciones para {{label}}"
|
"optionsFor": "Opciones para {{label}}"
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"agents": "Personas",
|
"agents": "Agentes",
|
||||||
"home": "Inicio",
|
"home": "Inicio",
|
||||||
"sessionHistory": "Historial de sesiones",
|
"sessionHistory": "Historial de sesiones",
|
||||||
"skills": "Habilidades"
|
"skills": "Habilidades"
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"view": {
|
"view": {
|
||||||
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
|
"deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.",
|
||||||
"deleteTitle": "¿Eliminar skill?",
|
"deleteTitle": "¿Eliminar skill?",
|
||||||
"description": "Instrucciones reutilizables para tus personas de IA",
|
"description": "Instrucciones reutilizables para tus agentes de IA",
|
||||||
"dropFile": "o suelta un archivo",
|
"dropFile": "o suelta un archivo",
|
||||||
"emptyDescription": "Crea una skill o suelta aquí un archivo .skill.json.",
|
"emptyDescription": "Crea una skill o suelta aquí un archivo .skill.json.",
|
||||||
"emptyTitle": "Aún no hay skills",
|
"emptyTitle": "Aún no hay skills",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ const buttonVariants = cva(
|
||||||
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"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:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
"outline-flat":
|
"outline-flat":
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ test.describe("Draft persistence", () => {
|
||||||
// Wait for the 300ms debounce to persist the draft
|
// Wait for the 300ms debounce to persist the draft
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
// Navigate away to Personas
|
// Navigate away to Agents
|
||||||
await page.getByRole("button", { name: "Personas" }).click();
|
await page.getByRole("button", { name: "Agents" }).click();
|
||||||
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
|
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
|
||||||
|
|
||||||
// Navigate back to Home
|
// Navigate back to Home
|
||||||
await page.getByRole("button", { name: "Home" }).click();
|
await page.getByRole("button", { name: "Home" }).click();
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,58 @@ export function buildInitScript(options?: {
|
||||||
const PROJECTS = ${projects};
|
const PROJECTS = ${projects};
|
||||||
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
|
const FAKE_ACP_URL = "ws://127.0.0.1:0/mock-acp";
|
||||||
const ACP_SESSIONS = [];
|
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) => ({
|
const skillToSourceEntry = (s) => ({
|
||||||
type: "skill",
|
type: "skill",
|
||||||
|
|
@ -126,7 +178,7 @@ export function buildInitScript(options?: {
|
||||||
return jsonRpcResult(message.id, { stopReason: "end_turn" });
|
return jsonRpcResult(message.id, { stopReason: "end_turn" });
|
||||||
}
|
}
|
||||||
case "_goose/providers/list":
|
case "_goose/providers/list":
|
||||||
return jsonRpcResult(message.id, { entries: [] });
|
return jsonRpcResult(message.id, { entries: PROVIDER_INVENTORY });
|
||||||
case "_goose/providers/inventory/refresh":
|
case "_goose/providers/inventory/refresh":
|
||||||
return jsonRpcResult(message.id, { started: [], skipped: [] });
|
return jsonRpcResult(message.id, { started: [], skipped: [] });
|
||||||
case "_goose/working_dir/update":
|
case "_goose/working_dir/update":
|
||||||
|
|
@ -222,7 +274,7 @@ export function buildInitScript(options?: {
|
||||||
case "create_persona":
|
case "create_persona":
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: "mock-" + Math.random().toString(36).slice(2, 10),
|
id: "mock-" + Math.random().toString(36).slice(2, 10),
|
||||||
displayName: args?.displayName ?? "New Persona",
|
displayName: args?.displayName ?? "New Agent",
|
||||||
systemPrompt: args?.systemPrompt ?? "",
|
systemPrompt: args?.systemPrompt ?? "",
|
||||||
isBuiltin: false,
|
isBuiltin: false,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
|
|
@ -233,7 +285,7 @@ export function buildInitScript(options?: {
|
||||||
case "update_persona":
|
case "update_persona":
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
id: args?.id ?? "mock-updated",
|
id: args?.id ?? "mock-updated",
|
||||||
displayName: args?.displayName ?? "Updated Persona",
|
displayName: args?.displayName ?? "Updated Agent",
|
||||||
systemPrompt: args?.systemPrompt ?? "",
|
systemPrompt: args?.systemPrompt ?? "",
|
||||||
isBuiltin: false,
|
isBuiltin: false,
|
||||||
createdAt: new Date().toISOString(),
|
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 page.goto("/");
|
||||||
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
|
await expect(page.getByText(/Good (morning|afternoon|evening)/)).toBeVisible({
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
await page.getByRole("button", { name: "Personas" }).click();
|
await page.getByRole("button", { name: "Agents" }).click();
|
||||||
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
|
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function navigateToSkills(page: Page) {
|
export async function navigateToSkills(page: Page) {
|
||||||
|
|
|
||||||
|
|
@ -1,150 +1,155 @@
|
||||||
import {
|
import {
|
||||||
test,
|
test,
|
||||||
expect,
|
expect,
|
||||||
navigateToPersonas,
|
navigateToAgents,
|
||||||
buildInitScript,
|
buildInitScript,
|
||||||
} from "./fixtures/tauri-mock";
|
} from "./fixtures/tauri-mock";
|
||||||
|
|
||||||
test.describe("Personas view", () => {
|
test.describe("Agents view", () => {
|
||||||
test("navigates to personas view from sidebar", async ({
|
test("navigates to agents view from sidebar", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
// Assert heading, subtitle, and sections are visible
|
|
||||||
await expect(page.locator("h1", { hasText: "Personas" })).toBeVisible();
|
await expect(page.locator("h1", { hasText: "Agents" })).toBeVisible();
|
||||||
await expect(page.getByText("Custom persona configurations")).toBeVisible();
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "Active Agents" }),
|
page.getByText("Custom agent configurations for specific workflows"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await expect(page.getByText("No active agents")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("displays persona cards from mock data", async ({
|
test("displays agent cards from mock data", async ({ tauriMocked: page }) => {
|
||||||
tauriMocked: page,
|
await navigateToAgents(page);
|
||||||
}) => {
|
|
||||||
await navigateToPersonas(page);
|
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
|
||||||
// All 3 persona cards should be visible with their aria-labels
|
await expect(page.getByLabel("Agent: Scout")).toBeVisible();
|
||||||
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
|
await expect(page.getByLabel("Agent: Code Reviewer")).toBeVisible();
|
||||||
await expect(page.getByLabel("Persona: Scout")).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Persona: Code Reviewer")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows Built-in badge on builtin personas", async ({
|
test("shows Built-in badge on built-in agents", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
// Solo and Scout are builtin — their cards should contain "Built-in" text
|
|
||||||
const soloCard = page.getByLabel("Persona: Solo");
|
const soloCard = page.getByLabel("Agent: Solo");
|
||||||
await expect(soloCard.getByText("Built-in")).toBeVisible();
|
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();
|
await expect(reviewerCard.getByText("Built-in")).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows create new persona button", async ({ tauriMocked: page }) => {
|
test("shows create new agent button", async ({ tauriMocked: page }) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await expect(page.getByLabel("Create new persona")).toBeVisible();
|
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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "New Persona", exact: true })
|
.getByRole("button", { name: "New Agent", exact: true })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await expect(
|
await expect(dialog.locator("h2", { hasText: "New Agent" })).toBeVisible();
|
||||||
dialog.locator("h2", { hasText: "New Persona" }),
|
|
||||||
).toBeVisible();
|
|
||||||
// Check form fields
|
|
||||||
await expect(dialog.getByPlaceholder("e.g. Code Reviewer")).toBeVisible();
|
await expect(dialog.getByPlaceholder("e.g. Code Reviewer")).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByPlaceholder("You are a helpful assistant that..."),
|
dialog.getByPlaceholder("You are a helpful assistant that..."),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opens create persona dialog via plus card", async ({
|
test("opens create agent dialog via plus card", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page.getByLabel("Create new persona").click();
|
await page.getByLabel("Create new agent").click();
|
||||||
await expect(page.getByRole("dialog")).toBeVisible();
|
await expect(page.getByRole("dialog")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create dialog has disabled Create button when fields are empty", async ({
|
test("create dialog has disabled Create button when fields are empty", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "New Persona", exact: true })
|
.getByRole("button", { name: "New Agent", exact: true })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
// Create button should be disabled
|
|
||||||
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
|
await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("create dialog enables Create button when name and prompt are filled", async ({
|
test("create dialog enables Create button when name and prompt are filled", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "New Persona", exact: true })
|
.getByRole("button", { name: "New Agent", exact: true })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
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
|
await dialog
|
||||||
.getByPlaceholder("You are a helpful assistant that...")
|
.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();
|
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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page
|
await page
|
||||||
.getByRole("button", { name: "New Persona", exact: true })
|
.getByRole("button", { name: "New Agent", exact: true })
|
||||||
.first()
|
.first()
|
||||||
.click();
|
.click();
|
||||||
|
|
||||||
await expect(page.getByRole("dialog")).toBeVisible();
|
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();
|
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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page.getByLabel("Persona: Code Reviewer").click();
|
await page.getByLabel("Agent: Code Reviewer").click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
dialog.locator("h2", { hasText: "Edit Persona" }),
|
dialog.locator("[data-slot='dialog-title']").filter({
|
||||||
|
hasText: "Code Reviewer",
|
||||||
|
}),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// Fields should be pre-filled
|
await expect(dialog.getByText(/^Provider$/)).toBeVisible();
|
||||||
await expect(dialog.getByPlaceholder("e.g. Code Reviewer")).toHaveValue(
|
await expect(dialog.getByText("claude-sonnet-4-20250514")).toBeVisible();
|
||||||
"Code Reviewer",
|
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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page.getByLabel("Persona: Solo").click();
|
await page.getByLabel("Agent: Solo").click();
|
||||||
|
|
||||||
const dialog = page.getByRole("dialog");
|
const dialog = page.getByRole("dialog");
|
||||||
await expect(dialog).toBeVisible();
|
await expect(dialog).toBeVisible();
|
||||||
// Header shows persona name for read-only
|
await expect(
|
||||||
await expect(dialog.locator("h2", { hasText: "Solo" })).toBeVisible();
|
dialog.locator("[data-slot='dialog-title']").filter({
|
||||||
// Duplicate button instead of Create/Save
|
hasText: "Solo",
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("button", { name: /Duplicate/ }),
|
dialog.getByRole("button", { name: /Duplicate/ }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
// Should NOT have Create or Save buttons
|
await expect(
|
||||||
|
dialog.getByRole("button", { name: "Edit" }),
|
||||||
|
).not.toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
dialog.getByRole("button", { name: "Create" }),
|
dialog.getByRole("button", { name: "Create" }),
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
|
|
@ -153,13 +158,14 @@ test.describe("Personas view", () => {
|
||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("persona card dropdown menu shows correct items", async ({
|
test("custom agent card dropdown menu shows correct items", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
// Open dropdown for Code Reviewer (custom persona)
|
|
||||||
const card = page.getByLabel("Persona: Code Reviewer");
|
const card = page.getByLabel("Agent: Code Reviewer");
|
||||||
await card.getByLabel("Persona options").click();
|
await card.getByLabel("Agent options").click();
|
||||||
|
|
||||||
const menu = page.getByRole("menu");
|
const menu = page.getByRole("menu");
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
await expect(menu.getByRole("menuitem", { name: "Edit" })).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();
|
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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
const card = page.getByLabel("Persona: Solo");
|
|
||||||
await card.getByLabel("Persona options").click();
|
const card = page.getByLabel("Agent: Solo");
|
||||||
|
await card.getByLabel("Agent options").click();
|
||||||
|
|
||||||
const menu = page.getByRole("menu");
|
const menu = page.getByRole("menu");
|
||||||
await expect(menu).toBeVisible();
|
await expect(menu).toBeVisible();
|
||||||
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
await expect(
|
||||||
|
menu.getByRole("menuitem", { name: "Edit" }),
|
||||||
|
).not.toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
menu.getByRole("menuitem", { name: "Duplicate" }),
|
menu.getByRole("menuitem", { name: "Duplicate" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
@ -189,12 +199,13 @@ test.describe("Personas view", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
|
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
const card = page.getByLabel("Persona: Code Reviewer");
|
|
||||||
await card.getByLabel("Persona options").click();
|
const card = page.getByLabel("Agent: Code Reviewer");
|
||||||
|
await card.getByLabel("Agent options").click();
|
||||||
await page.getByRole("menuitem", { name: "Delete" }).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(
|
await expect(
|
||||||
page.getByText(/Are you sure you want to delete.*Code Reviewer/),
|
page.getByText(/Are you sure you want to delete.*Code Reviewer/),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|
@ -205,46 +216,45 @@ test.describe("Personas view", () => {
|
||||||
test("Cancel in delete confirmation closes dialog", async ({
|
test("Cancel in delete confirmation closes dialog", async ({
|
||||||
tauriMocked: page,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
const card = page.getByLabel("Persona: Code Reviewer");
|
|
||||||
await card.getByLabel("Persona options").click();
|
const card = page.getByLabel("Agent: Code Reviewer");
|
||||||
|
await card.getByLabel("Agent options").click();
|
||||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||||
await expect(page.getByText("Delete persona?")).toBeVisible();
|
await expect(page.getByText("Delete agent?")).toBeVisible();
|
||||||
// Click Cancel within the delete confirmation dialog container
|
|
||||||
await page
|
const confirmDialog = page.locator(".max-w-sm", {
|
||||||
.locator("text=Delete persona?")
|
has: page.getByText("Delete agent?"),
|
||||||
.locator("..")
|
});
|
||||||
.locator("..")
|
await confirmDialog.getByRole("button", { name: "Cancel" }).click();
|
||||||
.getByRole("button", { name: "Cancel" })
|
|
||||||
.click();
|
await expect(page.getByText("Delete agent?")).not.toBeVisible();
|
||||||
await expect(page.getByText("Delete persona?")).not.toBeVisible();
|
await expect(page.getByLabel("Agent: Code Reviewer")).toBeVisible();
|
||||||
// Persona card should still be there
|
|
||||||
await expect(page.getByLabel("Persona: Code Reviewer")).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("search filters personas", async ({ tauriMocked: page }) => {
|
test("search filters agents", async ({ tauriMocked: page }) => {
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await page.getByPlaceholder("Search personas...").fill("Solo");
|
await page.getByPlaceholder("Search agents...").fill("Solo");
|
||||||
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
|
|
||||||
await expect(page.getByLabel("Persona: Scout")).not.toBeVisible();
|
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
|
||||||
await expect(page.getByLabel("Persona: Code Reviewer")).not.toBeVisible();
|
await expect(page.getByLabel("Agent: Scout")).not.toBeVisible();
|
||||||
// Clear search
|
await expect(page.getByLabel("Agent: Code Reviewer")).not.toBeVisible();
|
||||||
await page.getByPlaceholder("Search personas...").clear();
|
|
||||||
await expect(page.getByLabel("Persona: Solo")).toBeVisible();
|
await page.getByPlaceholder("Search agents...").clear();
|
||||||
await expect(page.getByLabel("Persona: Scout")).toBeVisible();
|
await expect(page.getByLabel("Agent: Solo")).toBeVisible();
|
||||||
await expect(page.getByLabel("Persona: Code Reviewer")).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,
|
tauriMocked: page,
|
||||||
}) => {
|
}) => {
|
||||||
// Override mock data with empty personas before navigation
|
|
||||||
await page.addInitScript({
|
await page.addInitScript({
|
||||||
content: buildInitScript({ personas: [], skills: [] }),
|
content: buildInitScript({ personas: [], skills: [] }),
|
||||||
});
|
});
|
||||||
await navigateToPersonas(page);
|
await navigateToAgents(page);
|
||||||
await expect(page.getByLabel("Create new persona")).toBeVisible();
|
|
||||||
// No persona cards should be visible
|
await expect(page.getByLabel("Create new agent")).toBeVisible();
|
||||||
await expect(page.getByLabel(/^Persona: /)).not.toBeVisible();
|
await expect(page.getByLabel(/^Agent: /)).not.toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ test.describe("Skills view", () => {
|
||||||
await navigateToSkills(page);
|
await navigateToSkills(page);
|
||||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByText("Reusable instructions for your AI personas"),
|
page.getByText("Reusable instructions for your AI agents"),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ test.describe("Smoke tests", () => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByPlaceholder(/Message .*, @ to mention personas/),
|
page.getByPlaceholder(/Message .*, @ to mention agents/),
|
||||||
).toBeVisible({ timeout: 10_000 });
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue