mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Align agent and skill actions
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -742,7 +742,7 @@ pub struct DeleteSourceRequest {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Export a source at an absolute path as a portable JSON payload.
|
||||
/// Export a source at an absolute path as its canonical Markdown payload.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
#[request(method = "_goose/sources/export", response = ExportSourceResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -755,11 +755,12 @@ pub struct ExportSourceRequest {
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportSourceResponse {
|
||||
/// Canonical source contents. The field remains `json` for wire compatibility.
|
||||
pub json: String,
|
||||
pub filename: String,
|
||||
}
|
||||
|
||||
/// Import a source from a JSON export payload produced by `_goose/sources/export`.
|
||||
/// Import a source from a canonical Markdown payload produced by `_goose/sources/export`.
|
||||
/// The imported source is written into the explicit target scope; on name
|
||||
/// collisions a `-imported` suffix is appended.
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
|
||||
|
||||
+19
-80
@@ -1,9 +1,8 @@
|
||||
//! Filesystem-backed CRUD for [`SourceEntry`] values exchanged over ACP custom
|
||||
|
||||
use crate::skills::{
|
||||
build_skill_md, discover_skills, infer_skill_name, is_global_skill_dir,
|
||||
parse_skill_frontmatter, resolve_discoverable_skill_dir, resolve_skill_dir, skill_base_dir,
|
||||
validate_skill_name,
|
||||
build_skill_md, discover_skills, is_global_skill_dir, resolve_discoverable_skill_dir,
|
||||
resolve_skill_dir, skill_base_dir, validate_skill_name, SkillFrontmatter,
|
||||
};
|
||||
use fs_err as fs;
|
||||
use goose_sdk::custom_requests::{SourceEntry, SourceType};
|
||||
@@ -180,21 +179,7 @@ pub fn export_source(source_type: SourceType, path: &str) -> Result<(String, Str
|
||||
let md = dir.join("SKILL.md");
|
||||
let raw = fs::read_to_string(&md)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to read SKILL.md: {e}")))?;
|
||||
let (description, content) = parse_skill_frontmatter(&raw);
|
||||
|
||||
let name = infer_skill_name(&dir);
|
||||
|
||||
let export = serde_json::json!({
|
||||
"version": 1,
|
||||
"type": "skill",
|
||||
"name": name,
|
||||
"description": description,
|
||||
"content": content,
|
||||
});
|
||||
let json = serde_json::to_string_pretty(&export)
|
||||
.map_err(|e| Error::internal_error().data(format!("Failed to serialize source: {e}")))?;
|
||||
let filename = format!("{}.skill.json", name);
|
||||
Ok((json, filename))
|
||||
Ok((raw, "SKILL.md".to_string()))
|
||||
}
|
||||
|
||||
pub fn import_sources(
|
||||
@@ -202,58 +187,19 @@ pub fn import_sources(
|
||||
global: bool,
|
||||
project_dir: Option<&str>,
|
||||
) -> Result<Vec<SourceEntry>, Error> {
|
||||
let value: serde_json::Value = serde_json::from_str(data)
|
||||
.map_err(|e| Error::invalid_params().data(format!("Invalid JSON: {e}")))?;
|
||||
let (metadata, content): (SkillFrontmatter, String) = parse_frontmatter(data)
|
||||
.map_err(|e| Error::invalid_params().data(format!("Invalid skill frontmatter: {e}")))?
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing skill frontmatter"))?;
|
||||
|
||||
let version = value
|
||||
.get("version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"version\" field"))?;
|
||||
if version != 1 {
|
||||
return Err(
|
||||
Error::invalid_params().data(format!("Unsupported source export version: {}", version))
|
||||
);
|
||||
}
|
||||
|
||||
match value
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("skill")
|
||||
{
|
||||
"skill" => {}
|
||||
other => {
|
||||
return Err(Error::invalid_params().data(format!(
|
||||
"Source type '{}' is not supported. Only 'skill' is currently supported.",
|
||||
other
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let name = value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))?
|
||||
.to_string();
|
||||
if name.is_empty() {
|
||||
return Err(Error::invalid_params().data("Source name must not be empty"));
|
||||
}
|
||||
|
||||
let description = value
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"description\" field"))?
|
||||
.to_string();
|
||||
if description.is_empty() {
|
||||
let name = metadata
|
||||
.name
|
||||
.filter(|name| !name.trim().is_empty())
|
||||
.ok_or_else(|| Error::invalid_params().data("Missing or invalid \"name\" field"))?;
|
||||
let description = metadata.description;
|
||||
if description.trim().is_empty() {
|
||||
return Err(Error::invalid_params().data("Source description must not be empty"));
|
||||
}
|
||||
|
||||
let content = value
|
||||
.get("content")
|
||||
.or_else(|| value.get("instructions"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
validate_skill_name(&name)?;
|
||||
|
||||
let base = skill_base_dir(global, project_dir)?;
|
||||
@@ -378,11 +324,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let portable_dir = project_a.join(".agents").join("skills").join("portable");
|
||||
let (json, filename) =
|
||||
let (markdown, filename) =
|
||||
export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap();
|
||||
assert_eq!(filename, "portable.skill.json");
|
||||
assert_eq!(filename, "SKILL.md");
|
||||
|
||||
let imported = import_sources(&json, false, Some(project_b.to_str().unwrap())).unwrap();
|
||||
let imported = import_sources(&markdown, false, Some(project_b.to_str().unwrap())).unwrap();
|
||||
assert_eq!(imported.len(), 1);
|
||||
assert_eq!(imported[0].name, "portable");
|
||||
assert_eq!(imported[0].description, "describes itself");
|
||||
@@ -408,10 +354,10 @@ mod tests {
|
||||
.find(|skill| skill.name == "portable")
|
||||
.expect("expected listed skill");
|
||||
|
||||
let (json, filename) =
|
||||
let (markdown, filename) =
|
||||
export_source(SourceType::Skill, exported_skill.directory.as_str()).unwrap();
|
||||
assert_eq!(filename, "portable.skill.json");
|
||||
assert!(json.contains("\"name\": \"portable\""));
|
||||
assert_eq!(filename, "SKILL.md");
|
||||
assert!(markdown.contains("name: portable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -451,14 +397,7 @@ mod tests {
|
||||
|
||||
create_source(SourceType::Skill, "busy", "d", "c", false, Some(project)).unwrap();
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"version": 1,
|
||||
"type": "skill",
|
||||
"name": "busy",
|
||||
"description": "d",
|
||||
"content": "c",
|
||||
})
|
||||
.to_string();
|
||||
let payload = build_skill_md("busy", "d", "c");
|
||||
let imported = import_sources(&payload, false, Some(project)).unwrap();
|
||||
assert_eq!(imported[0].name, "busy-imported");
|
||||
}
|
||||
|
||||
@@ -77,9 +77,9 @@ fn validate_import_persona_path(source_path: &str) -> Result<PathBuf, 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());
|
||||
.ok_or_else(|| "Unsupported file type. Expected a .md file.".to_string())?;
|
||||
if !extension.eq_ignore_ascii_case("md") {
|
||||
return Err("Unsupported file type. Expected a .md file.".to_string());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&path)
|
||||
@@ -111,153 +111,73 @@ pub fn read_import_persona_file(source_path: String) -> Result<ImportFileReadRes
|
||||
})
|
||||
}
|
||||
|
||||
// --- Sprout-compatible persona import/export ---
|
||||
// --- Markdown agent import ---
|
||||
|
||||
/// Sprout-compatible persona export format (version 1, camelCase keys).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersonaExportV1 {
|
||||
version: u32,
|
||||
display_name: String,
|
||||
system_prompt: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MarkdownAgentFrontmatter {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
avatar: Option<Avatar>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
/// Result returned by export_persona containing the JSON string and a suggested filename.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExportResult {
|
||||
pub json: String,
|
||||
pub suggested_filename: String,
|
||||
}
|
||||
|
||||
/// Convert a display name into a filesystem-safe slug.
|
||||
/// Lowercase, replace non-alphanumeric with hyphens, collapse runs, trim, max 50 chars.
|
||||
pub fn slugify(name: &str) -> String {
|
||||
let slug: String = name
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
// Collapse consecutive hyphens
|
||||
let mut collapsed = String::with_capacity(slug.len());
|
||||
let mut prev_hyphen = false;
|
||||
for c in slug.chars() {
|
||||
if c == '-' {
|
||||
if !prev_hyphen {
|
||||
collapsed.push('-');
|
||||
}
|
||||
prev_hyphen = true;
|
||||
} else {
|
||||
collapsed.push(c);
|
||||
prev_hyphen = false;
|
||||
}
|
||||
fn parse_markdown_agent(content: &str) -> Result<CreatePersonaRequest, String> {
|
||||
let trimmed = content.trim_start();
|
||||
if !trimmed.starts_with("---") {
|
||||
return Err("Missing frontmatter delimiter".to_string());
|
||||
}
|
||||
|
||||
let trimmed = collapsed.trim_matches('-');
|
||||
let result = if trimmed.len() > 50 {
|
||||
// Cut at 50 chars without splitting mid-char, then trim trailing hyphens
|
||||
trimmed[..50].trim_end_matches('-').to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
};
|
||||
let after_first = &trimmed[3..];
|
||||
let end_idx = after_first
|
||||
.find("\n---")
|
||||
.ok_or_else(|| "Missing closing frontmatter delimiter".to_string())?;
|
||||
let yaml = &after_first[..end_idx];
|
||||
let body = after_first[end_idx + 4..].trim().to_string();
|
||||
let frontmatter: MarkdownAgentFrontmatter = serde_yaml::from_str(yaml)
|
||||
.map_err(|e| format!("Invalid frontmatter YAML: {}", e))?;
|
||||
|
||||
if result.is_empty() {
|
||||
"persona".to_string()
|
||||
} else {
|
||||
result
|
||||
if frontmatter.name.trim().is_empty() {
|
||||
return Err("Agent name cannot be empty".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Export a persona as sprout-compatible JSON (version 1).
|
||||
/// Returns the JSON string and a suggested filename.
|
||||
#[tauri::command]
|
||||
pub fn export_persona(store: State<'_, PersonaStore>, id: String) -> Result<ExportResult, String> {
|
||||
let persona = store
|
||||
.get(&id)
|
||||
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
||||
|
||||
// For export, only include URL avatars (local files aren't portable)
|
||||
let export_avatar = match &persona.avatar {
|
||||
Some(Avatar::Url(url)) => Some(Avatar::Url(url.clone())),
|
||||
_ => None,
|
||||
let system_prompt = if body.is_empty() {
|
||||
frontmatter
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("You are {}.", frontmatter.name))
|
||||
} else {
|
||||
body
|
||||
};
|
||||
|
||||
let export = PersonaExportV1 {
|
||||
version: 1,
|
||||
display_name: persona.display_name.clone(),
|
||||
system_prompt: persona.system_prompt,
|
||||
avatar: export_avatar,
|
||||
provider: persona.provider,
|
||||
model: persona.model,
|
||||
};
|
||||
if system_prompt.trim().is_empty() {
|
||||
return Err("Agent prompt cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(&export)
|
||||
.map_err(|e| format!("Failed to serialize persona: {}", e))?;
|
||||
|
||||
let slug = slugify(&persona.display_name);
|
||||
let suggested_filename = format!("{}.persona.json", slug);
|
||||
|
||||
Ok(ExportResult {
|
||||
json,
|
||||
suggested_filename,
|
||||
Ok(CreatePersonaRequest {
|
||||
display_name: frontmatter.name,
|
||||
avatar: frontmatter.avatar,
|
||||
system_prompt,
|
||||
provider: frontmatter.provider,
|
||||
model: frontmatter.model,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import personas from sprout-compatible JSON (version 1).
|
||||
/// Accepts raw file bytes and the original filename.
|
||||
/// Returns the list of newly created personas.
|
||||
/// Import an agent from its canonical Markdown format.
|
||||
#[tauri::command]
|
||||
pub fn import_personas(
|
||||
store: State<'_, PersonaStore>,
|
||||
file_bytes: Vec<u8>,
|
||||
file_name: String,
|
||||
) -> Result<Vec<Persona>, String> {
|
||||
// Validate file extension
|
||||
if !file_name.ends_with(".persona.json") && !file_name.ends_with(".json") {
|
||||
return Err("Unsupported file type. Expected a .persona.json or .json file.".to_string());
|
||||
if !file_name.to_lowercase().ends_with(".md") {
|
||||
return Err("Unsupported file type. Expected a .md file.".to_string());
|
||||
}
|
||||
|
||||
// Parse the bytes as UTF-8
|
||||
let content =
|
||||
String::from_utf8(file_bytes).map_err(|_| "File is not valid UTF-8 text".to_string())?;
|
||||
|
||||
// Parse as JSON
|
||||
let export: PersonaExportV1 =
|
||||
serde_json::from_str(&content).map_err(|e| format!("Invalid persona JSON: {}", e))?;
|
||||
|
||||
// Validate version
|
||||
if export.version != 1 {
|
||||
return Err(format!(
|
||||
"Unsupported persona format version {}. Expected version 1.",
|
||||
export.version
|
||||
));
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if export.display_name.trim().is_empty() {
|
||||
return Err("Persona displayName cannot be empty".to_string());
|
||||
}
|
||||
if export.system_prompt.trim().is_empty() {
|
||||
return Err("Persona systemPrompt cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Create the persona via the store
|
||||
let request = CreatePersonaRequest {
|
||||
display_name: export.display_name,
|
||||
avatar: export.avatar,
|
||||
system_prompt: export.system_prompt,
|
||||
provider: export.provider,
|
||||
model: export.model,
|
||||
};
|
||||
|
||||
let persona = store.create(request)?;
|
||||
let request = parse_markdown_agent(&content)?;
|
||||
let persona = store.import_markdown(&request.display_name, &content)?;
|
||||
Ok(vec![persona])
|
||||
}
|
||||
|
||||
@@ -266,8 +186,8 @@ 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");
|
||||
fn validate_import_persona_path_rejects_non_markdown_files() {
|
||||
let path = std::env::temp_dir().join("persona-import.json");
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let result = validate_import_persona_path(path.to_str().unwrap());
|
||||
@@ -288,8 +208,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_accepts_json_files() {
|
||||
let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id()));
|
||||
fn validate_import_persona_path_accepts_markdown_files() {
|
||||
let path = std::env::temp_dir().join(format!("persona-import-{}.md", std::process::id()));
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap();
|
||||
|
||||
@@ -36,7 +36,6 @@ pub fn run() {
|
||||
commands::agents::update_persona,
|
||||
commands::agents::delete_persona,
|
||||
commands::agents::refresh_personas,
|
||||
commands::agents::export_persona,
|
||||
commands::agents::import_personas,
|
||||
commands::agents::read_import_persona_file,
|
||||
commands::agents::save_persona_avatar,
|
||||
|
||||
@@ -2,32 +2,36 @@ use crate::types::agents::{
|
||||
builtin_personas, Avatar, CreatePersonaRequest, Persona, UpdatePersonaRequest,
|
||||
};
|
||||
use log::warn;
|
||||
use std::collections::HashSet;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct PersonaStore {
|
||||
personas: Mutex<Vec<Persona>>,
|
||||
store_path: PathBuf,
|
||||
}
|
||||
|
||||
/// YAML frontmatter fields parsed from markdown persona files.
|
||||
#[derive(serde::Deserialize)]
|
||||
#[derive(Clone, Default, Deserialize, Serialize)]
|
||||
struct MarkdownFrontmatter {
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
avatar: Option<Avatar>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
provider: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl PersonaStore {
|
||||
pub fn new() -> Self {
|
||||
let store_path = Self::store_path();
|
||||
let stored = Self::load_from_disk(&store_path);
|
||||
let markdown = Self::load_markdown_personas();
|
||||
let merged = Self::merge_all(stored, markdown);
|
||||
Self {
|
||||
personas: Mutex::new(merged),
|
||||
store_path,
|
||||
}
|
||||
Self::migrate_legacy_markdown_agents();
|
||||
Self::migrate_legacy_personas_json(&store_path);
|
||||
Self::ensure_seed_agents();
|
||||
let merged = Self::load_markdown_personas();
|
||||
Self { personas: Mutex::new(merged) }
|
||||
}
|
||||
|
||||
fn store_path() -> PathBuf {
|
||||
@@ -35,6 +39,18 @@ impl PersonaStore {
|
||||
base.join(".goose").join("personas.json")
|
||||
}
|
||||
|
||||
fn migration_marker_path() -> PathBuf {
|
||||
Self::agents_dir().join(".personas-json-migrated")
|
||||
}
|
||||
|
||||
fn seed_marker_path() -> PathBuf {
|
||||
Self::agents_dir().join(".seed-agents-installed")
|
||||
}
|
||||
|
||||
fn legacy_markdown_migration_marker_path() -> PathBuf {
|
||||
Self::agents_dir().join(".goose-agents-migrated")
|
||||
}
|
||||
|
||||
/// Path to the avatars directory (~/.goose/avatars/).
|
||||
pub fn avatars_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
@@ -43,66 +59,149 @@ impl PersonaStore {
|
||||
.join("avatars")
|
||||
}
|
||||
|
||||
fn load_from_disk(path: &PathBuf) -> Vec<Persona> {
|
||||
fn load_legacy_json(path: &PathBuf) -> Vec<Persona> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) => {
|
||||
let mut personas: Vec<Persona> =
|
||||
serde_json::from_str(&contents).unwrap_or_default();
|
||||
let source_path = path.to_string_lossy().to_string();
|
||||
for persona in &mut personas {
|
||||
persona.source_path = Some(source_path.clone());
|
||||
}
|
||||
personas
|
||||
}
|
||||
Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge builtins, JSON custom personas, and markdown personas.
|
||||
/// Priority: builtins first, then JSON custom, then markdown.
|
||||
/// Deduplication is by display_name (case-insensitive).
|
||||
fn merge_all(stored: Vec<Persona>, markdown: Vec<Persona>) -> Vec<Persona> {
|
||||
let builtins = builtin_personas();
|
||||
|
||||
let mut result = builtins;
|
||||
let mut seen_names: HashSet<String> = result
|
||||
.iter()
|
||||
.map(|p| p.display_name.to_lowercase())
|
||||
.collect();
|
||||
let mut seen_ids: HashSet<String> = result.iter().map(|p| p.id.clone()).collect();
|
||||
|
||||
// Add custom (non-builtin) personas from JSON
|
||||
for persona in stored {
|
||||
if !seen_ids.contains(&persona.id) {
|
||||
seen_names.insert(persona.display_name.to_lowercase());
|
||||
seen_ids.insert(persona.id.clone());
|
||||
result.push(persona);
|
||||
}
|
||||
}
|
||||
|
||||
// Add markdown personas, skipping any whose name already exists
|
||||
for persona in markdown {
|
||||
if !seen_names.contains(&persona.display_name.to_lowercase())
|
||||
&& !seen_ids.contains(&persona.id)
|
||||
{
|
||||
seen_names.insert(persona.display_name.to_lowercase());
|
||||
seen_ids.insert(persona.id.clone());
|
||||
result.push(persona);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
/// Canonical directory containing global markdown agent files.
|
||||
fn agents_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("home dir")
|
||||
.join(".agents")
|
||||
.join("agents")
|
||||
}
|
||||
|
||||
/// Directory containing markdown persona files.
|
||||
fn agents_dir() -> PathBuf {
|
||||
/// Previous markdown-agent location. Read only for one-time migration.
|
||||
fn legacy_agents_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.expect("home dir")
|
||||
.join(".goose")
|
||||
.join("agents")
|
||||
}
|
||||
|
||||
/// Scan `~/.goose/agents/*.md` and parse each into a Persona.
|
||||
fn ensure_seed_agents() {
|
||||
let dir = Self::agents_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
warn!("Failed to create agents directory {:?}: {}", dir, err);
|
||||
return;
|
||||
}
|
||||
if Self::seed_marker_path().exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
for persona in builtin_personas() {
|
||||
let path = dir.join(format!("{}.md", Self::slugify_name(&persona.display_name)));
|
||||
if path.exists() {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = std::fs::write(&path, Self::persona_to_markdown(&persona)) {
|
||||
warn!("Failed to seed agent {:?}: {}", path, err);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::write(Self::seed_marker_path(), chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
fn migrate_legacy_markdown_agents() {
|
||||
let legacy_dir = Self::legacy_agents_dir();
|
||||
if !legacy_dir.is_dir() || Self::legacy_markdown_migration_marker_path().exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let target_dir = Self::agents_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&target_dir) {
|
||||
warn!("Failed to create agents directory {:?}: {}", target_dir, err);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match std::fs::read_dir(&legacy_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(err) => {
|
||||
warn!("Failed to read legacy agents directory {:?}: {}", legacy_dir, err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let source = entry.path();
|
||||
if source.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(file_name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let destination =
|
||||
Self::unique_agent_path(&target_dir, file_name.to_string_lossy().as_ref());
|
||||
if let Err(err) = std::fs::copy(&source, &destination) {
|
||||
warn!(
|
||||
"Failed to migrate legacy agent {:?} to {:?}: {}",
|
||||
source, destination, err
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = std::fs::write(
|
||||
Self::legacy_markdown_migration_marker_path(),
|
||||
chrono::Utc::now().to_rfc3339(),
|
||||
);
|
||||
}
|
||||
|
||||
fn migrate_legacy_personas_json(path: &PathBuf) {
|
||||
if !path.is_file() || Self::migration_marker_path().exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let personas = Self::load_legacy_json(path);
|
||||
let dir = Self::agents_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
warn!("Failed to create agents directory {:?}: {}", dir, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if personas.is_empty() {
|
||||
let _ = std::fs::write(Self::migration_marker_path(), "");
|
||||
return;
|
||||
}
|
||||
|
||||
for persona in personas {
|
||||
let filename = format!("{}.md", Self::slugify_name(&persona.display_name));
|
||||
let path = Self::unique_agent_path(&dir, &filename);
|
||||
if let Err(err) = std::fs::write(&path, Self::persona_to_markdown(&persona)) {
|
||||
warn!("Failed to migrate persona '{}' to markdown: {}", persona.display_name, err);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = std::fs::write(Self::migration_marker_path(), chrono::Utc::now().to_rfc3339());
|
||||
}
|
||||
|
||||
fn unique_agent_path(dir: &Path, filename: &str) -> PathBuf {
|
||||
let path = dir.join(filename);
|
||||
if !path.exists() {
|
||||
return path;
|
||||
}
|
||||
|
||||
let stem = Path::new(filename)
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("agent");
|
||||
let extension = Path::new(filename)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("md");
|
||||
|
||||
for counter in 2.. {
|
||||
let candidate = dir.join(format!("{}-{}.{}", stem, counter, extension));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
unreachable!("counter loop always returns");
|
||||
}
|
||||
|
||||
/// Scan the canonical global agents directory and parse each Markdown file into a Persona.
|
||||
fn load_markdown_personas() -> Vec<Persona> {
|
||||
let dir = Self::agents_dir();
|
||||
if !dir.is_dir() {
|
||||
@@ -181,10 +280,10 @@ impl PersonaStore {
|
||||
Ok(Persona {
|
||||
id,
|
||||
display_name: frontmatter.name,
|
||||
avatar: None,
|
||||
avatar: frontmatter.avatar,
|
||||
system_prompt,
|
||||
provider: None,
|
||||
model: None,
|
||||
provider: frontmatter.provider,
|
||||
model: frontmatter.model,
|
||||
is_builtin: false,
|
||||
is_from_disk: true,
|
||||
source_path: Some(path.to_string_lossy().to_string()),
|
||||
@@ -210,6 +309,74 @@ impl PersonaStore {
|
||||
Ok((yaml_str, body))
|
||||
}
|
||||
|
||||
fn slugify_name(name: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut previous_hyphen = false;
|
||||
|
||||
for ch in name.to_lowercase().chars() {
|
||||
let next = if ch.is_ascii_alphanumeric() {
|
||||
Some(ch)
|
||||
} else if ch.is_whitespace() || ch == '-' || ch == '_' {
|
||||
Some('-')
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(ch) = next {
|
||||
if ch == '-' {
|
||||
if !previous_hyphen && !slug.is_empty() {
|
||||
slug.push(ch);
|
||||
}
|
||||
previous_hyphen = true;
|
||||
} else {
|
||||
slug.push(ch);
|
||||
previous_hyphen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let slug = slug.trim_matches('-');
|
||||
if slug.is_empty() {
|
||||
"agent".to_string()
|
||||
} else {
|
||||
slug.chars().take(64).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn persona_to_frontmatter(persona: &Persona) -> MarkdownFrontmatter {
|
||||
MarkdownFrontmatter {
|
||||
name: persona.display_name.clone(),
|
||||
description: None,
|
||||
avatar: persona.avatar.clone(),
|
||||
provider: persona.provider.clone(),
|
||||
model: persona.model.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn markdown_from_parts(frontmatter: &MarkdownFrontmatter, body: &str) -> Result<String, String> {
|
||||
let yaml = serde_yaml::to_string(frontmatter)
|
||||
.map_err(|e| format!("Failed to serialize frontmatter: {}", e))?;
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
Ok(format!("---\n{}---\n", yaml))
|
||||
} else {
|
||||
Ok(format!("---\n{}---\n\n{}\n", yaml, body))
|
||||
}
|
||||
}
|
||||
|
||||
fn persona_to_markdown(persona: &Persona) -> String {
|
||||
Self::markdown_from_parts(
|
||||
&Self::persona_to_frontmatter(persona),
|
||||
&persona.system_prompt,
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
format!(
|
||||
"---\nname: {}\n---\n\n{}\n",
|
||||
persona.display_name, persona.system_prompt
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn update_markdown_persona_file(
|
||||
id: &str,
|
||||
req: &UpdatePersonaRequest,
|
||||
@@ -219,26 +386,39 @@ impl PersonaStore {
|
||||
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
let (yaml_str, current_body) = Self::split_markdown_persona(&content)?;
|
||||
|
||||
let mut frontmatter: serde_yaml::Mapping = serde_yaml::from_str(yaml_str)
|
||||
let original_frontmatter: MarkdownFrontmatter = serde_yaml::from_str(yaml_str)
|
||||
.map_err(|e| format!("Invalid frontmatter YAML: {}", e))?;
|
||||
let mut frontmatter = original_frontmatter.clone();
|
||||
|
||||
if let Some(name) = &req.display_name {
|
||||
frontmatter.insert(
|
||||
serde_yaml::Value::String("name".to_string()),
|
||||
serde_yaml::Value::String(name.clone()),
|
||||
);
|
||||
frontmatter.name = name.clone();
|
||||
}
|
||||
if let Some(avatar) = &req.avatar {
|
||||
frontmatter.avatar = avatar.clone();
|
||||
}
|
||||
if let Some(provider) = &req.provider {
|
||||
frontmatter.provider = Some(provider.clone());
|
||||
}
|
||||
if let Some(model) = &req.model {
|
||||
frontmatter.model = Some(model.clone());
|
||||
}
|
||||
|
||||
let body = req
|
||||
.system_prompt
|
||||
.clone()
|
||||
.unwrap_or(current_body)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let yaml = serde_yaml::to_string(&frontmatter)
|
||||
.map_err(|e| format!("Failed to serialize frontmatter: {}", e))?;
|
||||
let next_content = format!("---\n{}---\n\n{}\n", yaml, body);
|
||||
let current_system_prompt = if current_body.is_empty() {
|
||||
original_frontmatter
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("You are {}.", original_frontmatter.name))
|
||||
} else {
|
||||
current_body.clone()
|
||||
};
|
||||
let body = match &req.system_prompt {
|
||||
Some(prompt) if current_body.is_empty() && prompt.trim() == current_system_prompt => {
|
||||
String::new()
|
||||
}
|
||||
Some(prompt) => prompt.trim().to_string(),
|
||||
None => current_body,
|
||||
};
|
||||
let next_content = Self::markdown_from_parts(&frontmatter, &body)?;
|
||||
|
||||
std::fs::write(&path, next_content)
|
||||
.map_err(|e| format!("Failed to write file '{}': {}", path.display(), e))?;
|
||||
@@ -269,34 +449,13 @@ impl PersonaStore {
|
||||
/// Re-scan markdown personas and update the in-memory list.
|
||||
/// Returns the full updated persona list.
|
||||
pub fn refresh_markdown(&self) -> Vec<Persona> {
|
||||
let stored = Self::load_from_disk(&self.store_path);
|
||||
let markdown = Self::load_markdown_personas();
|
||||
let merged = Self::merge_all(stored, markdown);
|
||||
|
||||
let mut personas = self.personas.lock().unwrap();
|
||||
*personas = merged;
|
||||
*personas = markdown;
|
||||
personas.clone()
|
||||
}
|
||||
|
||||
fn save_to_disk(&self, personas: &[Persona]) {
|
||||
if let Some(parent) = self.store_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
// Only persist app-created personas. Source paths are runtime metadata.
|
||||
let custom: Vec<Persona> = personas
|
||||
.iter()
|
||||
.filter(|p| !p.is_builtin && !p.is_from_disk)
|
||||
.map(|persona| {
|
||||
let mut persona = persona.clone();
|
||||
persona.source_path = None;
|
||||
persona
|
||||
})
|
||||
.collect();
|
||||
if let Ok(json) = serde_json::to_string_pretty(&custom) {
|
||||
let _ = std::fs::write(&self.store_path, json);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<Persona> {
|
||||
let personas = self.personas.lock().unwrap();
|
||||
personas.clone()
|
||||
@@ -310,7 +469,7 @@ impl PersonaStore {
|
||||
|
||||
pub fn create(&self, req: CreatePersonaRequest) -> Result<Persona, String> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let persona = Persona {
|
||||
let mut persona = Persona {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
display_name: req.display_name,
|
||||
avatar: req.avatar,
|
||||
@@ -318,15 +477,41 @@ impl PersonaStore {
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
is_builtin: false,
|
||||
is_from_disk: false,
|
||||
source_path: Some(self.store_path.to_string_lossy().to_string()),
|
||||
is_from_disk: true,
|
||||
source_path: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
let agents_dir = Self::agents_dir();
|
||||
std::fs::create_dir_all(&agents_dir)
|
||||
.map_err(|e| format!("Failed to create agents directory: {}", e))?;
|
||||
let filename = format!("{}.md", Self::slugify_name(&persona.display_name));
|
||||
let path = Self::unique_agent_path(&agents_dir, &filename);
|
||||
std::fs::write(&path, Self::persona_to_markdown(&persona))
|
||||
.map_err(|e| format!("Failed to write agent file '{}': {}", path.display(), e))?;
|
||||
persona = Self::parse_markdown_persona(&path)?;
|
||||
|
||||
let mut personas = self.personas.lock().unwrap();
|
||||
personas.push(persona.clone());
|
||||
Ok(persona)
|
||||
}
|
||||
|
||||
pub fn import_markdown(
|
||||
&self,
|
||||
display_name: &str,
|
||||
markdown: &str,
|
||||
) -> Result<Persona, String> {
|
||||
let agents_dir = Self::agents_dir();
|
||||
std::fs::create_dir_all(&agents_dir)
|
||||
.map_err(|e| format!("Failed to create agents directory: {}", e))?;
|
||||
let filename = format!("{}.md", Self::slugify_name(display_name));
|
||||
let path = Self::unique_agent_path(&agents_dir, &filename);
|
||||
std::fs::write(&path, markdown)
|
||||
.map_err(|e| format!("Failed to write agent file '{}': {}", path.display(), e))?;
|
||||
let persona = Self::parse_markdown_persona(&path)?;
|
||||
|
||||
let mut personas = self.personas.lock().unwrap();
|
||||
personas.push(persona.clone());
|
||||
self.save_to_disk(&personas);
|
||||
Ok(persona)
|
||||
}
|
||||
|
||||
@@ -337,9 +522,6 @@ impl PersonaStore {
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Cannot update a built-in persona".to_string());
|
||||
}
|
||||
if persona.is_from_disk {
|
||||
let updated = Self::update_markdown_persona_file(id, &req)?;
|
||||
*persona = updated.clone();
|
||||
@@ -365,7 +547,6 @@ impl PersonaStore {
|
||||
persona.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let updated = persona.clone();
|
||||
self.save_to_disk(&personas);
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -378,9 +559,6 @@ impl PersonaStore {
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Cannot delete a built-in persona".to_string());
|
||||
}
|
||||
if persona.is_from_disk {
|
||||
let path = Self::markdown_persona_path(id)?;
|
||||
match std::fs::remove_file(&path) {
|
||||
@@ -396,7 +574,6 @@ impl PersonaStore {
|
||||
}
|
||||
|
||||
personas.retain(|p| p.id != id);
|
||||
self.save_to_disk(&personas);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -407,7 +584,6 @@ impl PersonaStore {
|
||||
}
|
||||
|
||||
personas.retain(|p| p.id != id);
|
||||
self.save_to_disk(&personas);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -293,8 +293,8 @@ pub fn builtin_personas() -> Vec<Persona> {
|
||||
system_prompt: SOLO_SYSTEM_PROMPT_TEXT.to_string(),
|
||||
provider: Some("goose".to_string()),
|
||||
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||
is_builtin: true,
|
||||
is_from_disk: false,
|
||||
is_builtin: false,
|
||||
is_from_disk: true,
|
||||
source_path: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
@@ -306,8 +306,8 @@ pub fn builtin_personas() -> Vec<Persona> {
|
||||
system_prompt: SCOUT_SYSTEM_PROMPT_TEXT.to_string(),
|
||||
provider: Some("goose".to_string()),
|
||||
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||
is_builtin: true,
|
||||
is_from_disk: false,
|
||||
is_builtin: false,
|
||||
is_from_disk: true,
|
||||
source_path: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now.clone(),
|
||||
@@ -319,8 +319,8 @@ pub fn builtin_personas() -> Vec<Persona> {
|
||||
system_prompt: RALPH_SYSTEM_PROMPT_TEXT.to_string(),
|
||||
provider: Some("goose".to_string()),
|
||||
model: Some("claude-sonnet-4-20250514".to_string()),
|
||||
is_builtin: true,
|
||||
is_from_disk: false,
|
||||
is_builtin: false,
|
||||
is_from_disk: true,
|
||||
source_path: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
const JSON_MIME_TYPES = new Set([
|
||||
const MARKDOWN_MIME_TYPES = new Set([
|
||||
"",
|
||||
"application/json",
|
||||
"application/x-json",
|
||||
"text/json",
|
||||
"text/markdown",
|
||||
"text/x-markdown",
|
||||
"text/plain",
|
||||
]);
|
||||
|
||||
@@ -19,13 +18,13 @@ export function validatePersonaImportFile(
|
||||
file: Pick<File, "name" | "type">,
|
||||
): ImportMessageDescriptor | null {
|
||||
const lowerName = file.name.toLowerCase();
|
||||
if (!lowerName.endsWith(".json")) {
|
||||
if (!lowerName.endsWith(".md")) {
|
||||
return {
|
||||
key: "view.importInvalidExtension",
|
||||
} satisfies ImportMessageDescriptor;
|
||||
}
|
||||
|
||||
if (!JSON_MIME_TYPES.has(file.type)) {
|
||||
if (!MARKDOWN_MIME_TYPES.has(file.type)) {
|
||||
return {
|
||||
key: "view.importInvalidMimeType",
|
||||
} satisfies ImportMessageDescriptor;
|
||||
|
||||
@@ -1,21 +1,3 @@
|
||||
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) === "builtin";
|
||||
}
|
||||
|
||||
export function getPersonaInitials(displayName: string): string {
|
||||
const initials = displayName
|
||||
.trim()
|
||||
|
||||
@@ -177,28 +177,4 @@ describe("agentStore", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((a) => a.id).sort()).toEqual(["a1", "a3"]);
|
||||
});
|
||||
|
||||
it("getBuiltinPersonas returns only builtins", () => {
|
||||
useAgentStore
|
||||
.getState()
|
||||
.setPersonas([
|
||||
makePersona({ id: "b", isBuiltin: true }),
|
||||
makePersona({ id: "c", isBuiltin: false }),
|
||||
]);
|
||||
const builtins = useAgentStore.getState().getBuiltinPersonas();
|
||||
expect(builtins).toHaveLength(1);
|
||||
expect(builtins[0].id).toBe("b");
|
||||
});
|
||||
|
||||
it("getCustomPersonas returns only non-builtins", () => {
|
||||
useAgentStore
|
||||
.getState()
|
||||
.setPersonas([
|
||||
makePersona({ id: "b", isBuiltin: true }),
|
||||
makePersona({ id: "c", isBuiltin: false }),
|
||||
]);
|
||||
const custom = useAgentStore.getState().getCustomPersonas();
|
||||
expect(custom).toHaveLength(1);
|
||||
expect(custom[0].id).toBe("c");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,8 +97,6 @@ interface AgentStoreActions {
|
||||
getPersonaById: (id: string) => Persona | undefined;
|
||||
getAgentById: (id: string) => Agent | undefined;
|
||||
getAgentsByPersona: (personaId: string) => Agent[];
|
||||
getBuiltinPersonas: () => Persona[];
|
||||
getCustomPersonas: () => Persona[];
|
||||
}
|
||||
|
||||
export type AgentStore = AgentStoreState & AgentStoreActions;
|
||||
@@ -216,8 +214,4 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
||||
|
||||
getAgentsByPersona: (personaId) =>
|
||||
get().agents.filter((a) => a.personaId === personaId),
|
||||
|
||||
getBuiltinPersonas: () => get().personas.filter((p) => p.isBuiltin),
|
||||
|
||||
getCustomPersonas: () => get().personas.filter((p) => !p.isBuiltin),
|
||||
}));
|
||||
|
||||
@@ -122,9 +122,6 @@ export function AgentConfig({
|
||||
{personas.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.displayName}
|
||||
{p.isBuiltin
|
||||
? ` (${t("common:labels.builtIn").toLowerCase()})`
|
||||
: ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useState, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Copy,
|
||||
FolderOpen,
|
||||
CopyPlus,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Save,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { MessageResponse } from "@/shared/ui/ai-elements/message";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/shared/ui/avatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DetailField } from "@/shared/ui/detail-field";
|
||||
import {
|
||||
@@ -26,20 +26,14 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { PageColumns } from "@/shared/ui/page-columns";
|
||||
import { DetailPageShell, PageHeader } from "@/shared/ui/page-shell";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import {
|
||||
getPersonaInitials,
|
||||
getPersonaSource,
|
||||
isPersonaReadOnly,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface AgentDetailPageProps {
|
||||
persona: Persona;
|
||||
onBack: () => void;
|
||||
onEdit: (persona: Persona) => void;
|
||||
onReveal: (persona: Persona) => void;
|
||||
onStartChat?: (persona: Persona) => void;
|
||||
onCopyFile: (persona: Persona) => void;
|
||||
onSaveCopy: (persona: Persona) => void;
|
||||
@@ -60,44 +54,22 @@ function AgentHeaderActionButton({
|
||||
...props
|
||||
}: AgentHeaderActionButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type={type}
|
||||
size="icon-xs"
|
||||
variant="outline-flat"
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" sideOffset={8}>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type={type}
|
||||
size="xs"
|
||||
variant="outline-flat"
|
||||
leftIcon={icon}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function AgentDetailPage({
|
||||
persona,
|
||||
onBack,
|
||||
onEdit,
|
||||
onReveal,
|
||||
onStartChat,
|
||||
onCopyFile,
|
||||
onSaveCopy,
|
||||
@@ -108,19 +80,10 @@ export function AgentDetailPage({
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
||||
const initials = getPersonaInitials(persona.displayName);
|
||||
const personaSource = getPersonaSource(persona);
|
||||
const canEditPersona = !isPersonaReadOnly(persona);
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const hasFileActions =
|
||||
personaSource === "file" && Boolean(persona.sourcePath);
|
||||
const sourceLabel =
|
||||
personaSource === "builtin"
|
||||
? t("common:labels.builtIn")
|
||||
: personaSource === "file"
|
||||
? t("card.fileBacked")
|
||||
: t("card.custom");
|
||||
const hasFileActions = Boolean(persona.sourcePath);
|
||||
const providerLabel = persona.provider || t("common:labels.none");
|
||||
const modelLabel = persona.model || t("common:labels.none");
|
||||
const shareLabel = t("view.share");
|
||||
const moreLabel = t("view.more");
|
||||
|
||||
return (
|
||||
@@ -160,24 +123,38 @@ export function AgentDetailPage({
|
||||
{onStartChat ? (
|
||||
<AgentHeaderActionButton
|
||||
label={t("view.startChatShort")}
|
||||
icon={<MessageSquarePlus className="size-3.5" />}
|
||||
icon={<MessageSquarePlus aria-hidden="true" />}
|
||||
onClick={() => onStartChat(persona)}
|
||||
/>
|
||||
) : null}
|
||||
{canEditPersona ? (
|
||||
<AgentHeaderActionButton
|
||||
label={t("common:actions.edit")}
|
||||
icon={<Pencil className="size-3.5" />}
|
||||
onClick={() => onEdit(persona)}
|
||||
/>
|
||||
) : null}
|
||||
{persona.sourcePath ? (
|
||||
<AgentHeaderActionButton
|
||||
label={t("view.reveal")}
|
||||
icon={<FolderOpen className="size-3.5" />}
|
||||
onClick={() => onReveal(persona)}
|
||||
/>
|
||||
) : null}
|
||||
<AgentHeaderActionButton
|
||||
label={t("common:actions.edit")}
|
||||
icon={<Pencil aria-hidden="true" />}
|
||||
onClick={() => onEdit(persona)}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline-flat"
|
||||
leftIcon={<Share2 aria-hidden="true" />}
|
||||
disabled={!hasFileActions}
|
||||
>
|
||||
{shareLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy(persona)}>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -191,31 +168,17 @@ export function AgentDetailPage({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
{hasFileActions ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy(persona)}>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => onDuplicate(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
<CopyPlus className="size-3.5" />
|
||||
{t("editor.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
{canDeletePersona ? (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(persona)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(persona)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
@@ -231,21 +194,7 @@ export function AgentDetailPage({
|
||||
minContentSize={52}
|
||||
sidebar={
|
||||
<aside className="space-y-5">
|
||||
<section className="space-y-5 border-b border-border pb-5">
|
||||
<DetailField label={t("view.source")}>
|
||||
<Badge variant="secondary">{sourceLabel}</Badge>
|
||||
</DetailField>
|
||||
|
||||
{persona.sourcePath ? (
|
||||
<DetailField
|
||||
label={t("view.filePath")}
|
||||
contentAs="p"
|
||||
contentClassName="break-all text-foreground"
|
||||
>
|
||||
{persona.sourcePath}
|
||||
</DetailField>
|
||||
) : null}
|
||||
|
||||
<section className="space-y-5">
|
||||
<DetailField
|
||||
label={t("editor.provider")}
|
||||
contentAs="p"
|
||||
@@ -262,15 +211,6 @@ export function AgentDetailPage({
|
||||
{modelLabel}
|
||||
</DetailField>
|
||||
</section>
|
||||
|
||||
<section className="space-y-5">
|
||||
<DetailField label={t("view.created")} contentAs="p">
|
||||
{formatDate(persona.createdAt)}
|
||||
</DetailField>
|
||||
<DetailField label={t("view.updated")} contentAs="p">
|
||||
{formatDate(persona.updatedAt)}
|
||||
</DetailField>
|
||||
</section>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { toast } from "sonner";
|
||||
import { SearchBar } from "@/shared/ui/SearchBar";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
|
||||
import { revealInFileManager } from "@/shared/lib/fileManager";
|
||||
import { copyFileToClipboard, saveFileCopy } from "@/shared/api/system";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -34,7 +33,6 @@ import {
|
||||
formatImportSuccessMessage,
|
||||
validatePersonaImportFile,
|
||||
} from "@/features/agents/lib/personaImport";
|
||||
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface AgentsViewProps {
|
||||
onStartChatWithPersona?: (persona: Persona) => void;
|
||||
@@ -122,7 +120,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
|
||||
);
|
||||
|
||||
const handleDeletePersona = useCallback((persona: Persona) => {
|
||||
if (getPersonaSource(persona) === "builtin") return;
|
||||
setDeletingPersona(persona);
|
||||
}, []);
|
||||
|
||||
@@ -178,11 +175,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleRevealPersona = useCallback((persona: Persona) => {
|
||||
if (!persona.sourcePath) return;
|
||||
void revealInFileManager(persona.sourcePath);
|
||||
}, []);
|
||||
|
||||
const handleImportError = useCallback((message: string) => {
|
||||
toast.error(message);
|
||||
}, []);
|
||||
@@ -217,8 +209,8 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
|
||||
title: t("common:actions.import"),
|
||||
filters: [
|
||||
{
|
||||
name: "JSON",
|
||||
extensions: ["json"],
|
||||
name: "Markdown",
|
||||
extensions: ["md"],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -295,7 +287,6 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
|
||||
persona={activePersona}
|
||||
onBack={() => setActivePersonaId(null)}
|
||||
onEdit={(persona) => openPersonaEditor(persona, "edit")}
|
||||
onReveal={handleRevealPersona}
|
||||
onStartChat={onStartChatWithPersona}
|
||||
onCopyFile={handleCopyPersonaFile}
|
||||
onSaveCopy={handleSavePersonaCopy}
|
||||
@@ -349,6 +340,7 @@ export function AgentsView({ onStartChatWithPersona }: AgentsViewProps) {
|
||||
personas={filteredPersonas}
|
||||
hasAnyPersonas={personas.length > 0}
|
||||
onSelectPersona={(p) => setActivePersonaId(p.id)}
|
||||
onStartChatPersona={onStartChatWithPersona}
|
||||
onEditPersona={(p) => openPersonaEditor(p, "edit")}
|
||||
onDuplicatePersona={handleDuplicatePersona}
|
||||
onDeletePersona={handleDeletePersona}
|
||||
|
||||
@@ -1,27 +1,35 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, MoreVertical, Pencil, Save, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
CopyPlus,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Save,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/shared/ui/avatar";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { useAvatarSrc } from "@/shared/hooks/useAvatarSrc";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import {
|
||||
getPersonaInitials,
|
||||
getPersonaSource,
|
||||
isPersonaReadOnly,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface PersonaCardProps {
|
||||
persona: Persona;
|
||||
onSelect?: (persona: Persona) => void;
|
||||
onStartChat?: (persona: Persona) => void;
|
||||
onEdit?: (persona: Persona) => void;
|
||||
onDuplicate?: (persona: Persona) => void;
|
||||
onDelete?: (persona: Persona) => void;
|
||||
@@ -33,6 +41,7 @@ interface PersonaCardProps {
|
||||
export function PersonaCard({
|
||||
persona,
|
||||
onSelect,
|
||||
onStartChat,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
@@ -45,12 +54,7 @@ export function PersonaCard({
|
||||
|
||||
const initials = getPersonaInitials(persona.displayName);
|
||||
const avatarSrc = useAvatarSrc(persona.avatar);
|
||||
const personaSource = getPersonaSource(persona);
|
||||
const canEditPersona = !isPersonaReadOnly(persona);
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const hasFileActions =
|
||||
personaSource === "file" && Boolean(persona.sourcePath);
|
||||
const isFeatured = personaSource === "builtin";
|
||||
const hasFileActions = Boolean(persona.sourcePath);
|
||||
const providerModelLabel = [persona.provider, persona.model]
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
@@ -75,6 +79,7 @@ export function PersonaCard({
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"group relative flex cursor-pointer flex-col rounded-2xl border border-border-soft bg-background p-5",
|
||||
"h-full",
|
||||
"transition-colors duration-200 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2",
|
||||
"hover:border-border hover:bg-muted/10 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "border-border bg-muted/20",
|
||||
@@ -109,37 +114,45 @@ export function PersonaCard({
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
{hasFileActions && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile?.(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy?.(persona)}>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{canEditPersona && (
|
||||
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
|
||||
<Pencil className="size-3.5" />
|
||||
{t("common:actions.edit")}
|
||||
{onStartChat && (
|
||||
<DropdownMenuItem onSelect={() => onStartChat(persona)}>
|
||||
<MessageSquarePlus className="size-3.5" />
|
||||
{t("view.startChatShort")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => onEdit?.(persona)}>
|
||||
<Pencil className="size-3.5" />
|
||||
{t("common:actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
{hasFileActions && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Share2 className="size-3.5" />
|
||||
{t("view.share")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile?.(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy?.(persona)}>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => onDuplicate?.(persona)}>
|
||||
<Copy className="size-3.5" />
|
||||
<CopyPlus className="size-3.5" />
|
||||
{t("common:actions.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
{canDeletePersona && (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete?.(persona)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete?.(persona)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -150,11 +163,6 @@ export function PersonaCard({
|
||||
<h3 className="min-w-0 truncate text-sm font-medium leading-5 text-foreground">
|
||||
{persona.displayName}
|
||||
</h3>
|
||||
{isFeatured ? (
|
||||
<Badge variant="featured" className="text-[10px]">
|
||||
{t("card.featured")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{providerModelLabel ? (
|
||||
|
||||
@@ -5,20 +5,15 @@ import {
|
||||
AvatarImage,
|
||||
} from "@/shared/ui/avatar";
|
||||
import { DetailField } from "@/shared/ui/detail-field";
|
||||
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 {
|
||||
getPersonaInitials,
|
||||
type PersonaSource,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface PersonaDetailsProps {
|
||||
avatar: Avatar | null;
|
||||
displayName: string;
|
||||
modelLabel: string;
|
||||
personaSource: PersonaSource;
|
||||
providerLabel: string;
|
||||
systemPrompt: string;
|
||||
}
|
||||
@@ -27,7 +22,6 @@ export function PersonaDetails({
|
||||
avatar,
|
||||
displayName,
|
||||
modelLabel,
|
||||
personaSource,
|
||||
providerLabel,
|
||||
systemPrompt,
|
||||
}: PersonaDetailsProps) {
|
||||
@@ -55,14 +49,6 @@ export function PersonaDetails({
|
||||
>
|
||||
{displayName}
|
||||
</DetailField>
|
||||
<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>
|
||||
|
||||
@@ -37,11 +37,7 @@ 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 {
|
||||
getPersonaInitials,
|
||||
getPersonaSource,
|
||||
isPersonaReadOnly,
|
||||
} from "@/features/agents/lib/personaPresentation";
|
||||
import { getPersonaInitials } from "@/features/agents/lib/personaPresentation";
|
||||
import { AvatarDropZone } from "./AvatarDropZone";
|
||||
import { PersonaDetails } from "./PersonaDetails";
|
||||
|
||||
@@ -71,12 +67,7 @@ export function PersonaEditor({
|
||||
const { t } = useTranslation(["agents", "common"]);
|
||||
const isEditing = mode === "edit";
|
||||
const detailsMode = mode === "details";
|
||||
const readOnlyBySource = persona ? isPersonaReadOnly(persona) : false;
|
||||
const isReadOnly = detailsMode || readOnlyBySource;
|
||||
const personaSource = persona ? getPersonaSource(persona) : "custom";
|
||||
const canEditPersona = !readOnlyBySource;
|
||||
const isFileBacked = personaSource === "file";
|
||||
const canDeletePersona = personaSource !== "builtin";
|
||||
const isReadOnly = detailsMode;
|
||||
const acpProviders = useAgentStore((s) => s.providers);
|
||||
const setProviders = useAgentStore((s) => s.setProviders);
|
||||
const mergeInventoryEntries = useProviderInventoryStore(
|
||||
@@ -151,9 +142,6 @@ export function PersonaEditor({
|
||||
? `__saved__:${model}`
|
||||
: model || "__none__";
|
||||
|
||||
const readOnlyDescription = readOnlyBySource
|
||||
? t("editor.readOnlyBuiltIn")
|
||||
: null;
|
||||
const providerLabel = provider
|
||||
? (acpProviders.find((providerOption) => providerOption.id === provider)
|
||||
?.label ?? provider)
|
||||
@@ -202,11 +190,6 @@ export function PersonaEditor({
|
||||
? t("editor.editTitle")
|
||||
: t("editor.newTitle")}
|
||||
</DialogTitle>
|
||||
{readOnlyDescription ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{readOnlyDescription}
|
||||
</p>
|
||||
) : null}
|
||||
</DialogHeader>
|
||||
|
||||
{detailsMode ? (
|
||||
@@ -215,7 +198,6 @@ export function PersonaEditor({
|
||||
avatar={avatar}
|
||||
displayName={displayName}
|
||||
modelLabel={modelLabel}
|
||||
personaSource={personaSource}
|
||||
providerLabel={providerLabel}
|
||||
systemPrompt={systemPrompt}
|
||||
/>
|
||||
@@ -224,7 +206,7 @@ export function PersonaEditor({
|
||||
<DialogBody asChild className="space-y-4 px-5 pb-5">
|
||||
<form id="persona-form" onSubmit={handleSubmit}>
|
||||
<div className="flex justify-center">
|
||||
{isReadOnly || isFileBacked ? (
|
||||
{isReadOnly ? (
|
||||
<AvatarRoot className="h-16 w-16 border border-border">
|
||||
<AvatarImage
|
||||
src={avatarSrc ?? undefined}
|
||||
@@ -301,13 +283,12 @@ export function PersonaEditor({
|
||||
setModel("");
|
||||
}
|
||||
}}
|
||||
disabled={isReadOnly || isFileBacked}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-full",
|
||||
(isReadOnly || isFileBacked) &&
|
||||
"opacity-70 cursor-not-allowed",
|
||||
isReadOnly && "opacity-70 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder={t("common:labels.none")} />
|
||||
@@ -345,13 +326,12 @@ export function PersonaEditor({
|
||||
}
|
||||
setModel(value);
|
||||
}}
|
||||
disabled={isReadOnly || isFileBacked || !provider}
|
||||
disabled={isReadOnly || !provider}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"w-full",
|
||||
(isReadOnly || isFileBacked) &&
|
||||
"opacity-70 cursor-not-allowed",
|
||||
isReadOnly && "opacity-70 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<SelectValue
|
||||
@@ -399,7 +379,7 @@ export function PersonaEditor({
|
||||
<DialogFooter className="shrink-0 border-t px-5 py-4">
|
||||
{detailsMode && persona ? (
|
||||
<>
|
||||
{onEdit && canEditPersona ? (
|
||||
{onEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-flat"
|
||||
@@ -421,7 +401,7 @@ export function PersonaEditor({
|
||||
{t("editor.duplicate")}
|
||||
</Button>
|
||||
) : null}
|
||||
{onDelete && canDeletePersona ? (
|
||||
{onDelete ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive-flat"
|
||||
|
||||
@@ -7,12 +7,12 @@ import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import type { Persona } from "@/shared/types/agents";
|
||||
import { PersonaCard } from "@/features/agents/ui/PersonaCard";
|
||||
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
|
||||
import { getPersonaSource } from "@/features/agents/lib/personaPresentation";
|
||||
|
||||
interface PersonaGalleryProps {
|
||||
personas: Persona[];
|
||||
activePersonaId?: string;
|
||||
onSelectPersona: (persona: Persona) => void;
|
||||
onStartChatPersona?: (persona: Persona) => void;
|
||||
onEditPersona: (persona: Persona) => void;
|
||||
onDuplicatePersona: (persona: Persona) => void;
|
||||
onDeletePersona: (persona: Persona) => void;
|
||||
@@ -53,6 +53,7 @@ export function PersonaGallery({
|
||||
personas,
|
||||
activePersonaId,
|
||||
onSelectPersona,
|
||||
onStartChatPersona,
|
||||
onEditPersona,
|
||||
onDuplicatePersona,
|
||||
onDeletePersona,
|
||||
@@ -74,16 +75,7 @@ export function PersonaGallery({
|
||||
});
|
||||
const sortedPersonas = useMemo(
|
||||
() =>
|
||||
[...personas].sort((a, b) => {
|
||||
const aFeatured = getPersonaSource(a) === "builtin";
|
||||
const bFeatured = getPersonaSource(b) === "builtin";
|
||||
|
||||
if (aFeatured !== bFeatured) {
|
||||
return aFeatured ? -1 : 1;
|
||||
}
|
||||
|
||||
return a.displayName.localeCompare(b.displayName);
|
||||
}),
|
||||
[...personas].sort((a, b) => a.displayName.localeCompare(b.displayName)),
|
||||
[personas],
|
||||
);
|
||||
|
||||
@@ -133,7 +125,7 @@ export function PersonaGallery({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
@@ -144,13 +136,14 @@ export function PersonaGallery({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{sortedPersonas.map((persona) => (
|
||||
<PersonaCard
|
||||
key={persona.id}
|
||||
persona={persona}
|
||||
isActive={persona.id === activePersonaId}
|
||||
onSelect={onSelectPersona}
|
||||
onStartChat={onStartChatPersona}
|
||||
onEdit={onEditPersona}
|
||||
onDuplicate={onDuplicatePersona}
|
||||
onDelete={onDeletePersona}
|
||||
@@ -166,7 +159,7 @@ export function PersonaGallery({
|
||||
aria-label={t("gallery.createAria")}
|
||||
{...dropHandlers}
|
||||
className={cn(
|
||||
"flex min-h-48 w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed p-5",
|
||||
"flex h-full min-h-48 w-full flex-col items-center justify-center gap-2 rounded-2xl border border-dashed p-5",
|
||||
"text-muted-foreground transition-colors",
|
||||
"hover:border-border hover:text-foreground hover:bg-muted/20",
|
||||
isDragOver
|
||||
@@ -186,7 +179,7 @@ export function PersonaGallery({
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,6 @@ function renderDetail(
|
||||
persona,
|
||||
onBack: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
onReveal: vi.fn(),
|
||||
onStartChat: vi.fn(),
|
||||
onCopyFile: vi.fn(),
|
||||
onSaveCopy: vi.fn(),
|
||||
@@ -40,29 +39,30 @@ function renderDetail(
|
||||
}
|
||||
|
||||
describe("AgentDetailPage", () => {
|
||||
it("shows the skills-style action rail for file-backed agents", async () => {
|
||||
it("shows labeled primary actions for file-backed agents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const persona = makePersona();
|
||||
const props = renderDetail(persona);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
await user.click(screen.getByRole("button", { name: "Start a chat" }));
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
await user.click(screen.getByRole("button", { name: "Show in folder" }));
|
||||
|
||||
expect(props.onStartChat).toHaveBeenCalledWith(persona);
|
||||
expect(props.onEdit).toHaveBeenCalledWith(persona);
|
||||
expect(props.onReveal).toHaveBeenCalledWith(persona);
|
||||
});
|
||||
|
||||
it("keeps file sharing actions in the overflow menu", () => {
|
||||
it("keeps file actions in the share menu", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Share" }));
|
||||
|
||||
expect(screen.getByRole("button", { name: "More" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Copy file" }),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("menuitem", { name: "Copy file to clipboard" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Save a copy..." }),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("menuitem", { name: "Export a copy" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,12 +22,7 @@ describe("PersonaCard", () => {
|
||||
expect(screen.getByText("Coder")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows featured badge for built-in personas", () => {
|
||||
render(<PersonaCard persona={makePersona({ isBuiltin: true })} />);
|
||||
expect(screen.getByText("Featured")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show featured badge for custom personas", () => {
|
||||
it("does not show a provenance badge", () => {
|
||||
render(<PersonaCard persona={makePersona({ isBuiltin: false })} />);
|
||||
expect(screen.queryByText("Featured")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -76,7 +71,8 @@ describe("PersonaCard", () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PersonaCard
|
||||
persona={makePersona()}
|
||||
persona={makePersona({ sourcePath: "/tmp/code-review.md" })}
|
||||
onStartChat={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDuplicate={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
@@ -85,7 +81,13 @@ describe("PersonaCard", () => {
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /agent options/i }));
|
||||
expect(screen.getByRole("menu")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /start a chat/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("menuitem", { name: /edit/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /share/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /duplicate/i }),
|
||||
).toBeInTheDocument();
|
||||
@@ -94,7 +96,7 @@ describe("PersonaCard", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("delete is disabled for built-in personas", async () => {
|
||||
it("shows delete for imported seeded personas", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PersonaCard
|
||||
@@ -104,8 +106,9 @@ describe("PersonaCard", () => {
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /agent options/i }));
|
||||
const deleteBtn = screen.queryByRole("menuitem", { name: /delete/i });
|
||||
expect(deleteBtn).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: /delete/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not trigger selection when keyboard opens the options menu", async () => {
|
||||
|
||||
@@ -167,21 +167,21 @@ export async function updateSkill(
|
||||
|
||||
export async function exportSkill(
|
||||
path: string,
|
||||
): Promise<{ json: string; filename: string }> {
|
||||
): Promise<{ contents: string; filename: string }> {
|
||||
const client = await getClient();
|
||||
const response = await client.goose.GooseSourcesExport({
|
||||
type: SKILL_SOURCE_TYPE,
|
||||
path,
|
||||
});
|
||||
return { json: response.json, filename: response.filename };
|
||||
return { contents: response.json, filename: response.filename };
|
||||
}
|
||||
|
||||
export async function importSkills(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
): Promise<SkillInfo[]> {
|
||||
if (!fileName.endsWith(".skill.json") && !fileName.endsWith(".json")) {
|
||||
throw new Error("File must have a .skill.json or .json extension");
|
||||
if (!fileName.toLowerCase().endsWith(".md")) {
|
||||
throw new Error("File must have a .md extension");
|
||||
}
|
||||
|
||||
const data = new TextDecoder().decode(new Uint8Array(fileBytes));
|
||||
|
||||
@@ -155,15 +155,3 @@ export function groupSkills(
|
||||
...projectSections,
|
||||
];
|
||||
}
|
||||
|
||||
export function downloadExport(json: string, filename: string) {
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconDots,
|
||||
IconCopy,
|
||||
IconDeviceFloppy,
|
||||
IconFolderOpen,
|
||||
IconMessagePlus,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
Copy,
|
||||
CopyPlus,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Save,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { MessageResponse } from "@/shared/ui/ai-elements/message";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DetailField } from "@/shared/ui/detail-field";
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { PageColumns } from "@/shared/ui/page-columns";
|
||||
import { DetailPageShell, PageHeader } from "@/shared/ui/page-shell";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import type { SkillInfo } from "../api/skills";
|
||||
import type { SkillViewInfo } from "../lib/skillCategories";
|
||||
|
||||
@@ -28,10 +28,10 @@ interface SkillDetailPageProps {
|
||||
skill: SkillViewInfo | null;
|
||||
onBack: () => void;
|
||||
onEdit: (skill: SkillInfo) => void;
|
||||
onReveal: (skill: SkillInfo) => void;
|
||||
onCopyFile: (skill: SkillInfo) => void;
|
||||
onSaveCopy: (skill: SkillInfo) => void;
|
||||
onStartChat?: (skill: SkillInfo) => void;
|
||||
onDuplicate: (skill: SkillInfo) => void;
|
||||
onDelete: (skill: SkillInfo) => void;
|
||||
}
|
||||
|
||||
@@ -39,34 +39,24 @@ interface SkillHeaderActionButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
|
||||
function SkillHeaderActionButton({
|
||||
label,
|
||||
icon,
|
||||
type = "button",
|
||||
tooltipSide = "top",
|
||||
...props
|
||||
}: SkillHeaderActionButtonProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type={type}
|
||||
size="icon-xs"
|
||||
variant="outline-flat"
|
||||
aria-label={label}
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={tooltipSide} align="center" sideOffset={8}>
|
||||
<p>{label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type={type}
|
||||
size="xs"
|
||||
variant="outline-flat"
|
||||
leftIcon={icon}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,10 +64,10 @@ export function SkillDetailPage({
|
||||
skill,
|
||||
onBack,
|
||||
onEdit,
|
||||
onReveal,
|
||||
onCopyFile,
|
||||
onSaveCopy,
|
||||
onStartChat,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: SkillDetailPageProps) {
|
||||
const { t } = useTranslation(["skills", "common"]);
|
||||
@@ -99,7 +89,7 @@ export function SkillDetailPage({
|
||||
: [skill.sourceLabel];
|
||||
const startChatLabel = t("view.startChatShort");
|
||||
const editLabel = t("common:actions.edit");
|
||||
const revealLabel = t("view.reveal");
|
||||
const shareLabel = t("view.share");
|
||||
const moreLabel = t("view.more");
|
||||
|
||||
return (
|
||||
@@ -126,23 +116,37 @@ export function SkillDetailPage({
|
||||
{onStartChat ? (
|
||||
<SkillHeaderActionButton
|
||||
label={startChatLabel}
|
||||
icon={<IconMessagePlus className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
icon={<MessageSquarePlus aria-hidden="true" />}
|
||||
onClick={() => onStartChat(skill)}
|
||||
/>
|
||||
) : null}
|
||||
<SkillHeaderActionButton
|
||||
label={editLabel}
|
||||
icon={<IconPencil className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
icon={<Pencil aria-hidden="true" />}
|
||||
onClick={() => onEdit(skill)}
|
||||
/>
|
||||
<SkillHeaderActionButton
|
||||
label={revealLabel}
|
||||
icon={<IconFolderOpen className="size-3.5" />}
|
||||
tooltipSide="top"
|
||||
onClick={() => onReveal(skill)}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
size="xs"
|
||||
variant="outline-flat"
|
||||
leftIcon={<Share2 aria-hidden="true" />}
|
||||
>
|
||||
{shareLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile(skill)}>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy(skill)}>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -151,24 +155,20 @@ export function SkillDetailPage({
|
||||
variant="outline-flat"
|
||||
aria-label={moreLabel}
|
||||
>
|
||||
<IconDots className="size-3.5" />
|
||||
<MoreVertical className="size-3.5" />
|
||||
<span className="sr-only">{moreLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8}>
|
||||
<DropdownMenuItem onSelect={() => onCopyFile(skill)}>
|
||||
<IconCopy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => onSaveCopy(skill)}>
|
||||
<IconDeviceFloppy className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
<DropdownMenuItem onSelect={() => onDuplicate(skill)}>
|
||||
<CopyPlus className="size-3.5" />
|
||||
{t("common:actions.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(skill)}
|
||||
>
|
||||
<IconTrash className="size-3.5" />
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Copy,
|
||||
CopyPlus,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Save,
|
||||
Share2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
@@ -6,8 +16,15 @@ import {
|
||||
AccordionSectionTrigger,
|
||||
} from "@/shared/ui/accordion";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { IconMessagePlus } from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import type { SkillViewInfo } from "../lib/skillCategories";
|
||||
import type { SkillsSection } from "../lib/skillsHelpers";
|
||||
|
||||
@@ -17,6 +34,11 @@ interface SkillsListSectionsProps {
|
||||
onExpandedSectionIdsChange: (ids: string[]) => void;
|
||||
onSelectSkill: (skill: SkillViewInfo) => void;
|
||||
onStartChat?: (skill: SkillViewInfo) => void;
|
||||
onEdit: (skill: SkillViewInfo) => void;
|
||||
onCopyFile: (skill: SkillViewInfo) => void;
|
||||
onSaveCopy: (skill: SkillViewInfo) => void;
|
||||
onDuplicate: (skill: SkillViewInfo) => void;
|
||||
onDelete: (skill: SkillViewInfo) => void;
|
||||
}
|
||||
|
||||
export function SkillsListSections({
|
||||
@@ -25,8 +47,13 @@ export function SkillsListSections({
|
||||
onExpandedSectionIdsChange,
|
||||
onSelectSkill,
|
||||
onStartChat,
|
||||
onEdit,
|
||||
onCopyFile,
|
||||
onSaveCopy,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}: SkillsListSectionsProps) {
|
||||
const { t } = useTranslation(["skills"]);
|
||||
const { t } = useTranslation(["skills", "common"]);
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
@@ -55,7 +82,7 @@ export function SkillsListSections({
|
||||
{section.skills.map((skill) => (
|
||||
<div
|
||||
key={`${section.id}-${skill.id}`}
|
||||
className="group relative flex items-center gap-3 px-5 py-4 transition-colors hover:bg-muted/20"
|
||||
className="group relative px-5 py-4 transition-colors hover:bg-muted/20"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -63,8 +90,8 @@ export function SkillsListSections({
|
||||
onClick={() => onSelectSkill(skill)}
|
||||
aria-label={t("view.openDetails", { name: skill.name })}
|
||||
/>
|
||||
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
|
||||
<p className="text-sm font-normal text-foreground">
|
||||
<div className="pointer-events-none relative z-10 min-w-0">
|
||||
<p className="pr-10 text-sm font-normal text-foreground">
|
||||
{skill.name}
|
||||
</p>
|
||||
{skill.description ? (
|
||||
@@ -73,34 +100,65 @@ export function SkillsListSections({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onStartChat ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline-flat"
|
||||
size="icon-xs"
|
||||
className="relative z-20 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={() => onStartChat(skill)}
|
||||
aria-label={t("view.startChat", {
|
||||
name: skill.name,
|
||||
})}
|
||||
>
|
||||
<IconMessagePlus className="size-3.5" />
|
||||
<span className="sr-only">
|
||||
{t("view.startChatShort")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
sideOffset={8}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute top-3 right-4 z-20 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 data-[state=open]:opacity-100"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={t("view.optionsAria", {
|
||||
name: skill.name,
|
||||
})}
|
||||
>
|
||||
<p>{t("view.startChatShort")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={4}>
|
||||
{onStartChat ? (
|
||||
<DropdownMenuItem onSelect={() => onStartChat(skill)}>
|
||||
<MessageSquarePlus className="size-3.5" />
|
||||
{t("view.startChatShort")}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem onSelect={() => onEdit(skill)}>
|
||||
<Pencil className="size-3.5" />
|
||||
{t("common:actions.edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Share2 className="size-3.5" />
|
||||
{t("view.share")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onCopyFile(skill)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
{t("view.copyFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onSaveCopy(skill)}
|
||||
>
|
||||
<Save className="size-3.5" />
|
||||
{t("view.saveCopy")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem onSelect={() => onDuplicate(skill)}>
|
||||
<CopyPlus className="size-3.5" />
|
||||
{t("common:actions.duplicate")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={() => onDelete(skill)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t("common:actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { toast } from "sonner";
|
||||
import { useProjectStore } from "@/features/projects/stores/projectStore";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
|
||||
import { revealInFileManager } from "@/shared/lib/fileManager";
|
||||
import { useSkillImportExport } from "../hooks/useSkillImportExport";
|
||||
import { SkillDetailPage } from "./SkillDetailPage";
|
||||
import { SkillsDialogs } from "./SkillsDialogs";
|
||||
@@ -15,11 +14,13 @@ import { SkillsToolbar } from "./SkillsToolbar";
|
||||
import { hydrateProjectNames } from "../lib/projectHydration";
|
||||
import {
|
||||
filterSkills,
|
||||
formatSkillName,
|
||||
groupSkills,
|
||||
uniqueProjectFilters,
|
||||
type SkillsFilter,
|
||||
} from "../lib/skillsHelpers";
|
||||
import {
|
||||
createSkill,
|
||||
deleteSkill,
|
||||
listSkills,
|
||||
type EditingSkill,
|
||||
@@ -32,6 +33,25 @@ import {
|
||||
type SkillViewInfo,
|
||||
} from "../lib/skillCategories";
|
||||
|
||||
function getDuplicateSkillName(name: string, existingNames: Set<string>) {
|
||||
const baseName = formatSkillName(`${name}-copy`) || "skill-copy";
|
||||
if (!existingNames.has(baseName)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
for (let index = 2; index < 1000; index += 1) {
|
||||
const suffix = `-${index}`;
|
||||
const prefix =
|
||||
baseName.slice(0, 64 - suffix.length).replace(/-+$/g, "") || "skill";
|
||||
const candidate = `${prefix}${suffix}`;
|
||||
if (!existingNames.has(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return `skill-copy-${Date.now().toString().slice(-8)}`;
|
||||
}
|
||||
|
||||
interface SkillsViewProps {
|
||||
onStartChatWithSkill?: (skill: SkillInfo, projectId?: string | null) => void;
|
||||
}
|
||||
@@ -73,7 +93,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
return nextSkills;
|
||||
} catch {
|
||||
if (loadRequestIdRef.current === requestId) {
|
||||
setSkills([]);
|
||||
toast.error(t("view.loadError"));
|
||||
}
|
||||
return [];
|
||||
@@ -172,9 +191,23 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleReveal = useCallback((skill: SkillInfo) => {
|
||||
void revealInFileManager(skill.path);
|
||||
}, []);
|
||||
const handleDuplicate = useCallback(
|
||||
async (skill: SkillInfo) => {
|
||||
const duplicateName = getDuplicateSkillName(
|
||||
skill.name,
|
||||
new Set(skills.map((currentSkill) => currentSkill.name)),
|
||||
);
|
||||
|
||||
try {
|
||||
await createSkill(duplicateName, skill.description, skill.instructions);
|
||||
await loadSkills();
|
||||
toast.success(t("view.duplicated", { name: duplicateName }));
|
||||
} catch {
|
||||
toast.error(t("view.duplicateError"));
|
||||
}
|
||||
},
|
||||
[loadSkills, skills, t],
|
||||
);
|
||||
|
||||
const handleStartChat = useCallback(
|
||||
(skill: SkillInfo) => {
|
||||
@@ -243,10 +276,10 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
skill={activeSkill}
|
||||
onBack={() => setActiveSkillId(null)}
|
||||
onEdit={handleEdit}
|
||||
onReveal={handleReveal}
|
||||
onCopyFile={handleCopyFile}
|
||||
onSaveCopy={handleSaveCopy}
|
||||
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{dialogs}
|
||||
@@ -297,13 +330,18 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
isDragOver={isDragOver}
|
||||
/>
|
||||
|
||||
{!loading && filteredSkills.length > 0 ? (
|
||||
{filteredSkills.length > 0 ? (
|
||||
<SkillsListSections
|
||||
sections={groupedSkills}
|
||||
expandedSectionIds={expandedSectionIds}
|
||||
onExpandedSectionIdsChange={setExpandedSectionIds}
|
||||
onSelectSkill={handleSelectSkill}
|
||||
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
|
||||
onEdit={handleEdit}
|
||||
onCopyFile={handleCopyFile}
|
||||
onSaveCopy={handleSaveCopy}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -320,7 +358,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".skill.json,.json"
|
||||
accept=".md,text/markdown,text/plain"
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -75,9 +75,10 @@ vi.mock("../../api/skills", () => ({
|
||||
projectLinks: [],
|
||||
}),
|
||||
deleteSkill: vi.fn().mockResolvedValue(undefined),
|
||||
exportSkill: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ json: "{}", filename: "test.skill.json" }),
|
||||
exportSkill: vi.fn().mockResolvedValue({
|
||||
contents: "---\nname: test\n---\n",
|
||||
filename: "SKILL.md",
|
||||
}),
|
||||
importSkills: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
@@ -190,6 +191,52 @@ describe("SkillsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing skills visible while refreshing", async () => {
|
||||
const secondLoad = createDeferred<typeof mockSkills>();
|
||||
listSkills
|
||||
.mockResolvedValueOnce(mockSkills)
|
||||
.mockReturnValueOnce(secondLoad.promise);
|
||||
const { rerender } = render(<SkillsView />);
|
||||
|
||||
await screen.findByText("code-review");
|
||||
|
||||
mockProjects = [
|
||||
{
|
||||
id: "project-beta",
|
||||
name: "beta",
|
||||
workingDirs: ["/tmp/beta"],
|
||||
},
|
||||
];
|
||||
rerender(<SkillsView />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listSkills).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(screen.getByText("code-review")).toBeInTheDocument();
|
||||
expect(screen.getByText("test-writer")).toBeInTheDocument();
|
||||
|
||||
secondLoad.resolve([
|
||||
{
|
||||
...mockSkills[2],
|
||||
id: "project:/tmp/beta/.goose/skills/beta-skill",
|
||||
name: "beta-skill",
|
||||
path: "/tmp/beta/.goose/skills/beta-skill",
|
||||
fileLocation: "/tmp/beta/.goose/skills/beta-skill/SKILL.md",
|
||||
sourceLabel: "beta",
|
||||
projectLinks: [
|
||||
{
|
||||
id: "/tmp/beta",
|
||||
name: "beta",
|
||||
workingDir: "/tmp/beta",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await screen.findByText("beta-skill");
|
||||
expect(screen.queryByText("code-review")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches saved project working directories with trailing separators", async () => {
|
||||
mockProjects = [
|
||||
{
|
||||
@@ -256,8 +303,9 @@ describe("SkillsView", () => {
|
||||
await screen.findByText("test-writer");
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Start chat with test-writer" }),
|
||||
screen.getByRole("button", { name: "Options for test-writer" }),
|
||||
);
|
||||
await user.click(screen.getByRole("menuitem", { name: "Start a chat" }));
|
||||
|
||||
expect(onStartChatWithSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "test-writer" }),
|
||||
@@ -276,7 +324,7 @@ describe("SkillsView", () => {
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Open code-review details" }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Start chat" }));
|
||||
await user.click(screen.getByRole("button", { name: "Start a chat" }));
|
||||
|
||||
expect(onStartChatWithSkill).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "code-review" }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { exportPersona, importPersonas, refreshPersonas } from "../agents";
|
||||
import { importPersonas, refreshPersonas } from "../agents";
|
||||
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
@@ -13,23 +13,6 @@ describe("agents API", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── exportPersona ────────────────────────────────────────────────────
|
||||
|
||||
it("exportPersona invokes correct Tauri command with ID", async () => {
|
||||
const mockResult = {
|
||||
json: '{"displayName":"Test"}',
|
||||
suggestedFilename: "test.json",
|
||||
};
|
||||
mockedInvoke.mockResolvedValue(mockResult);
|
||||
|
||||
const result = await exportPersona("persona-123");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("export_persona", {
|
||||
id: "persona-123",
|
||||
});
|
||||
expect(result).toEqual(mockResult);
|
||||
});
|
||||
|
||||
// ── importPersonas ───────────────────────────────────────────────────
|
||||
|
||||
it("importPersonas invokes correct Tauri command with bytes and filename", async () => {
|
||||
@@ -46,11 +29,11 @@ describe("agents API", () => {
|
||||
mockedInvoke.mockResolvedValue(mockPersonas);
|
||||
|
||||
const fileBytes = [0x7b, 0x7d]; // "{}"
|
||||
const result = await importPersonas(fileBytes, "personas.json");
|
||||
const result = await importPersonas(fileBytes, "agent.md");
|
||||
|
||||
expect(mockedInvoke).toHaveBeenCalledWith("import_personas", {
|
||||
fileBytes,
|
||||
fileName: "personas.json",
|
||||
fileName: "agent.md",
|
||||
});
|
||||
expect(result).toEqual(mockPersonas);
|
||||
});
|
||||
|
||||
@@ -30,15 +30,6 @@ export async function refreshPersonas(): Promise<Persona[]> {
|
||||
return invoke("refresh_personas");
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
json: string;
|
||||
suggestedFilename: string;
|
||||
}
|
||||
|
||||
export async function exportPersona(id: string): Promise<ExportResult> {
|
||||
return invoke("export_persona", { id });
|
||||
}
|
||||
|
||||
export async function importPersonas(
|
||||
fileBytes: number[],
|
||||
fileName: string,
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
},
|
||||
"card": {
|
||||
"ariaLabel": "Agent: {{name}}",
|
||||
"custom": "Custom",
|
||||
"featured": "Featured",
|
||||
"fileBacked": "File-backed",
|
||||
"options": "Agent options"
|
||||
},
|
||||
"config": {
|
||||
@@ -45,8 +42,6 @@
|
||||
"newTitle": "New Agent",
|
||||
"noModelsAvailable": "No models are available for this provider yet.",
|
||||
"provider": "Provider",
|
||||
"readOnlyBuiltIn": "Built-in agents are read-only. Duplicate to customize one.",
|
||||
"readOnlyFile": "This agent is loaded from a file. You can review it here, but editing is disabled.",
|
||||
"saveFailed": "Failed to save agent.",
|
||||
"savedModelUnavailable": "{{model}} (saved, unavailable)",
|
||||
"savedModelUnavailableHelp": "This agent uses a saved model that is not in the current provider inventory.",
|
||||
@@ -72,10 +67,9 @@
|
||||
"view": {
|
||||
"copyName": "{{name}} (Copy)",
|
||||
"backToAgents": "Back to agents",
|
||||
"copyFile": "Copy file",
|
||||
"copyFile": "Copy file to clipboard",
|
||||
"copyFileFailed": "Couldn't copy the file.",
|
||||
"copySaved": "Saved a copy to {{path}}",
|
||||
"created": "Created",
|
||||
"deleteFailed": "Failed to delete agent.",
|
||||
"deleteDescription": "This agent and its configuration will be permanently removed.",
|
||||
"deleteTitle": "Delete \"{{name}}\" permanently?",
|
||||
@@ -84,9 +78,8 @@
|
||||
"emptyAgentsDescription": "Create an agent to get started.",
|
||||
"emptyAgentsTitle": "No agents yet",
|
||||
"fileCopied": "File copied. Paste it into Slack, email, or another app.",
|
||||
"filePath": "File path",
|
||||
"importInvalidExtension": "Unsupported file type. Choose a .json file.",
|
||||
"importInvalidMimeType": "Unsupported file type. Choose a JSON file.",
|
||||
"importInvalidExtension": "Unsupported file type. Choose a .md file.",
|
||||
"importInvalidMimeType": "Unsupported file type. Choose a Markdown file.",
|
||||
"importFailed": "Failed to import agent.",
|
||||
"imported_one": "Imported {{count}} agent.",
|
||||
"imported_other": "Imported {{count}} agents.",
|
||||
@@ -94,13 +87,12 @@
|
||||
"newPersona": "New Agent",
|
||||
"optionsAria": "Options for {{name}}",
|
||||
"reveal": "Show in folder",
|
||||
"saveCopy": "Save a copy...",
|
||||
"saveCopy": "Export a copy",
|
||||
"saveCopyFailed": "Couldn't save a copy.",
|
||||
"share": "Share",
|
||||
"searchPlaceholder": "Search agents",
|
||||
"source": "Source",
|
||||
"startChat": "Start chat with {{name}}",
|
||||
"startChatShort": "Start chat",
|
||||
"title": "Agents",
|
||||
"updated": "Updated"
|
||||
"startChat": "Start a chat with {{name}}",
|
||||
"startChatShort": "Start a chat",
|
||||
"title": "Agents"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
"description": "Use skills to add specific instructions or behaviors to any agent.",
|
||||
"detailEmptyDescription": "Select a skill to inspect its source, location, and instructions.",
|
||||
"detailEmptyTitle": "Choose a skill",
|
||||
"copyFile": "Copy file",
|
||||
"copyFile": "Copy file to clipboard",
|
||||
"copyFileError": "Couldn't copy the file.",
|
||||
"copySaved": "Saved a copy to {{path}}",
|
||||
"duplicateError": "Couldn't duplicate the skill.",
|
||||
"duplicated": "Duplicated as \"{{name}}\"",
|
||||
"dropFile": "or drop a file",
|
||||
"emptyDescription": "Create a skill or import one to get started.",
|
||||
"emptyTitle": "No skills yet",
|
||||
@@ -61,16 +63,18 @@
|
||||
"noMatchesDescription": "Try a different search term.",
|
||||
"noMatchesTitle": "No matching skills",
|
||||
"openDetails": "Open {{name}} details",
|
||||
"optionsAria": "Options for {{name}}",
|
||||
"projects": "Projects",
|
||||
"reveal": "Show in folder",
|
||||
"saveCopy": "Save a copy...",
|
||||
"saveCopy": "Export a copy",
|
||||
"saveCopyError": "Couldn't save a copy.",
|
||||
"searchPlaceholder": "Search skills",
|
||||
"share": "Share",
|
||||
"skillCount_one": "{{displayCount}} skill",
|
||||
"skillCount_other": "{{displayCount}} skills",
|
||||
"source": "Source",
|
||||
"startChat": "Start chat with {{name}}",
|
||||
"startChatShort": "Start chat",
|
||||
"startChat": "Start a chat with {{name}}",
|
||||
"startChatShort": "Start a chat",
|
||||
"title": "Skills",
|
||||
"useInChat": "Use in chat"
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
},
|
||||
"card": {
|
||||
"ariaLabel": "Agente: {{name}}",
|
||||
"custom": "Personalizado",
|
||||
"featured": "Destacado",
|
||||
"fileBacked": "Desde archivo",
|
||||
"options": "Opciones del agente"
|
||||
},
|
||||
"config": {
|
||||
@@ -45,8 +42,6 @@
|
||||
"newTitle": "Nuevo agente",
|
||||
"noModelsAvailable": "Todavía no hay modelos disponibles para este proveedor.",
|
||||
"provider": "Proveedor",
|
||||
"readOnlyBuiltIn": "Los agentes integrados son de solo lectura. Duplícalo para personalizarlo.",
|
||||
"readOnlyFile": "Este agente se cargó desde un archivo. Puedes revisarlo aquí, pero la edición está deshabilitada.",
|
||||
"saveFailed": "No se pudo guardar el agente.",
|
||||
"savedModelUnavailable": "{{model}} (guardado, no disponible)",
|
||||
"savedModelUnavailableHelp": "Este agente usa un modelo guardado que no está en el inventario actual del proveedor.",
|
||||
@@ -72,10 +67,9 @@
|
||||
"view": {
|
||||
"copyName": "{{name}} (Copia)",
|
||||
"backToAgents": "Volver a agentes",
|
||||
"copyFile": "Copiar archivo",
|
||||
"copyFile": "Copiar archivo al portapapeles",
|
||||
"copyFileFailed": "No se pudo copiar el archivo.",
|
||||
"copySaved": "Se guardó una copia en {{path}}",
|
||||
"created": "Creado",
|
||||
"deleteFailed": "No se pudo eliminar el agente.",
|
||||
"deleteDescription": "Este agente y su configuración se eliminarán de forma permanente.",
|
||||
"deleteTitle": "¿Eliminar \"{{name}}\" de forma permanente?",
|
||||
@@ -84,9 +78,8 @@
|
||||
"emptyAgentsDescription": "Crea un agente para empezar.",
|
||||
"emptyAgentsTitle": "Aún no hay agentes",
|
||||
"fileCopied": "Archivo copiado. Pégalo en Slack, correo u otra app.",
|
||||
"filePath": "Ruta del archivo",
|
||||
"importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .json.",
|
||||
"importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo JSON.",
|
||||
"importInvalidExtension": "Tipo de archivo no compatible. Elige un archivo .md.",
|
||||
"importInvalidMimeType": "Tipo de archivo no compatible. Elige un archivo Markdown.",
|
||||
"importFailed": "No se pudo importar el agente.",
|
||||
"imported_one": "Se importó {{count}} agente.",
|
||||
"imported_other": "Se importaron {{count}} agentes.",
|
||||
@@ -94,13 +87,12 @@
|
||||
"newPersona": "Nuevo agente",
|
||||
"optionsAria": "Opciones de {{name}}",
|
||||
"reveal": "Mostrar en carpeta",
|
||||
"saveCopy": "Guardar una copia...",
|
||||
"saveCopy": "Exportar una copia",
|
||||
"saveCopyFailed": "No se pudo guardar una copia.",
|
||||
"share": "Compartir",
|
||||
"searchPlaceholder": "Buscar agentes",
|
||||
"source": "Origen",
|
||||
"startChat": "Iniciar chat con {{name}}",
|
||||
"startChatShort": "Iniciar chat",
|
||||
"title": "Agentes",
|
||||
"updated": "Actualizado"
|
||||
"startChat": "Iniciar un chat con {{name}}",
|
||||
"startChatShort": "Iniciar un chat",
|
||||
"title": "Agentes"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,9 +41,11 @@
|
||||
"description": "Las skills son instrucciones reutilizables que ayudan a un agente a manejar tareas, flujos de trabajo o herramientas específicas.",
|
||||
"detailEmptyDescription": "Selecciona una skill para inspeccionar su origen, ubicación e instrucciones.",
|
||||
"detailEmptyTitle": "Elige una skill",
|
||||
"copyFile": "Copiar archivo",
|
||||
"copyFile": "Copiar archivo al portapapeles",
|
||||
"copyFileError": "No se pudo copiar el archivo.",
|
||||
"copySaved": "Se guardó una copia en {{path}}",
|
||||
"duplicateError": "No se pudo duplicar la skill.",
|
||||
"duplicated": "Duplicada como \"{{name}}\"",
|
||||
"dropFile": "o suelta un archivo",
|
||||
"emptyDescription": "Crea una skill o importa una para empezar.",
|
||||
"emptyTitle": "Aún no hay skills",
|
||||
@@ -61,16 +63,18 @@
|
||||
"noMatchesDescription": "Prueba con otra búsqueda o filtro.",
|
||||
"noMatchesTitle": "No hay skills que coincidan",
|
||||
"openDetails": "Abrir detalles de {{name}}",
|
||||
"optionsAria": "Opciones de {{name}}",
|
||||
"projects": "Proyectos",
|
||||
"reveal": "Mostrar en carpeta",
|
||||
"saveCopy": "Guardar una copia...",
|
||||
"saveCopy": "Exportar una copia",
|
||||
"saveCopyError": "No se pudo guardar una copia.",
|
||||
"searchPlaceholder": "Buscar skills",
|
||||
"share": "Compartir",
|
||||
"skillCount_one": "{{displayCount}} skill",
|
||||
"skillCount_other": "{{displayCount}} skills",
|
||||
"source": "Origen",
|
||||
"startChat": "Iniciar chat con {{name}}",
|
||||
"startChatShort": "Iniciar chat",
|
||||
"startChat": "Iniciar un chat con {{name}}",
|
||||
"startChatShort": "Iniciar un chat",
|
||||
"title": "Skills",
|
||||
"useInChat": "Usar en chat"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./dropdown-menu";
|
||||
|
||||
function SiblingMenus() {
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>More</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Duplicate</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>Share</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem>Copy file to clipboard</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DropdownMenu", () => {
|
||||
it("closes an open sibling menu when another menu opens", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SiblingMenus />);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "More" }));
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: "Duplicate" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Share" }));
|
||||
|
||||
expect(
|
||||
screen.getByRole("menuitem", { name: "Copy file to clipboard" }),
|
||||
).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole("menuitem", { name: "Duplicate" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the same icon treatment for submenu triggers as menu items", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger>Actions</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<svg aria-hidden="true" />
|
||||
Share
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>Copy file to clipboard</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Actions" }));
|
||||
|
||||
const shareItem = screen.getByRole("menuitem", { name: "Share" });
|
||||
expect(shareItem).toHaveClass("gap-2");
|
||||
expect(shareItem.className).toContain("text-muted-foreground");
|
||||
expect(shareItem.className).toContain("[&_svg]:pointer-events-none");
|
||||
expect(shareItem.className).toContain(
|
||||
"[&_svg:not([class*='size-'])]:size-4",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,73 @@
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const DROPDOWN_MENU_OPEN_EVENT = "goose:dropdown-menu-open";
|
||||
|
||||
function DropdownMenu({
|
||||
open: controlledOpen,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
closeOnSiblingOpen = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root> & {
|
||||
closeOnSiblingOpen?: boolean;
|
||||
}) {
|
||||
const menuId = React.useId();
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
|
||||
const open = controlledOpen ?? uncontrolledOpen;
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (controlledOpen === undefined) {
|
||||
setUncontrolledOpen(nextOpen);
|
||||
}
|
||||
onOpenChange?.(nextOpen);
|
||||
|
||||
if (nextOpen && closeOnSiblingOpen && typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DROPDOWN_MENU_OPEN_EVENT, {
|
||||
detail: { id: menuId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[closeOnSiblingOpen, controlledOpen, menuId, onOpenChange],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!closeOnSiblingOpen || !open || typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleSiblingOpen = (event: Event) => {
|
||||
const siblingMenuId = (event as CustomEvent<{ id?: string }>).detail?.id;
|
||||
if (siblingMenuId === menuId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (controlledOpen === undefined) {
|
||||
setUncontrolledOpen(false);
|
||||
}
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
window.addEventListener(DROPDOWN_MENU_OPEN_EVENT, handleSiblingOpen);
|
||||
return () => {
|
||||
window.removeEventListener(DROPDOWN_MENU_OPEN_EVENT, handleSiblingOpen);
|
||||
};
|
||||
}, [closeOnSiblingOpen, controlledOpen, menuId, onOpenChange, open]);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
@@ -206,7 +266,7 @@ function DropdownMenuSubTrigger({
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-muted focus:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
"focus:bg-muted focus:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -231,8 +231,8 @@ export function buildInitScript(options?: {
|
||||
const path = message.params?.path ?? "/mock/.agents/skills/skill";
|
||||
const name = String(path).split("/").filter(Boolean).at(-1) ?? "skill";
|
||||
return jsonRpcResult(message.id, {
|
||||
json: "{}",
|
||||
filename: name + ".skill.json",
|
||||
json: "---\\nname: " + name + "\\ndescription: Mock skill\\n---\\n\\nMock instructions\\n",
|
||||
filename: "SKILL.md",
|
||||
});
|
||||
}
|
||||
case "_goose/sources/import":
|
||||
@@ -315,11 +315,6 @@ export function buildInitScript(options?: {
|
||||
});
|
||||
case "delete_persona":
|
||||
return Promise.resolve(null);
|
||||
case "export_persona":
|
||||
return Promise.resolve({
|
||||
json: "{}",
|
||||
suggestedFilename: "persona.json",
|
||||
});
|
||||
case "import_personas":
|
||||
return Promise.resolve(PERSONAS);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user