From b35eaf4bf92a35ae592de86b3ba673996f62867a Mon Sep 17 00:00:00 2001 From: morgmart <98432065+morgmart@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:58:17 -0700 Subject: [PATCH] redesign skills library (#8868) Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> --- crates/goose/src/skills/mod.rs | 6 +- crates/goose/src/sources.rs | 65 +- ui/goose2/scripts/check-file-sizes.mjs | 5 + .../src/features/agents/ui/PersonaDetails.tsx | 59 +- .../src/features/skills/api/skills.test.ts | 159 ++++ ui/goose2/src/features/skills/api/skills.ts | 115 ++- .../features/skills/lib/projectHydration.ts | 63 ++ .../features/skills/lib/skillCategories.ts | 286 ++++++++ .../src/features/skills/lib/skillsHelpers.ts | 42 ++ .../features/skills/ui/CreateSkillDialog.tsx | 4 +- .../features/skills/ui/SkillDetailPage.tsx | 240 +++++++ .../src/features/skills/ui/SkillsDialogs.tsx | 86 +++ .../features/skills/ui/SkillsEmptyState.tsx | 66 ++ .../features/skills/ui/SkillsListSections.tsx | 102 +++ .../src/features/skills/ui/SkillsView.tsx | 679 +++++++++++------- .../skills/ui/__tests__/SkillsView.test.tsx | 517 ++++++++----- .../src/shared/i18n/locales/en/skills.json | 48 +- .../src/shared/i18n/locales/es/skills.json | 48 +- ui/goose2/src/shared/styles/globals.css | 39 + ui/goose2/src/shared/ui/MainPanelLayout.tsx | 11 +- ui/goose2/src/shared/ui/SearchBar.tsx | 39 +- ui/goose2/src/shared/ui/accordion.tsx | 64 +- ui/goose2/src/shared/ui/button.test.tsx | 65 ++ ui/goose2/src/shared/ui/button.tsx | 117 ++- ui/goose2/src/shared/ui/detail-field.tsx | 52 ++ ui/goose2/src/shared/ui/input.tsx | 10 +- ui/goose2/src/shared/ui/page-columns.test.tsx | 41 ++ ui/goose2/src/shared/ui/page-columns.tsx | 100 +++ ui/goose2/src/shared/ui/page-shell.tsx | 146 ++++ ui/goose2/src/shared/ui/resizable.tsx | 37 +- ui/goose2/tests/e2e/fixtures/mock-data.ts | 12 +- ui/goose2/tests/e2e/fixtures/tauri-mock.ts | 12 +- ui/goose2/tests/e2e/skills.spec.ts | 311 +++----- 33 files changed, 2879 insertions(+), 767 deletions(-) create mode 100644 ui/goose2/src/features/skills/api/skills.test.ts create mode 100644 ui/goose2/src/features/skills/lib/projectHydration.ts create mode 100644 ui/goose2/src/features/skills/lib/skillCategories.ts create mode 100644 ui/goose2/src/features/skills/lib/skillsHelpers.ts create mode 100644 ui/goose2/src/features/skills/ui/SkillDetailPage.tsx create mode 100644 ui/goose2/src/features/skills/ui/SkillsDialogs.tsx create mode 100644 ui/goose2/src/features/skills/ui/SkillsEmptyState.tsx create mode 100644 ui/goose2/src/features/skills/ui/SkillsListSections.tsx create mode 100644 ui/goose2/src/shared/ui/button.test.tsx create mode 100644 ui/goose2/src/shared/ui/detail-field.tsx create mode 100644 ui/goose2/src/shared/ui/page-columns.test.tsx create mode 100644 ui/goose2/src/shared/ui/page-columns.tsx create mode 100644 ui/goose2/src/shared/ui/page-shell.tsx diff --git a/crates/goose/src/skills/mod.rs b/crates/goose/src/skills/mod.rs index 60f09d20b6..b337b4e4cb 100644 --- a/crates/goose/src/skills/mod.rs +++ b/crates/goose/src/skills/mod.rs @@ -30,9 +30,9 @@ pub fn global_skills_dir() -> Option { } /// Canonical writable location for project-scoped skills: -/// `/.goose/skills`. +/// `/.agents/skills`. pub fn project_skills_dir(project_dir: &Path) -> PathBuf { - project_dir.join(".goose").join("skills") + project_dir.join(".agents").join("skills") } pub(crate) fn skills_dir_global_or_err() -> Result { @@ -196,9 +196,9 @@ pub fn all_skill_dirs(working_dir: Option<&Path>) -> Vec<(PathBuf, bool)> { let mut dirs: Vec<(PathBuf, bool)> = Vec::new(); if let Some(wd) = working_dir { + dirs.push((wd.join(".agents").join("skills"), false)); dirs.push((wd.join(".goose").join("skills"), false)); dirs.push((wd.join(".claude").join("skills"), false)); - dirs.push((wd.join(".agents").join("skills"), false)); } let home = dirs::home_dir(); diff --git a/crates/goose/src/sources.rs b/crates/goose/src/sources.rs index f6d9c27eeb..9a353803af 100644 --- a/crates/goose/src/sources.rs +++ b/crates/goose/src/sources.rs @@ -377,7 +377,7 @@ mod tests { ) .unwrap(); - let portable_dir = project_a.join(".goose").join("skills").join("portable"); + let portable_dir = project_a.join(".agents").join("skills").join("portable"); let (json, filename) = export_source(SourceType::Skill, portable_dir.to_str().unwrap()).unwrap(); assert_eq!(filename, "portable.skill.json"); @@ -538,7 +538,7 @@ mod tests { ) .unwrap(); - let skill_dir = tmp.path().join(".goose").join("skills").join("my-dir"); + let skill_dir = tmp.path().join(".agents").join("skills").join("my-dir"); let updated = update_source( SourceType::Skill, skill_dir.to_str().unwrap(), @@ -551,6 +551,67 @@ mod tests { assert_eq!(updated.name, "my-dir"); } + #[test] + fn list_sources_reads_project_agents_skills() { + let tmp = TempDir::new().unwrap(); + let skill_dir = tmp.path().join(".agents").join("skills").join("test-skill"); + std::fs::create_dir_all(&skill_dir).unwrap(); + std::fs::write( + skill_dir.join("SKILL.md"), + build_skill_md("test-skill", "from agents", "Body"), + ) + .unwrap(); + + let listed = + list_sources(Some(SourceType::Skill), Some(tmp.path().to_str().unwrap())).unwrap(); + let skill = listed + .iter() + .find(|source| source.name == "test-skill" && !source.global) + .unwrap(); + assert!(skill.directory.contains(".agents/skills")); + assert_eq!(skill.description, "from agents"); + } + + #[test] + fn project_sources_prefer_agents_directory_over_legacy_goose() { + let tmp = TempDir::new().unwrap(); + let agents_skill_dir = tmp + .path() + .join(".agents") + .join("skills") + .join("shared-skill"); + let legacy_skill_dir = tmp + .path() + .join(".goose") + .join("skills") + .join("shared-skill"); + std::fs::create_dir_all(&agents_skill_dir).unwrap(); + std::fs::create_dir_all(&legacy_skill_dir).unwrap(); + std::fs::write( + agents_skill_dir.join("SKILL.md"), + build_skill_md("shared-skill", "preferred", "Agents"), + ) + .unwrap(); + std::fs::write( + legacy_skill_dir.join("SKILL.md"), + build_skill_md("shared-skill", "legacy", "Goose"), + ) + .unwrap(); + + let listed = + list_sources(Some(SourceType::Skill), Some(tmp.path().to_str().unwrap())).unwrap(); + let matching: Vec<_> = listed + .iter() + .filter(|source| source.name == "shared-skill" && !source.global) + .collect(); + assert_eq!(matching.len(), 1); + assert!(matching[0].directory.contains(".agents/skills")); + assert_eq!(matching[0].description, "preferred"); + + let exported = export_source(SourceType::Skill, matching[0].directory.as_str()).unwrap(); + assert!(exported.0.contains("preferred")); + } + #[test] fn update_rejects_path_traversal() { let tmp = TempDir::new().unwrap(); diff --git a/ui/goose2/scripts/check-file-sizes.mjs b/ui/goose2/scripts/check-file-sizes.mjs index 1cda1e5664..bc6f75bcba 100644 --- a/ui/goose2/scripts/check-file-sizes.mjs +++ b/ui/goose2/scripts/check-file-sizes.mjs @@ -80,6 +80,11 @@ const EXCEPTIONS = { justification: "Bubble rendering still owns assistant identity, grouped tool output, attachments, and the inline actions tray pending a later extraction pass.", }, + "src/features/skills/ui/SkillsView.tsx": { + limit: 620, + justification: + "SkillsView currently centralizes list/detail state, project-aware skill hydration, category/source filtering, import/export flows, and detail-page action wiring pending a later decomposition.", + }, "src/features/chat/ui/__tests__/ChatInput.test.tsx": { limit: 570, justification: diff --git a/ui/goose2/src/features/agents/ui/PersonaDetails.tsx b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx index 6022c35f54..7c1556512b 100644 --- a/ui/goose2/src/features/agents/ui/PersonaDetails.tsx +++ b/ui/goose2/src/features/agents/ui/PersonaDetails.tsx @@ -4,6 +4,7 @@ import { AvatarFallback, 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"; @@ -46,14 +47,12 @@ export function PersonaDetails({
-
-

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

-

- {displayName} -

-
+ + {displayName} +
{personaSource === "builtin" ? ( @@ -69,33 +68,35 @@ export function PersonaDetails({
-
-

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

-

+

+ {providerLabel} -

+
-
-

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

-

{modelLabel}

+
+ + {modelLabel} +
-
-

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

- - {t("common:labels.characterCount", { - count: systemPrompt.length, - })} - -
+ + {t("common:labels.characterCount", { + count: systemPrompt.length, + })} + + } + />
{systemPrompt} diff --git a/ui/goose2/src/features/skills/api/skills.test.ts b/ui/goose2/src/features/skills/api/skills.test.ts new file mode 100644 index 0000000000..eb29b13fa0 --- /dev/null +++ b/ui/goose2/src/features/skills/api/skills.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockGooseSourcesList = vi.fn(); + +vi.mock("@/shared/api/acpConnection", () => ({ + getClient: async () => ({ + goose: { + GooseSourcesList: (...args: unknown[]) => mockGooseSourcesList(...args), + }, + }), +})); + +describe("listSkills", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + }); + + it("aggregates project skill listings and recognizes .agents skill paths", async () => { + mockGooseSourcesList + .mockResolvedValueOnce({ + sources: [ + { + type: "skill", + name: "code-review", + description: "Reviews code", + content: "Review carefully", + directory: "/Users/test/.agents/skills/code-review", + global: true, + }, + ], + }) + .mockResolvedValueOnce({ + sources: [ + { + type: "skill", + name: "code-review", + description: "Reviews code", + content: "Review carefully", + directory: "/Users/test/.agents/skills/code-review", + global: true, + }, + { + type: "skill", + name: "test-writer", + description: "Writes tests", + content: "Write tests", + directory: "/tmp/alpha/.agents/skills/test-writer", + global: false, + }, + ], + }); + + const { listSkills } = await import("./skills"); + const skills = await listSkills(["/tmp/alpha", "/tmp/alpha"]); + + expect(mockGooseSourcesList).toHaveBeenNthCalledWith(1, { + type: "skill", + }); + expect(mockGooseSourcesList).toHaveBeenNthCalledWith(2, { + type: "skill", + projectDir: "/tmp/alpha", + }); + expect(skills.filter((skill) => skill.name === "code-review")).toHaveLength( + 1, + ); + expect(skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "test-writer", + sourceKind: "project", + sourceLabel: "alpha", + projectLinks: [ + { + id: "/tmp/alpha", + name: "alpha", + workingDir: "/tmp/alpha", + }, + ], + }), + ]), + ); + }); + + it("recognizes legacy .goose project skill paths", async () => { + mockGooseSourcesList + .mockResolvedValueOnce({ sources: [] }) + .mockResolvedValueOnce({ + sources: [ + { + type: "skill", + name: "legacy-writer", + description: "Legacy project skill", + content: "Legacy instructions", + directory: "/tmp/beta/.goose/skills/legacy-writer", + global: false, + }, + ], + }); + + const { listSkills } = await import("./skills"); + const skills = await listSkills(["/tmp/beta"]); + + expect(skills).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "legacy-writer", + sourceKind: "project", + sourceLabel: "beta", + projectLinks: [ + { + id: "/tmp/beta", + name: "beta", + workingDir: "/tmp/beta", + }, + ], + }), + ]), + ); + }); + + it("keeps available skills when a project skill listing fails", async () => { + mockGooseSourcesList + .mockResolvedValueOnce({ + sources: [ + { + type: "skill", + name: "code-review", + description: "Reviews code", + content: "Review carefully", + directory: "/Users/test/.agents/skills/code-review", + global: true, + }, + ], + }) + .mockRejectedValueOnce(new Error("permission denied")) + .mockResolvedValueOnce({ + sources: [ + { + type: "skill", + name: "test-writer", + description: "Writes tests", + content: "Write tests", + directory: "/tmp/beta/.agents/skills/test-writer", + global: false, + }, + ], + }); + + const { listSkills } = await import("./skills"); + const skills = await listSkills(["/tmp/alpha", "/tmp/beta"]); + + expect(mockGooseSourcesList).toHaveBeenCalledTimes(3); + expect(skills.map((skill) => skill.name)).toEqual([ + "code-review", + "test-writer", + ]); + }); +}); diff --git a/ui/goose2/src/features/skills/api/skills.ts b/ui/goose2/src/features/skills/api/skills.ts index 8fe1af7c99..73f524362c 100644 --- a/ui/goose2/src/features/skills/api/skills.ts +++ b/ui/goose2/src/features/skills/api/skills.ts @@ -2,13 +2,32 @@ import type { SourceEntry } from "@aaif/goose-sdk"; import { getClient } from "@/shared/api/acpConnection"; const SKILL_SOURCE_TYPE = "skill" as const; +const PROJECT_SKILLS_MARKERS = [ + "/.agents/skills/", + "/.goose/skills/", + "/.claude/skills/", +]; + +export interface SkillProjectLink { + id: string; + name: string; + workingDir: string; +} + +export type SkillSourceKind = "global" | "project"; export interface SkillInfo { + id: string; name: string; description: string; instructions: string; path: string; fileLocation: string; + directoryPath: string; + sourceKind: SkillSourceKind; + sourceLabel: string; + projectLinks: SkillProjectLink[]; + editable: boolean; } type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE }; @@ -17,6 +36,16 @@ function isSkillSource(source: SourceEntry): source is SkillSourceEntry { return source.type === SKILL_SOURCE_TYPE; } +function normalizePath(path: string): string { + return path.replace(/\\/g, "/"); +} + +function basename(path: string): string { + const trimmed = normalizePath(path).replace(/\/+$/, ""); + const idx = trimmed.lastIndexOf("/"); + return idx >= 0 ? trimmed.slice(idx + 1) : trimmed; +} + function getSkillFileLocation(directory: string): string { const separator = directory.includes("\\") ? "\\" : "/"; return directory.endsWith(separator) @@ -24,16 +53,56 @@ function getSkillFileLocation(directory: string): string { : `${directory}${separator}SKILL.md`; } +function deriveProjectRoot(directory: string): string | null { + const normalizedDirectory = normalizePath(directory); + + for (const marker of PROJECT_SKILLS_MARKERS) { + const idx = normalizedDirectory.lastIndexOf(marker); + if (idx >= 0) { + return directory.slice(0, idx); + } + } + + return null; +} + function toSkillInfo(source: SkillSourceEntry): SkillInfo { + const sourceKind: SkillSourceKind = source.global ? "global" : "project"; + const projectRoot = source.global + ? null + : deriveProjectRoot(source.directory); + const projectName = projectRoot ? basename(projectRoot) : ""; + + const projectLinks: SkillProjectLink[] = projectRoot + ? [ + { + id: projectRoot, + name: projectName || projectRoot, + workingDir: projectRoot, + }, + ] + : []; + return { + id: `${sourceKind}:${source.directory}`, name: source.name, description: source.description, instructions: source.content, path: source.directory, fileLocation: getSkillFileLocation(source.directory), + directoryPath: source.directory, + sourceKind, + sourceLabel: + sourceKind === "global" ? "Personal" : projectName || "Project", + projectLinks, + editable: true, }; } +function uniqueProjectDirs(projectDirs: string[]) { + return [...new Set(projectDirs.map((dir) => dir.trim()).filter(Boolean))]; +} + export async function createSkill( name: string, description: string, @@ -49,12 +118,50 @@ export async function createSkill( }); } -export async function listSkills(): Promise { +export async function listSkills( + projectDirs: string[] = [], +): Promise { const client = await getClient(); - const response = await client.goose.GooseSourcesList({ - type: SKILL_SOURCE_TYPE, + const fetchSources = (projectDir?: string) => + client.goose.GooseSourcesList({ + type: SKILL_SOURCE_TYPE, + ...(projectDir ? { projectDir } : {}), + }); + + const globalResponse = await fetchSources(); + const projectResponses = await Promise.allSettled( + uniqueProjectDirs(projectDirs).map((projectDir) => + fetchSources(projectDir), + ), + ); + const responses = [ + { response: globalResponse, projectResponse: false }, + ...projectResponses.flatMap((result) => + result.status === "fulfilled" + ? [{ response: result.value, projectResponse: true }] + : [], + ), + ]; + const seen = new Set(); + const skills: SkillInfo[] = []; + + responses.forEach(({ response, projectResponse }) => { + for (const source of response.sources) { + if (!isSkillSource(source) || (projectResponse && source.global)) { + continue; + } + + const key = `${source.global ? "global" : "project"}:${source.directory}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + skills.push(toSkillInfo(source)); + } }); - return response.sources.filter(isSkillSource).map(toSkillInfo); + + return skills; } export async function deleteSkill(path: string): Promise { diff --git a/ui/goose2/src/features/skills/lib/projectHydration.ts b/ui/goose2/src/features/skills/lib/projectHydration.ts new file mode 100644 index 0000000000..65e8dcea97 --- /dev/null +++ b/ui/goose2/src/features/skills/lib/projectHydration.ts @@ -0,0 +1,63 @@ +import type { ProjectInfo } from "@/features/projects/api/projects"; +import type { SkillInfo } from "../api/skills"; + +function normalizeWorkingDirKey(workingDir: string) { + const normalized = workingDir.trim().replace(/\\/g, "/"); + if (!normalized) { + return ""; + } + if (/^\/+$/.test(normalized)) { + return "/"; + } + if (/^[A-Za-z]:\/+$/.test(normalized)) { + return `${normalized.slice(0, 2)}/`; + } + + return normalized.replace(/\/+$/, ""); +} + +export function hydrateProjectNames( + skills: SkillInfo[], + projects: ProjectInfo[], +) { + const projectsByWorkingDir = new Map< + string, + Pick + >(); + + for (const project of projects) { + for (const workingDir of project.workingDirs) { + const normalizedDir = normalizeWorkingDirKey(workingDir); + if (!normalizedDir || projectsByWorkingDir.has(normalizedDir)) { + continue; + } + projectsByWorkingDir.set(normalizedDir, { + id: project.id, + name: project.name, + }); + } + } + + return skills.map((skill) => { + if (skill.sourceKind !== "project") { + return skill; + } + + const projectLinks = skill.projectLinks.map((project) => { + const savedProject = projectsByWorkingDir.get( + normalizeWorkingDirKey(project.workingDir), + ); + return { + ...project, + id: savedProject?.id ?? project.id, + name: savedProject?.name ?? project.name, + }; + }); + + return { + ...skill, + projectLinks, + sourceLabel: projectLinks[0]?.name ?? skill.sourceLabel, + }; + }); +} diff --git a/ui/goose2/src/features/skills/lib/skillCategories.ts b/ui/goose2/src/features/skills/lib/skillCategories.ts new file mode 100644 index 0000000000..8d7f9f4eaa --- /dev/null +++ b/ui/goose2/src/features/skills/lib/skillCategories.ts @@ -0,0 +1,286 @@ +import type { SkillInfo } from "../api/skills"; + +export const SKILL_CATEGORY_ORDER = [ + "design", + "engineering", + "quality", + "research", + "writing", + "integrations", + "operations", + "productivity", + "general", +] as const; + +export type SkillCategory = (typeof SKILL_CATEGORY_ORDER)[number]; + +export interface SkillViewInfo extends SkillInfo { + inferredCategory: SkillCategory; +} + +const DESIGN_SLUGS = new Set([ + "adapt", + "animate", + "audit", + "bolder", + "clarify", + "colorize", + "critique", + "delight", + "distill", + "frontend-design", + "harden", + "impeccable", + "layout", +]); + +const ENGINEERING_SLUGS = new Set([ + "cloning-squareup-repos", + "create-pr", + "plugin-creator", + "skill-creator", + "skill-installer", +]); + +const QUALITY_SLUGS = new Set([ + "code-review", + "create-app-e2e-test", + "edge-case-finder", +]); + +const RESEARCH_SLUGS = new Set([ + "agent-browser", + "codesearch", + "dev-guides", + "eng-ai-chat", + "go-link", + "openai-docs", +]); + +const WRITING_SLUGS = new Set(["ceo-weekly-update"]); + +const OPERATIONS_SLUGS = new Set([ + "check-ci", + "datadog", + "github:gh-address-comments", + "github:gh-fix-ci", +]); + +const INTEGRATION_SLUGS = new Set([ + "excel", + "gdrive", + "github:github", + "launchdarkly", + "linear", + "powerpoint", +]); + +const PRODUCTIVITY_SLUGS = new Set(["grocery-list-organizer"]); + +const CATEGORY_KEYWORDS: Record = { + design: [ + "accessibility", + "animation", + "breakpoint", + "color", + "copy", + "design", + "frontend", + "interface", + "layout", + "mobile", + "motion", + "polish", + "responsive", + "spacing", + "theme", + "typography", + "ui", + "ux", + "visual", + ], + engineering: [ + "app", + "build", + "codebase", + "create", + "feature", + "implement", + "install", + "plugin", + "react", + "repository", + "rust", + "scaffold", + "skill", + "typescript", + ], + quality: [ + "bug", + "coverage", + "edge case", + "lint", + "quality", + "regression", + "review", + "test", + "verify", + ], + research: [ + "browse", + "discover", + "docs", + "documentation", + "find", + "guide", + "investigate", + "knowledge", + "look up", + "query", + "read", + "search", + ], + writing: [ + "copy", + "document", + "draft", + "edit", + "email", + "message", + "rewrite", + "summary", + "update", + "write", + ], + integrations: [ + "drive", + "excel", + "extension", + "github", + "google docs", + "google drive", + "google sheets", + "google slides", + "launchdarkly", + "linear", + "powerpoint", + "sheets", + "slides", + ], + operations: [ + "buildkite", + "canary", + "ci", + "flag", + "incident", + "kochiku", + "log", + "metric", + "monitor", + "observability", + "release", + "trace", + ], + productivity: [ + "grocery", + "meal plan", + "organize", + "organizer", + "shopping", + "weekly update", + ], + general: [], +}; + +function normalizeText(value: string) { + return value.toLowerCase().replace(/[^a-z0-9:+\s-]/g, " "); +} + +function keywordScore(haystack: string, keywords: string[]) { + return keywords.reduce((score, keyword) => { + if (!haystack.includes(keyword)) { + return score; + } + + return score + (keyword.includes(" ") ? 2 : 1); + }, 0); +} + +function inferCategoryFromSlug(slug: string): SkillCategory | null { + if (DESIGN_SLUGS.has(slug)) { + return "design"; + } + + if (QUALITY_SLUGS.has(slug)) { + return "quality"; + } + + if (ENGINEERING_SLUGS.has(slug)) { + return "engineering"; + } + + if (RESEARCH_SLUGS.has(slug)) { + return "research"; + } + + if (WRITING_SLUGS.has(slug)) { + return "writing"; + } + + if (OPERATIONS_SLUGS.has(slug)) { + return "operations"; + } + + if (INTEGRATION_SLUGS.has(slug) || slug.startsWith("google-drive:")) { + return "integrations"; + } + + if (PRODUCTIVITY_SLUGS.has(slug)) { + return "productivity"; + } + + return null; +} + +export function inferSkillCategory( + skill: Pick, +): SkillCategory { + const slug = skill.name.toLowerCase(); + const explicitCategory = inferCategoryFromSlug(slug); + if (explicitCategory) { + return explicitCategory; + } + + const haystack = normalizeText( + [skill.name, skill.description, skill.instructions].join(" "), + ); + let bestCategory: SkillCategory = "general"; + let bestScore = 0; + + for (const category of SKILL_CATEGORY_ORDER) { + if (category === "general") { + continue; + } + + const score = keywordScore(haystack, CATEGORY_KEYWORDS[category]); + if (score > bestScore) { + bestCategory = category; + bestScore = score; + } + } + + return bestScore > 0 ? bestCategory : "general"; +} + +export function withInferredSkillCategory(skill: SkillInfo): SkillViewInfo { + return { + ...skill, + inferredCategory: inferSkillCategory(skill), + }; +} + +export function withInferredSkillCategories( + skills: SkillInfo[], +): SkillViewInfo[] { + return skills.map(withInferredSkillCategory); +} diff --git a/ui/goose2/src/features/skills/lib/skillsHelpers.ts b/ui/goose2/src/features/skills/lib/skillsHelpers.ts new file mode 100644 index 0000000000..b11adf3344 --- /dev/null +++ b/ui/goose2/src/features/skills/lib/skillsHelpers.ts @@ -0,0 +1,42 @@ +import type { SkillInfo } from "../api/skills"; +import { SKILL_CATEGORY_ORDER, type SkillViewInfo } from "./skillCategories"; + +export function uniqueProjectFilters(skills: SkillInfo[]) { + const seen = new Map(); + for (const skill of skills) { + for (const project of skill.projectLinks) { + if (!seen.has(project.id)) { + seen.set(project.id, project.name); + } + } + } + return [...seen.entries()] + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function uniqueSkillCategories(skills: SkillViewInfo[]) { + return SKILL_CATEGORY_ORDER.filter((category) => + skills.some((skill) => skill.inferredCategory === category), + ); +} + +export function compareSkillsByName(a: SkillInfo, b: SkillInfo) { + return ( + a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) || + a.name.localeCompare(b.name) || + a.path.localeCompare(b.path) + ); +} + +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); +} diff --git a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx index 0497ac61f1..7d9cfefde2 100644 --- a/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx +++ b/ui/goose2/src/features/skills/ui/CreateSkillDialog.tsx @@ -186,12 +186,12 @@ export function CreateSkillDialog({ />
- {isEditing && editingSkill && ( + {isEditing && editingSkill ? (

{t("dialog.pathOnDisk")}:{" "} {getRenamedSkillFileLocation(editingSkill.fileLocation, name)}

- )} + ) : null} {/* Instructions */}
diff --git a/ui/goose2/src/features/skills/ui/SkillDetailPage.tsx b/ui/goose2/src/features/skills/ui/SkillDetailPage.tsx new file mode 100644 index 0000000000..0d88dbab73 --- /dev/null +++ b/ui/goose2/src/features/skills/ui/SkillDetailPage.tsx @@ -0,0 +1,240 @@ +import type { ButtonHTMLAttributes, ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { + IconDots, + IconFolderOpen, + IconMessagePlus, + IconPencil, + IconShare, + IconTrash, +} from "@tabler/icons-react"; +import { MessageResponse } from "@/shared/ui/ai-elements/message"; +import { Button } from "@/shared/ui/button"; +import { DetailField } from "@/shared/ui/detail-field"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} 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"; + +interface SkillDetailPageProps { + skill: SkillViewInfo | null; + onBack: () => void; + onEdit: (skill: SkillInfo) => void; + onReveal: (skill: SkillInfo) => void; + onShare: (skill: SkillInfo) => void; + onStartChat?: (skill: SkillInfo) => void; + onDelete: (skill: SkillInfo) => void; +} + +interface SkillHeaderActionButtonProps + extends ButtonHTMLAttributes { + label: string; + icon: ReactNode; + tooltipSide?: "top" | "right" | "bottom" | "left"; +} + +function SkillHeaderActionButton({ + label, + icon, + type = "button", + tooltipSide = "top", + ...props +}: SkillHeaderActionButtonProps) { + return ( + + + + + +

{label}

+
+
+ ); +} + +export function SkillDetailPage({ + skill, + onBack, + onEdit, + onReveal, + onShare, + onStartChat, + onDelete, +}: SkillDetailPageProps) { + const { t } = useTranslation(["skills", "common"]); + + if (!skill) { + return ( +
+

{t("view.detailEmptyTitle")}

+

+ {t("view.detailEmptyDescription")} +

+
+ ); + } + + const sourceLabels = + skill.projectLinks.length > 0 + ? [...new Set(skill.projectLinks.map((project) => project.name))] + : [skill.sourceLabel]; + const startChatLabel = t("view.startChatShort"); + const editLabel = t("common:actions.edit"); + const revealLabel = t("view.reveal"); + const moreLabel = t("view.more"); + + return ( + +
+ + + + {onStartChat ? ( + } + tooltipSide="top" + onClick={() => onStartChat(skill)} + /> + ) : null} + {skill.editable ? ( + } + tooltipSide="top" + onClick={() => onEdit(skill)} + /> + ) : null} + } + tooltipSide="top" + onClick={() => onReveal(skill)} + /> + + + + + + onShare(skill)}> + + {t("view.share")} + + {skill.editable ? ( + onDelete(skill)} + > + + {t("common:actions.delete")} + + ) : null} + + + + } + actionsClassName="gap-2" + /> +
+ + +
+ + {t(`view.categories.options.${skill.inferredCategory}`)} + + + + {sourceLabels.map((label) => ( +

{label}

+ ))} +
+ + {skill.projectLinks.length > 0 ? ( + + {skill.projectLinks.map((project) => ( +
+

{project.name}

+

+ {project.workingDir} +

+
+ ))} +
+ ) : null} + + + {skill.fileLocation} + +
+ + } + > +
+ + + {skill.instructions || " "} + +
+
+
+ ); +} diff --git a/ui/goose2/src/features/skills/ui/SkillsDialogs.tsx b/ui/goose2/src/features/skills/ui/SkillsDialogs.tsx new file mode 100644 index 0000000000..d3f0146541 --- /dev/null +++ b/ui/goose2/src/features/skills/ui/SkillsDialogs.tsx @@ -0,0 +1,86 @@ +import { useTranslation } from "react-i18next"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { buttonVariants } from "@/shared/ui/button"; +import { CreateSkillDialog } from "./CreateSkillDialog"; +import type { SkillInfo } from "../api/skills"; + +interface SkillsDialogsProps { + dialogOpen: boolean; + onDialogClose: () => void; + onCreated: () => void | Promise; + editingSkill?: { + name: string; + description: string; + instructions: string; + path: string; + fileLocation: string; + }; + deletingSkill: SkillInfo | null; + onDeletingSkillChange: (skill: SkillInfo | null) => void; + onConfirmDelete: () => void | Promise; + notification: string | null; +} + +export function SkillsDialogs({ + dialogOpen, + onDialogClose, + onCreated, + editingSkill, + deletingSkill, + onDeletingSkillChange, + onConfirmDelete, + notification, +}: SkillsDialogsProps) { + const { t } = useTranslation(["skills", "common"]); + + return ( + <> + + + !open && onDeletingSkillChange(null)} + > + + + {t("view.deleteTitle")} + + {t("view.deleteDescription", { + name: deletingSkill?.name ?? "", + })} + + + + {t("common:actions.cancel")} + + {t("common:actions.delete")} + + + + + + {notification && ( +
+ {notification} +
+ )} + + ); +} diff --git a/ui/goose2/src/features/skills/ui/SkillsEmptyState.tsx b/ui/goose2/src/features/skills/ui/SkillsEmptyState.tsx new file mode 100644 index 0000000000..61baaa170c --- /dev/null +++ b/ui/goose2/src/features/skills/ui/SkillsEmptyState.tsx @@ -0,0 +1,66 @@ +import { useTranslation } from "react-i18next"; +import { AtSign, Plus, Upload } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; + +interface SkillsEmptyStateProps { + hasAnySkills: boolean; + isDragOver: boolean; + dropHandlers: React.HTMLAttributes; + onNewSkill: () => void; + onImport: () => void; +} + +export function SkillsEmptyState({ + hasAnySkills, + isDragOver, + dropHandlers, + onNewSkill, + onImport, +}: SkillsEmptyStateProps) { + const { t } = useTranslation(["skills", "common"]); + + return ( +
+ +
+

+ {hasAnySkills ? t("view.noMatchesTitle") : t("view.emptyTitle")} +

+

+ {hasAnySkills + ? t("view.noMatchesDescription") + : t("view.emptyDescription")} +

+
+ {!hasAnySkills ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/ui/goose2/src/features/skills/ui/SkillsListSections.tsx b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx new file mode 100644 index 0000000000..55b41caebd --- /dev/null +++ b/ui/goose2/src/features/skills/ui/SkillsListSections.tsx @@ -0,0 +1,102 @@ +import { useTranslation } from "react-i18next"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionSectionTrigger, +} from "@/shared/ui/accordion"; +import { Button } from "@/shared/ui/button"; +import type { SkillViewInfo } from "../lib/skillCategories"; + +export interface SkillsSection { + id: string; + title: string; + skills: SkillViewInfo[]; +} + +interface SkillsListSectionsProps { + sections: SkillsSection[]; + expandedSectionIds: string[]; + onExpandedSectionIdsChange: (ids: string[]) => void; + onSelectSkill: (skill: SkillViewInfo) => void; + onStartChat?: (skill: SkillViewInfo) => void; +} + +export function SkillsListSections({ + sections, + expandedSectionIds, + onExpandedSectionIdsChange, + onSelectSkill, + onStartChat, +}: SkillsListSectionsProps) { + const { t } = useTranslation(["skills"]); + + return ( + + {sections.map((section) => ( + + + + +
+
+ {section.skills.map((skill) => ( +
+ + ) : null} +
+ ))} +
+
+
+
+ ))} +
+ ); +} diff --git a/ui/goose2/src/features/skills/ui/SkillsView.tsx b/ui/goose2/src/features/skills/ui/SkillsView.tsx index 7b6786d3ea..ed35d2b211 100644 --- a/ui/goose2/src/features/skills/ui/SkillsView.tsx +++ b/ui/goose2/src/features/skills/ui/SkillsView.tsx @@ -1,101 +1,159 @@ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { Plus, Upload } from "lucide-react"; import { - AtSign, - Plus, - Trash2, - MoreHorizontal, - Pencil, - Copy, - Download, - Upload, -} from "lucide-react"; + IconAdjustmentsHorizontal, + IconChevronDown, +} from "@tabler/icons-react"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; import { cn } from "@/shared/lib/cn"; import { SearchBar } from "@/shared/ui/SearchBar"; -import { Button, buttonVariants } from "@/shared/ui/button"; +import { Button } from "@/shared/ui/button"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; +import { FilterRow, PageHeader, PageShell } from "@/shared/ui/page-shell"; import { useFileImportZone } from "@/shared/hooks/useFileImportZone"; -import { CreateSkillDialog } from "./CreateSkillDialog"; +import { revealInFileManager } from "@/shared/lib/fileManager"; +import { SkillDetailPage } from "./SkillDetailPage"; +import { SkillsDialogs } from "./SkillsDialogs"; +import { SkillsEmptyState } from "./SkillsEmptyState"; +import { SkillsListSections, type SkillsSection } from "./SkillsListSections"; +import { hydrateProjectNames } from "../lib/projectHydration"; +import { + compareSkillsByName, + downloadExport, + uniqueSkillCategories, + uniqueProjectFilters, +} from "../lib/skillsHelpers"; import { - listSkills, deleteSkill, - createSkill, exportSkill, importSkills, + listSkills, type SkillInfo, } from "../api/skills"; +import { + withInferredSkillCategories, + type SkillCategory, + type SkillViewInfo, +} from "../lib/skillCategories"; -function SkillCardMenu({ - skill, - onEdit, - onDuplicate, - onExport, - onDelete, +type SkillsFilter = "all" | "global" | `project:${string}`; + +function SkillCategoryFilter({ + categories, + selectedCategories, + onSelectedCategoriesChange, }: { - skill: SkillInfo; - onEdit: (skill: SkillInfo) => void; - onDuplicate: (skill: SkillInfo) => void; - onExport: (skill: SkillInfo) => void; - onDelete: (skill: SkillInfo) => void; + categories: SkillCategory[]; + selectedCategories: SkillCategory[]; + onSelectedCategoriesChange: (categories: SkillCategory[]) => void; }) { - const { t } = useTranslation(["skills", "common"]); + const { t } = useTranslation(["skills"]); + + const toggleCategory = useCallback( + (category: SkillCategory) => { + onSelectedCategoriesChange( + selectedCategories.includes(category) + ? selectedCategories.filter((value) => value !== category) + : [...selectedCategories, category], + ); + }, + [onSelectedCategoriesChange, selectedCategories], + ); + + const buttonLabel = + selectedCategories.length === 0 + ? t("view.categories.label") + : selectedCategories.length === 1 + ? t(`view.categories.options.${selectedCategories[0]}`) + : t("view.categories.count", { count: selectedCategories.length }); return ( - - onEdit(skill)}> - - {t("common:actions.edit")} - - onDuplicate(skill)}> - - {t("common:actions.duplicate")} - - onExport(skill)}> - - {t("common:actions.export")} - - onDelete(skill)} - > - - {t("common:actions.delete")} - + + {t("view.categories.label")} + + {categories.map((category) => ( + event.preventDefault()} + onCheckedChange={() => toggleCategory(category)} + > + {t(`view.categories.options.${category}`)} + + ))} + {selectedCategories.length > 0 ? ( + <> + + { + event.preventDefault(); + onSelectedCategoriesChange([]); + }} + > + {t("view.categories.clear")} + + + ) : null} ); } -export function SkillsView() { +interface SkillsViewProps { + onStartChatWithSkill?: (skillName: string, projectId?: string | null) => void; +} + +function FilterButton({ + active, + children, + onClick, +}: { + active: boolean; + children: React.ReactNode; + onClick: () => void; +}) { + return ( + + ); +} + +export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) { const { t } = useTranslation(["skills", "common"]); + const projects = useProjectStore((state) => state.projects); const [search, setSearch] = useState(""); + const [activeFilter, setActiveFilter] = useState("all"); + const [selectedCategories, setSelectedCategories] = useState( + [], + ); const [dialogOpen, setDialogOpen] = useState(false); const [editingSkill, setEditingSkill] = useState< | { @@ -107,28 +165,164 @@ export function SkillsView() { } | undefined >(undefined); - const [skills, setSkills] = useState([]); + const [skills, setSkills] = useState([]); const [loading, setLoading] = useState(true); const [deletingSkill, setDeletingSkill] = useState(null); const [notification, setNotification] = useState(null); + const [activeSkillId, setActiveSkillId] = useState(null); + const [expandedSectionIds, setExpandedSectionIds] = useState([]); const importInputRef = useRef(null); + const loadRequestIdRef = useRef(0); const loadSkills = useCallback(async () => { + const requestId = loadRequestIdRef.current + 1; + loadRequestIdRef.current = requestId; setLoading(true); + try { - const result = await listSkills(); - setSkills(result); + const projectDirs = projects.flatMap((project) => project.workingDirs); + const result = await listSkills(projectDirs); + if (loadRequestIdRef.current !== requestId) { + return; + } + setSkills( + withInferredSkillCategories(hydrateProjectNames(result, projects)), + ); } catch { - setSkills([]); + if (loadRequestIdRef.current === requestId) { + setSkills([]); + } } finally { - setLoading(false); + if (loadRequestIdRef.current === requestId) { + setLoading(false); + } } - }, []); + }, [projects]); useEffect(() => { loadSkills(); }, [loadSkills]); + const projectFilters = useMemo(() => uniqueProjectFilters(skills), [skills]); + const categoryFilters = useMemo( + () => uniqueSkillCategories(skills), + [skills], + ); + + useEffect(() => { + if (!activeFilter.startsWith("project:")) { + return; + } + + const projectId = activeFilter.slice("project:".length); + if (!projectFilters.some((project) => project.id === projectId)) { + setActiveFilter("all"); + } + }, [activeFilter, projectFilters]); + + useEffect(() => { + setSelectedCategories((current) => + current.filter((category) => categoryFilters.includes(category)), + ); + }, [categoryFilters]); + + const filteredSkills = useMemo(() => { + const searchTerm = search.trim().toLowerCase(); + return skills.filter((skill) => { + const matchesSearch = + searchTerm.length === 0 || + skill.name.toLowerCase().includes(searchTerm) || + skill.description.toLowerCase().includes(searchTerm) || + skill.sourceLabel.toLowerCase().includes(searchTerm) || + t(`view.categories.options.${skill.inferredCategory}`) + .toLowerCase() + .includes(searchTerm); + + const matchesFilter = + activeFilter === "all" + ? true + : activeFilter === "global" + ? skill.sourceKind === "global" + : skill.projectLinks.some( + (project) => `project:${project.id}` === activeFilter, + ); + + const matchesCategory = + selectedCategories.length === 0 || + selectedCategories.includes(skill.inferredCategory); + + return matchesSearch && matchesFilter && matchesCategory; + }); + }, [activeFilter, search, selectedCategories, skills, t]); + + const groupedSkills = useMemo(() => { + if (activeFilter === "global") { + return [ + { + id: "personal", + title: t("view.filtersGlobal"), + skills: [...filteredSkills].sort(compareSkillsByName), + }, + ]; + } + + if (activeFilter.startsWith("project:")) { + const projectId = activeFilter.slice("project:".length); + const projectName = + projectFilters.find((project) => project.id === projectId)?.name ?? + t("view.projects"); + + return [ + { + id: activeFilter, + title: projectName, + skills: [...filteredSkills].sort(compareSkillsByName), + }, + ]; + } + + const personalSkills = filteredSkills + .filter((skill) => skill.sourceKind === "global") + .sort(compareSkillsByName); + + const projectSections = projectFilters + .map((project) => ({ + id: `project:${project.id}`, + title: project.name, + skills: filteredSkills + .filter((skill) => + skill.projectLinks.some((link) => link.id === project.id), + ) + .sort(compareSkillsByName), + })) + .filter((section) => section.skills.length > 0); + + return [ + ...(personalSkills.length > 0 + ? [ + { + id: "personal", + title: t("view.filtersGlobal"), + skills: personalSkills, + }, + ] + : []), + ...projectSections, + ]; + }, [activeFilter, filteredSkills, projectFilters, t]); + + useEffect(() => { + const nextIds = groupedSkills.map((section) => section.id); + setExpandedSectionIds((prev) => { + const stillVisible = prev.filter((id) => nextIds.includes(id)); + const newIds = nextIds.filter((id) => !stillVisible.includes(id)); + return [...stillVisible, ...newIds]; + }); + }, [groupedSkills]); + + const activeSkill = + skills.find((skill) => skill.id === activeSkillId) ?? null; + const handleDelete = (skill: SkillInfo) => { setDeletingSkill(skill); }; @@ -138,6 +332,9 @@ export function SkillsView() { try { await deleteSkill(deletingSkill.path); await loadSkills(); + if (activeSkillId === deletingSkill.id) { + setActiveSkillId(null); + } } catch { // best-effort } @@ -155,34 +352,10 @@ export function SkillsView() { setDialogOpen(true); }; - const handleDuplicate = async (skill: SkillInfo) => { - const existingNames = new Set(skills.map((s) => s.name)); - let copyName = `${skill.name}-copy`; - let counter = 2; - while (existingNames.has(copyName)) { - copyName = `${skill.name}-copy-${counter}`; - counter++; - } - try { - await createSkill(copyName, skill.description, skill.instructions); - await loadSkills(); - } catch { - // best-effort - } - }; - const handleExport = async (skill: SkillInfo) => { try { const result = await exportSkill(skill.path); - const blob = new Blob([result.json], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadExport(result.json, result.filename); setNotification(t("view.exportedTo", { filename: result.filename })); setTimeout(() => setNotification(null), 3000); } catch (err) { @@ -190,9 +363,20 @@ export function SkillsView() { } }; + const handleReveal = useCallback((skill: SkillInfo) => { + void revealInFileManager(skill.path); + }, []); + + const handleStartChat = useCallback( + (skill: SkillInfo) => { + onStartChatWithSkill?.(skill.name, skill.projectLinks[0]?.id ?? null); + }, + [onStartChatWithSkill], + ); + const handleImportFile = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; if (!file) return; try { @@ -200,8 +384,8 @@ export function SkillsView() { const bytes = Array.from(new Uint8Array(arrayBuffer)); await importSkills(bytes, file.name); await loadSkills(); - } catch (err) { - console.error("Failed to import skill:", err); + } catch (error) { + console.error("Failed to import skill:", error); } if (importInputRef.current) { @@ -226,8 +410,8 @@ export function SkillsView() { try { await importSkills(fileBytes, fileName); await loadSkills(); - } catch (err) { - console.error("Failed to import skill:", err); + } catch (error) { + console.error("Failed to import skill:", error); } }, [loadSkills], @@ -240,150 +424,147 @@ export function SkillsView() { handleFileChange: handleDropFileChange, } = useFileImportZone({ onImportFile: handleDropImport }); - const filtered = skills.filter( - (s) => - s.name.toLowerCase().includes(search.toLowerCase()) || - s.description.toLowerCase().includes(search.toLowerCase()), + const handleSelectSkill = (skill: SkillViewInfo) => { + setActiveSkillId(skill.id); + }; + + const dialogs = ( + ); - return ( -
-
-
-
-
-

- {t("view.title")} -

-

- {t("view.description")} -

-
-
- - - -
-
+ if (activeSkill) { + return ( + <> + setActiveSkillId(null)} + onEdit={handleEdit} + onReveal={handleReveal} + onShare={handleExport} + onStartChat={onStartChatWithSkill ? handleStartChat : undefined} + onDelete={handleDelete} + /> + {dialogs} + + ); + } + return ( + + + + + + + } + /> + +
+
- {loading && ( -
- {t("common:labels.loading")} -
- )} - - {!loading && filtered.length > 0 && ( -
- {filtered.map((skill) => ( -
-
-
-

{skill.name}

-
- {skill.description && ( -

- {skill.description} -

- )} -
- -
- ))} - - -
- )} - - {!loading && filtered.length === 0 && ( -
+ setActiveFilter("all")} > - -
-

- {skills.length === 0 - ? t("view.emptyTitle") - : t("view.noMatchesTitle")} -

-

- {skills.length === 0 - ? t("view.emptyDescription") - : t("view.noMatchesDescription")} -

-
- {skills.length === 0 && ( - - )} -
- )} + {project.name} + + ); + })} + {categoryFilters.length > 0 ? ( + + ) : null} +
+ {!loading && filteredSkills.length > 0 ? ( + + ) : null} + + {!loading && filteredSkills.length === 0 ? ( + 0} + isDragOver={isDragOver} + dropHandlers={dropHandlers} + onNewSkill={handleNewSkill} + onImport={() => importInputRef.current?.click()} + /> + ) : null} + - - - !open && setDeletingSkill(null)} - > - - - {t("view.deleteTitle")} - - {t("view.deleteDescription", { - name: deletingSkill?.name ?? "", - })} - - - - {t("common:actions.cancel")} - - {t("common:actions.delete")} - - - - - - {notification && ( -
- {notification} -
- )} -
+ {dialogs} + ); } diff --git a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx index 2d24e6bc14..53daa70194 100644 --- a/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx +++ b/ui/goose2/src/features/skills/ui/__tests__/SkillsView.test.tsx @@ -1,36 +1,86 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { SkillInfo } from "../../api/skills"; import { SkillsView } from "../SkillsView"; -const mockSkills = [ +type MockProject = { + id: string; + name: string; + workingDirs: string[]; +}; + +let mockProjects: MockProject[] = [ { + id: "project-alpha", + name: "alpha", + workingDirs: ["/tmp/alpha"], + }, +]; + +const mockSkills: SkillInfo[] = [ + { + id: "global:/path/layout-polish", + name: "layout", + description: "Improves layout, spacing, and visual hierarchy", + instructions: "Refine spacing and visual rhythm...", + path: "/path/layout/SKILL.md", + fileLocation: "/path/layout/SKILL.md", + directoryPath: "/path/layout", + sourceKind: "global" as const, + sourceLabel: "Personal", + projectLinks: [], + editable: true, + }, + { + id: "global:/path/code-review", name: "code-review", description: "Reviews code", instructions: "Review the code...", path: "/path/code-review", fileLocation: "/path/code-review/SKILL.md", + directoryPath: "/path/code-review", + sourceKind: "global" as const, + sourceLabel: "Personal", + projectLinks: [], + editable: true, }, { + id: "project:/tmp/alpha/.goose/skills/test-writer", name: "test-writer", description: "Writes tests", instructions: "Write tests...", - path: "/path/test-writer", - fileLocation: "/path/test-writer/SKILL.md", + path: "/tmp/alpha/.goose/skills/test-writer", + fileLocation: "/tmp/alpha/.goose/skills/test-writer/SKILL.md", + directoryPath: "/tmp/alpha/.goose/skills/test-writer", + sourceKind: "project" as const, + sourceLabel: "alpha", + projectLinks: [ + { + id: "/tmp/alpha", + name: "alpha", + workingDir: "/tmp/alpha", + }, + ], + editable: true, }, ]; vi.mock("../../api/skills", () => ({ listSkills: vi.fn().mockResolvedValue([]), - createSkill: vi.fn().mockResolvedValue(undefined), deleteSkill: vi.fn().mockResolvedValue(undefined), - updateSkill: vi.fn().mockResolvedValue(undefined), exportSkill: vi .fn() .mockResolvedValue({ json: "{}", filename: "test.skill.json" }), importSkills: vi.fn().mockResolvedValue([]), })); +vi.mock("@/features/projects/stores/projectStore", () => ({ + useProjectStore: ( + selector: (state: { projects: MockProject[] }) => unknown, + ) => selector({ projects: mockProjects }), +})); + const { listSkills, deleteSkill } = (await import( "../../api/skills" )) as unknown as { @@ -40,197 +90,314 @@ const { listSkills, deleteSkill } = (await import( beforeEach(() => { vi.clearAllMocks(); + mockProjects = [ + { + id: "project-alpha", + name: "alpha", + workingDirs: ["/tmp/alpha"], + }, + ]; listSkills.mockResolvedValue([]); }); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error?: unknown) => void; + const promise = new Promise((promiseResolve, promiseReject) => { + resolve = promiseResolve; + reject = promiseReject; + }); + return { promise, resolve, reject }; +} + describe("SkillsView", () => { - describe("Rendering", () => { - it("shows Skills heading and subtitle", async () => { - render(); - expect(screen.getByText("Skills")).toBeInTheDocument(); - expect( - screen.getByText("Reusable instructions for your AI agents"), - ).toBeInTheDocument(); - }); - - it("shows New Skill and Import buttons", async () => { - render(); - expect(screen.getByText("New Skill")).toBeInTheDocument(); - expect(screen.getByText("Import")).toBeInTheDocument(); - }); - - it("shows empty state when no skills exist", async () => { - render(); - await waitFor(() => { - expect(screen.getByText("No skills yet")).toBeInTheDocument(); - }); - expect( - screen.getByText("Create a skill or drop a .skill.json file here."), - ).toBeInTheDocument(); - }); - - it("renders skill cards when skills are loaded", async () => { - listSkills.mockResolvedValue(mockSkills); - render(); - expect(await screen.findByText("code-review")).toBeInTheDocument(); - expect(screen.getByText("test-writer")).toBeInTheDocument(); - expect(screen.getByText("Reviews code")).toBeInTheDocument(); - expect(screen.getByText("Writes tests")).toBeInTheDocument(); - }); + it("shows the redesigned heading and description", () => { + render(); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect( + screen.getByText(/Skills are reusable instructions/), + ).toBeInTheDocument(); }); - describe("Search", () => { - it("filters skills by name when searching", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); + it("shows the empty state when no skills are available", async () => { + render(); + await waitFor(() => { + expect(listSkills).toHaveBeenCalledWith(["/tmp/alpha"]); + }); + await waitFor(() => { + expect(screen.getByText("No skills yet")).toBeInTheDocument(); + }); + expect( + screen.getByText("Create a skill or import one to get started."), + ).toBeInTheDocument(); + }); - await user.type( - screen.getByPlaceholderText("Search skills by name or description..."), - "code", - ); + it("ignores stale skill loads after projects change", async () => { + const firstLoad = createDeferred(); + const secondLoad = createDeferred(); + listSkills + .mockReturnValueOnce(firstLoad.promise) + .mockReturnValueOnce(secondLoad.promise); + const { rerender } = render(); - expect(screen.getByText("code-review")).toBeInTheDocument(); + await waitFor(() => { + expect(listSkills).toHaveBeenCalledTimes(1); + }); + + mockProjects = [ + { + id: "project-beta", + name: "beta", + workingDirs: ["/tmp/beta"], + }, + ]; + rerender(); + + await waitFor(() => { + expect(listSkills).toHaveBeenCalledTimes(2); + }); + + 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", + directoryPath: "/tmp/beta/.goose/skills/beta-skill", + sourceLabel: "beta", + projectLinks: [ + { + id: "/tmp/beta", + name: "beta", + workingDir: "/tmp/beta", + }, + ], + }, + ]); + await screen.findByText("beta-skill"); + + firstLoad.resolve([mockSkills[2]]); + await waitFor(() => { + expect(screen.getByText("beta-skill")).toBeInTheDocument(); expect(screen.queryByText("test-writer")).not.toBeInTheDocument(); }); + }); - it("filters skills by description when searching", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); + it("matches saved project working directories with trailing separators", async () => { + mockProjects = [ + { + id: "project-goose", + name: "Goose", + workingDirs: ["/tmp/goose/"], + }, + ]; + listSkills.mockResolvedValue([ + { + ...mockSkills[2], + id: "project:/tmp/goose/.agents/skills/test-writer", + name: "test-writer", + path: "/tmp/goose/.agents/skills/test-writer", + fileLocation: "/tmp/goose/.agents/skills/test-writer/SKILL.md", + directoryPath: "/tmp/goose/.agents/skills/test-writer", + sourceLabel: "goose", + projectLinks: [ + { + id: "/tmp/goose", + name: "goose", + workingDir: "/tmp/goose", + }, + ], + }, + ]); + const user = userEvent.setup(); - await user.type( - screen.getByPlaceholderText("Search skills by name or description..."), - "Writes tests", - ); + render(); + await screen.findByText("test-writer"); - expect(screen.queryByText("code-review")).not.toBeInTheDocument(); - expect(screen.getByText("test-writer")).toBeInTheDocument(); - }); + await user.click(screen.getByRole("button", { name: "Goose" })); - it("shows empty state when search has no results", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); + expect(screen.getByText("test-writer")).toBeInTheDocument(); + }); - await user.type( - screen.getByPlaceholderText("Search skills by name or description..."), - "nonexistent", - ); + it("renders skills and opens the detail subpage", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); - expect(screen.getByText("No matching skills")).toBeInTheDocument(); - expect( - screen.getByText("Try a different search term."), - ).toBeInTheDocument(); + render(); + await screen.findByText("code-review"); + + await user.click( + screen.getByRole("button", { name: "Open test-writer details" }), + ); + + expect( + screen.getByRole("button", { name: "Back to skills" }), + ).toBeInTheDocument(); + expect(screen.getAllByText("alpha").length).toBeGreaterThan(0); + expect(screen.getByText("Write tests...")).toBeInTheDocument(); + expect( + screen.getByText("/tmp/alpha/.goose/skills/test-writer/SKILL.md"), + ).toBeInTheDocument(); + expect(screen.getByText("Quality")).toBeInTheDocument(); + }); + + it("returns to the list without losing filters", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.click(screen.getByRole("button", { name: "alpha" })); + await user.click( + screen.getByRole("button", { name: "Open test-writer details" }), + ); + await user.click(screen.getByRole("button", { name: "Back to skills" })); + + expect(screen.getByText("test-writer")).toBeInTheDocument(); + expect(screen.queryByText("code-review")).not.toBeInTheDocument(); + }); + + it("filters skills by search text", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.type( + screen.getByPlaceholderText("Search skills"), + "writes tests", + ); + + expect(screen.queryByText("code-review")).not.toBeInTheDocument(); + expect(screen.getByText("test-writer")).toBeInTheDocument(); + }); + + it("filters skills by project from the main filter row", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.click(screen.getByRole("button", { name: "alpha" })); + + expect(screen.queryByText("code-review")).not.toBeInTheDocument(); + expect(screen.getByText("test-writer")).toBeInTheDocument(); + }); + + it("filters skills by inferred category from the dropdown", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.click( + screen.getByRole("button", { name: "Filter by category" }), + ); + await user.click(screen.getByRole("menuitemcheckbox", { name: "Design" })); + + expect(screen.getByText("layout")).toBeInTheDocument(); + expect(screen.queryByText("code-review")).not.toBeInTheDocument(); + expect(screen.queryByText("test-writer")).not.toBeInTheDocument(); + }); + + it("shows a delete confirmation from the detail panel", async () => { + listSkills.mockResolvedValue(mockSkills); + const user = userEvent.setup(); + + render(); + await screen.findByText("code-review"); + + await user.click( + screen.getByRole("button", { name: "Open code-review details" }), + ); + screen.getByRole("button", { name: "More" }).focus(); + await user.keyboard("{Enter}"); + await user.click(screen.getByRole("menuitem", { name: "Delete" })); + + expect(screen.getByText("Delete skill?")).toBeInTheDocument(); + + const deleteButtons = screen.getAllByRole("button", { name: "Delete" }); + await user.click(deleteButtons[deleteButtons.length - 1]); + + await waitFor(() => { + expect(deleteSkill).toHaveBeenCalledWith("/path/code-review"); }); }); - describe("Skill card menu", () => { - it("shows dropdown menu with Edit, Duplicate, Export, Delete options", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); + it("passes saved project working directories into listSkills", async () => { + mockProjects = [ + { + id: "project-goose", + name: "Goose", + workingDirs: ["/tmp/goose", "/tmp/goose-worktree"], + }, + ]; - await user.click(screen.getByLabelText("Options for code-review")); + render(); - expect(screen.getByRole("menu")).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /edit/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /duplicate/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /export/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /delete/i }), - ).toBeInTheDocument(); - }); - }); - - describe("Delete confirmation", () => { - it("shows confirmation dialog when delete is clicked", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); - - await user.click(screen.getByLabelText("Options for code-review")); - await user.click(screen.getByRole("menuitem", { name: /delete/i })); - - expect(screen.getByText("Delete skill?")).toBeInTheDocument(); - expect( - screen.getByText(/Are you sure you want to delete "code-review"\?/), - ).toBeInTheDocument(); - }); - - it("cancels deletion when Cancel is clicked", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); - - await user.click(screen.getByLabelText("Options for code-review")); - await user.click(screen.getByRole("menuitem", { name: /delete/i })); - expect(screen.getByText("Delete skill?")).toBeInTheDocument(); - - await user.click(screen.getByText("Cancel")); - expect(screen.queryByText("Delete skill?")).not.toBeInTheDocument(); - }); - - it("calls deleteSkill API when confirmed", async () => { - listSkills.mockResolvedValue(mockSkills); - const user = userEvent.setup(); - render(); - await screen.findByText("code-review"); - - await user.click(screen.getByLabelText("Options for code-review")); - await user.click(screen.getByRole("menuitem", { name: /delete/i })); - await user.click(screen.getByRole("button", { name: "Delete" })); - - await waitFor(() => { - expect(deleteSkill).toHaveBeenCalledWith("/path/code-review"); - }); - }); - - it("does not show the path on disk in the list view and still allows deleting discovered skills", async () => { - listSkills.mockResolvedValue([ - { - name: "claude-skill", - description: "Imported from Claude", - instructions: "Use this skill...", - path: "/Users/test/.claude/skills/claude-skill", - fileLocation: "/Users/test/.claude/skills/claude-skill/SKILL.md", - }, + await waitFor(() => { + expect(listSkills).toHaveBeenCalledWith([ + "/tmp/goose", + "/tmp/goose-worktree", ]); - const user = userEvent.setup(); - - render(); - - expect(await screen.findByText("claude-skill")).toBeInTheDocument(); - expect(screen.queryByText("Path on disk:")).not.toBeInTheDocument(); - expect( - screen.queryByText("/Users/test/.claude/skills/claude-skill/SKILL.md"), - ).not.toBeInTheDocument(); - - await user.click(screen.getByLabelText("Options for claude-skill")); - expect( - screen.getByRole("menuitem", { name: /edit/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /duplicate/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /export/i }), - ).toBeInTheDocument(); - expect( - screen.getByRole("menuitem", { name: /delete/i }), - ).toBeInTheDocument(); }); }); + + it("groups multiple working directories under the saved project filter", async () => { + mockProjects = [ + { + id: "project-goose", + name: "Goose", + workingDirs: ["/tmp/goose", "/tmp/goose-worktree"], + }, + ]; + listSkills.mockResolvedValue([ + { + ...mockSkills[2], + id: "project:/tmp/goose/.agents/skills/test-writer", + name: "test-writer", + path: "/tmp/goose/.agents/skills/test-writer", + fileLocation: "/tmp/goose/.agents/skills/test-writer/SKILL.md", + directoryPath: "/tmp/goose/.agents/skills/test-writer", + sourceLabel: "goose", + projectLinks: [ + { + id: "/tmp/goose", + name: "goose", + workingDir: "/tmp/goose", + }, + ], + }, + { + ...mockSkills[2], + id: "project:/tmp/goose-worktree/.agents/skills/audit", + name: "audit", + path: "/tmp/goose-worktree/.agents/skills/audit", + fileLocation: "/tmp/goose-worktree/.agents/skills/audit/SKILL.md", + directoryPath: "/tmp/goose-worktree/.agents/skills/audit", + sourceLabel: "goose-worktree", + projectLinks: [ + { + id: "/tmp/goose-worktree", + name: "goose-worktree", + workingDir: "/tmp/goose-worktree", + }, + ], + }, + ]); + const user = userEvent.setup(); + + render(); + await screen.findByText("test-writer"); + + await user.click(screen.getByRole("button", { name: "Goose" })); + + expect(screen.getByText("test-writer")).toBeInTheDocument(); + expect(screen.getByText("audit")).toBeInTheDocument(); + }); }); diff --git a/ui/goose2/src/shared/i18n/locales/en/skills.json b/ui/goose2/src/shared/i18n/locales/en/skills.json index 3ea548028a..fd5a06c0dd 100644 --- a/ui/goose2/src/shared/i18n/locales/en/skills.json +++ b/ui/goose2/src/shared/i18n/locales/en/skills.json @@ -15,18 +15,54 @@ "saving": "Saving..." }, "view": { + "backToSkills": "Back to skills", "deleteDescription": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", "deleteTitle": "Delete skill?", - "description": "Reusable instructions for your AI agents", + "category": "Category", + "categories": { + "clear": "Clear categories", + "count": "{{count}} categories", + "filter": "Filter by category", + "label": "Categories", + "options": { + "design": "Design", + "engineering": "Engineering", + "general": "General", + "integrations": "Integrations", + "operations": "Operations", + "productivity": "Productivity", + "quality": "Quality", + "research": "Research", + "writing": "Writing" + } + }, + "description": "Skills are reusable instructions that help an agent handle specific tasks, workflows, or tools.", + "detailEmptyDescription": "Select a skill to inspect its source, location, and instructions.", + "detailEmptyTitle": "Choose a skill", "dropFile": "or drop a file", - "emptyDescription": "Create a skill or drop a .skill.json file here.", + "emptyDescription": "Create a skill or import one to get started.", "emptyTitle": "No skills yet", "exportedTo": "Exported to {{filename}}", + "filePath": "File path", + "filtersAllSources": "All", + "filtersGlobal": "Personal", + "instructions": "Instructions", + "location": "Location", + "more": "More", "newSkill": "New Skill", - "noMatchesDescription": "Try a different search term.", + "noMatchesDescription": "Try a different search or filter.", "noMatchesTitle": "No matching skills", - "optionsAria": "Options for {{name}}", - "searchPlaceholder": "Search skills by name or description...", - "title": "Skills" + "openDetails": "Open {{name}} details", + "projects": "Projects", + "reveal": "Show in folder", + "searchPlaceholder": "Search skills", + "share": "Share", + "skillCount_one": "{{displayCount}} skill", + "skillCount_other": "{{displayCount}} skills", + "source": "Source", + "startChat": "Start chat with {{name}}", + "startChatShort": "Start chat", + "title": "Skills", + "useInChat": "Use in chat" } } diff --git a/ui/goose2/src/shared/i18n/locales/es/skills.json b/ui/goose2/src/shared/i18n/locales/es/skills.json index 8fc358db4c..6db88514a4 100644 --- a/ui/goose2/src/shared/i18n/locales/es/skills.json +++ b/ui/goose2/src/shared/i18n/locales/es/skills.json @@ -15,18 +15,54 @@ "saving": "Guardando..." }, "view": { + "backToSkills": "Volver a skills", + "category": "Categoría", + "categories": { + "clear": "Limpiar categorías", + "count": "{{count}} categorías", + "filter": "Filtrar por categoría", + "label": "Categorías", + "options": { + "design": "Diseño", + "engineering": "Ingeniería", + "general": "General", + "integrations": "Integraciones", + "operations": "Operaciones", + "productivity": "Productividad", + "quality": "Calidad", + "research": "Investigación", + "writing": "Redacción" + } + }, "deleteDescription": "¿Seguro que quieres eliminar \"{{name}}\"? Esto no se puede deshacer.", "deleteTitle": "¿Eliminar skill?", - "description": "Instrucciones reutilizables para tus agentes de IA", + "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", "dropFile": "o suelta un archivo", - "emptyDescription": "Crea una skill o suelta aquí un archivo .skill.json.", + "emptyDescription": "Crea una skill o importa una para empezar.", "emptyTitle": "Aún no hay skills", "exportedTo": "Exportado a {{filename}}", + "filePath": "Ruta del archivo", + "filtersAllSources": "Todas", + "filtersGlobal": "Personal", + "instructions": "Instrucciones", + "location": "Ubicación", + "more": "Más", "newSkill": "Nueva skill", - "noMatchesDescription": "Prueba con otro término de búsqueda.", + "noMatchesDescription": "Prueba con otra búsqueda o filtro.", "noMatchesTitle": "No hay skills que coincidan", - "optionsAria": "Opciones de {{name}}", - "searchPlaceholder": "Buscar skills por nombre o descripción...", - "title": "Skills" + "openDetails": "Abrir detalles de {{name}}", + "projects": "Proyectos", + "reveal": "Mostrar en carpeta", + "searchPlaceholder": "Buscar skills", + "share": "Compartir", + "skillCount_one": "{{displayCount}} skill", + "skillCount_other": "{{displayCount}} skills", + "source": "Origen", + "startChat": "Iniciar chat con {{name}}", + "startChatShort": "Iniciar chat", + "title": "Skills", + "useInChat": "Usar en chat" } } diff --git a/ui/goose2/src/shared/styles/globals.css b/ui/goose2/src/shared/styles/globals.css index d1fffb3407..868046a4dd 100644 --- a/ui/goose2/src/shared/styles/globals.css +++ b/ui/goose2/src/shared/styles/globals.css @@ -102,6 +102,12 @@ /* Semantic borders */ --border-default: var(--color-gray-200); + --border-soft: color-mix(in srgb, var(--border-default) 80%, transparent); + --border-soft-divider: color-mix( + in srgb, + var(--border-default) 70%, + transparent + ); --border-input: var(--color-gray-300); --border-input-hover: var(--color-gray-400); --border-strong: var(--color-gray-900); @@ -285,6 +291,12 @@ /* semantic borders */ --border-default: var(--color-gray-700); + --border-soft: color-mix(in srgb, var(--border-default) 80%, transparent); + --border-soft-divider: color-mix( + in srgb, + var(--border-default) 70%, + transparent + ); --border-input: var(--color-gray-700); --border-input-hover: var(--color-gray-600); --border-strong: var(--color-white); @@ -390,6 +402,8 @@ /* semantic borders */ --color-border-default: var(--border-default); + --color-border-soft: var(--border-soft); + --color-border-soft-divider: var(--border-soft-divider); --color-border-input: var(--border-input); --color-border-input-hover: var(--border-input-hover); --color-border-strong: var(--border-strong); @@ -487,6 +501,9 @@ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-accordion-content-open: accordion-content-open 0.2s + cubic-bezier(0.25, 1, 0.5, 1) both; + --animate-accordion-content-close: accordion-content-close 0.2s ease-out both; --animate-caret-blink: caret-blink 1s ease-out infinite; --animate-scale-in: scale-in 0.2s ease; --animate-scale-out: scale-out 0.15s ease; @@ -516,6 +533,28 @@ } } + @keyframes accordion-content-open { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes accordion-content-close { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-2px); + } + } + @keyframes caret-blink { 0%, 70%, diff --git a/ui/goose2/src/shared/ui/MainPanelLayout.tsx b/ui/goose2/src/shared/ui/MainPanelLayout.tsx index d1f9e866de..755d635eb5 100644 --- a/ui/goose2/src/shared/ui/MainPanelLayout.tsx +++ b/ui/goose2/src/shared/ui/MainPanelLayout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { cn } from "@/shared/lib/cn"; /** * Layout wrapper for full-page views (Skills, Agents, etc.). @@ -9,12 +10,20 @@ import type { ReactNode } from "react"; export function MainPanelLayout({ children, backgroundColor = "bg-background", + className, }: { children: ReactNode; backgroundColor?: string; + className?: string; }) { return ( -
+
{children}
); diff --git a/ui/goose2/src/shared/ui/SearchBar.tsx b/ui/goose2/src/shared/ui/SearchBar.tsx index dd6f9727f5..37a6cf1473 100644 --- a/ui/goose2/src/shared/ui/SearchBar.tsx +++ b/ui/goose2/src/shared/ui/SearchBar.tsx @@ -3,6 +3,24 @@ import { Search } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; +const searchBarSizes = { + small: { + wrapper: + "rounded-md border border-border-soft px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground", + icon: "left-3 size-3.5", + input: + "h-auto border-none bg-transparent px-0 pl-6 pr-0 text-xs font-normal shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-offset-0", + inputVariant: "ghost" as const, + }, + default: { + wrapper: "", + icon: "left-3 size-4", + input: + "rounded-lg border-border-soft bg-background pr-3 pl-9 text-sm font-normal hover:border-border-soft focus-visible:border-ring", + inputVariant: "default" as const, + }, +} as const; + interface SearchBarProps { /** Current search value (controlled) */ value: string; @@ -14,6 +32,10 @@ interface SearchBarProps { onKeyDown?: React.KeyboardEventHandler; /** Optional className for the wrapper */ className?: string; + /** Size variant for the search field */ + size?: keyof typeof searchBarSizes; + /** Optional ref for the underlying input */ + inputRef?: React.Ref; } export function SearchBar({ @@ -22,11 +44,22 @@ export function SearchBar({ placeholder, onKeyDown, className, + size = "default", + inputRef, }: SearchBarProps) { + const styles = searchBarSizes[size]; + return ( -
- +
+ onChange(e.target.value)} onKeyDown={onKeyDown} placeholder={placeholder} - className="rounded-lg bg-background pr-3 pl-9 text-sm" + className={cn("w-full placeholder:text-placeholder", styles.input)} />
); diff --git a/ui/goose2/src/shared/ui/accordion.tsx b/ui/goose2/src/shared/ui/accordion.tsx index 1d5c361077..de0bc1fc37 100644 --- a/ui/goose2/src/shared/ui/accordion.tsx +++ b/ui/goose2/src/shared/ui/accordion.tsx @@ -26,20 +26,35 @@ function AccordionItem({ function AccordionTrigger({ className, children, + indicatorClassName, + indicatorPosition = "end", ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + indicatorClassName?: string; + indicatorPosition?: "start" | "end" | "none"; +}) { + const indicator = ( + + ); + return ( svg]:rotate-180", + "group/accordion-trigger focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm transition-all outline-none hover:underline focus-visible:ring-[1px] disabled:pointer-events-none disabled:opacity-50", className, )} {...props} > + {indicatorPosition === "start" ? indicator : null} {children} - + {indicatorPosition === "end" ? indicator : null} ); @@ -61,4 +76,45 @@ function AccordionContent({ ); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; +function AccordionSectionTrigger({ + className, + title, + meta, + indicatorClassName, + ...props +}: Omit, "children"> & { + title: React.ReactNode; + meta?: React.ReactNode; + indicatorClassName?: string; +}) { + return ( + +
+
+

{title}

+ +
+ {meta ? ( +
{meta}
+ ) : null} +
+
+ ); +} + +export { + Accordion, + AccordionContent, + AccordionItem, + AccordionSectionTrigger, + AccordionTrigger, +}; diff --git a/ui/goose2/src/shared/ui/button.test.tsx b/ui/goose2/src/shared/ui/button.test.tsx new file mode 100644 index 0000000000..13af8a364f --- /dev/null +++ b/ui/goose2/src/shared/ui/button.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { IconArrowNarrowLeft } from "@tabler/icons-react"; +import { Button } from "./button"; + +describe("Button", () => { + it("applies the button size to unsized icons", () => { + render( + , + ); + + expect(screen.getByTestId("icon")).toHaveClass("size-3"); + }); + + it("preserves an explicit icon class size", () => { + render( + , + ); + + expect(screen.getByTestId("icon")).toHaveClass("size-4"); + expect(screen.getByTestId("icon")).not.toHaveClass("size-3"); + }); + + it("preserves an explicit icon size prop", () => { + render( + , + ); + + expect(screen.getByTestId("icon")).toHaveAttribute("width", "18"); + expect(screen.getByTestId("icon")).toHaveAttribute("height", "18"); + expect(screen.getByTestId("icon")).not.toHaveClass("size-3"); + }); + + it("renders the back variant with its default chevron icon", () => { + render( + , + ); + + const button = screen.getByRole("button", { name: "Back" }); + const icon = button.querySelector("svg"); + + expect(button).toHaveClass( + "h-8", + "px-3", + "text-xs", + "text-muted-foreground", + ); + expect(icon).not.toBeNull(); + expect(icon).toHaveClass("size-3"); + }); +}); diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx index 8d19e56d98..0e6d754806 100644 --- a/ui/goose2/src/shared/ui/button.tsx +++ b/ui/goose2/src/shared/ui/button.tsx @@ -1,11 +1,12 @@ import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; +import { IconChevronLeft } from "@tabler/icons-react"; import { cn } from "@/shared/lib/cn"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-left text-sm font-normal transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { variant: { @@ -18,29 +19,28 @@ const buttonVariants = cva( outline: "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", "outline-flat": - "border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground", + "border border-border-soft bg-background shadow-none hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", "ghost-light": "font-normal hover:bg-accent hover:text-accent-foreground", + "inline-subtle": + "rounded-md bg-transparent font-normal text-muted-foreground shadow-none hover:bg-muted/70 hover:text-foreground", toolbar: "justify-start bg-transparent font-normal text-foreground shadow-none hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground", + back: "justify-start text-muted-foreground hover:text-foreground", link: "text-brand underline-offset-4 hover:underline", }, size: { - xs: "h-7 px-2.5 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3", - default: - "h-9 px-4 py-2 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5", - sm: "h-8 px-3 text-xs [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3", - lg: "h-10 px-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4", - icon: "h-9 w-9 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-4", - "icon-xs": - "h-7 w-7 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3", - "icon-sm": - "h-8 w-8 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-3.5", - "icon-lg": - "h-10 w-10 [&_svg:not([class*='size-']):not([class*='h-']):not([class*='w-'])]:size-5", + xs: "h-7 px-2.5 text-xs", + default: "h-9 px-4 py-2", + sm: "h-8 px-3 text-xs", + lg: "h-10 px-8", + icon: "h-9 w-9", + "icon-xs": "h-7 w-7", + "icon-sm": "h-8 w-8", + "icon-lg": "h-10 w-10", }, }, compoundVariants: [ @@ -59,6 +59,11 @@ const buttonVariants = cva( size: "default", className: "gap-1.5 px-2.5 text-[13px]", }, + { + variant: "inline-subtle", + size: "xs", + className: "h-6 gap-1.5 px-2 text-[11px]", + }, { variant: "ghost", size: "icon-xs", @@ -91,6 +96,67 @@ const buttonVariants = cva( }, ); +const buttonIconSizeClasses = { + xs: "size-3", + default: "size-3.5", + sm: "size-3", + lg: "size-4", + icon: "size-4", + "icon-xs": "size-3", + "icon-sm": "size-3.5", + "icon-lg": "size-5", +} satisfies Record< + NonNullable["size"]>, + string +>; + +type ButtonIconProps = { + className?: string; + size?: number | string; + width?: number | string; + height?: number | string; + style?: React.CSSProperties; +}; + +function hasExplicitIconDimensions(icon: React.ReactElement) { + return ( + icon.props.size !== undefined || + icon.props.width !== undefined || + icon.props.height !== undefined || + icon.props.style?.width !== undefined || + icon.props.style?.height !== undefined + ); +} + +function renderButtonIcon( + icon: React.ReactNode, + slot: "button-left-icon" | "button-right-icon", + size: VariantProps["size"], +) { + if (!icon) { + return null; + } + + const iconSizeClass = buttonIconSizeClasses[size ?? "default"]; + const content = + React.isValidElement(icon) && + icon.type !== React.Fragment && + !hasExplicitIconDimensions(icon) + ? React.cloneElement(icon, { + className: cn(iconSizeClass, icon.props.className), + }) + : icon; + + return ( + + {content} + + ); +} + export type ButtonProps = React.ButtonHTMLAttributes & VariantProps & { asChild?: boolean; @@ -113,6 +179,11 @@ const Button = React.forwardRef( ref, ) => { const Comp = asChild ? Slot : "button"; + const resolvedLeftIcon = + variant === "back" + ? (leftIcon ??
}> +
Instructions
+ , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText("Instructions")).toBeInTheDocument(); + expect(screen.getByRole("separator")).toBeInTheDocument(); + }); + + it("falls back to a stacked layout on mobile", () => { + mockIsMobile = true; + + render( + Metadata
}> +
Instructions
+ , + ); + + expect(screen.getByText("Metadata")).toBeInTheDocument(); + expect(screen.getByText("Instructions")).toBeInTheDocument(); + expect(screen.queryByRole("separator")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/goose2/src/shared/ui/page-columns.tsx b/ui/goose2/src/shared/ui/page-columns.tsx new file mode 100644 index 0000000000..ade440bde0 --- /dev/null +++ b/ui/goose2/src/shared/ui/page-columns.tsx @@ -0,0 +1,100 @@ +import { useId, type CSSProperties, type ReactNode } from "react"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/shared/lib/cn"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/shared/ui/resizable"; + +interface PageColumnsProps { + sidebar: ReactNode; + children: ReactNode; + className?: string; + sidebarClassName?: string; + contentClassName?: string; + resizable?: boolean; + defaultSidebarSize?: number; + minSidebarSize?: number; + maxSidebarSize?: number; + minContentSize?: number; +} + +export function PageColumns({ + sidebar, + children, + className, + sidebarClassName, + contentClassName, + resizable = true, + defaultSidebarSize = 28, + minSidebarSize = 22, + maxSidebarSize = 38, + minContentSize = 48, +}: PageColumnsProps) { + const isMobile = useIsMobile(); + const pageColumnsId = useId(); + const sidebarSize = `${defaultSidebarSize}%`; + const sidebarMinSize = `${minSidebarSize}%`; + const sidebarMaxSize = `${maxSidebarSize}%`; + const contentSize = `${100 - defaultSidebarSize}%`; + const contentMinSize = `${minContentSize}%`; + const stackedLayoutStyle = { + "--page-columns-sidebar-size": sidebarSize, + } as CSSProperties; + const panelContentStyle = { + overflow: "visible", + maxHeight: "none", + flexGrow: 0, + } as CSSProperties; + + if (isMobile || !resizable) { + return ( +
+
{sidebar}
+
{children}
+
+ ); + } + + return ( + + +
{sidebar}
+
+ + +
{children}
+
+
+ ); +} diff --git a/ui/goose2/src/shared/ui/page-shell.tsx b/ui/goose2/src/shared/ui/page-shell.tsx new file mode 100644 index 0000000000..c98054c3db --- /dev/null +++ b/ui/goose2/src/shared/ui/page-shell.tsx @@ -0,0 +1,146 @@ +import type { ReactNode } from "react"; +import { cn } from "@/shared/lib/cn"; +import { MainPanelLayout } from "./MainPanelLayout"; + +interface ShellProps { + children: ReactNode; + className?: string; + contentClassName?: string; +} + +interface PageHeaderProps { + eyebrow?: ReactNode; + title: ReactNode; + description?: ReactNode; + actions?: ReactNode; + titleElement?: "h1" | "div"; + variant?: "default" | "detail"; + actionsPlacement?: "end" | "below"; + className?: string; + eyebrowClassName?: string; + titleClassName?: string; + descriptionClassName?: string; + actionsClassName?: string; +} + +export function PageShell({ + children, + className, + contentClassName, +}: ShellProps) { + return ( + +
+
+ {children} +
+
+
+ ); +} + +export function DetailPageShell({ + children, + className, + contentClassName, +}: ShellProps) { + return ( + +
+
+ {children} +
+
+
+ ); +} + +export function PageHeader({ + eyebrow, + title, + description, + actions, + titleElement = "h1", + variant = "default", + actionsPlacement = "end", + className, + eyebrowClassName, + titleClassName, + descriptionClassName, + actionsClassName, +}: PageHeaderProps) { + const TitleElement = titleElement; + const actionsBelow = actionsPlacement === "below"; + const titleVariantClassName = + variant === "detail" + ? "font-display text-2xl font-normal tracking-tight text-foreground" + : "text-xl tracking-tight"; + + return ( +
+
+ {eyebrow ? ( +
{eyebrow}
+ ) : null} + + {title} + + {description ? ( +

+ {description} +

+ ) : null} + {actions && actionsBelow ? ( +
+ {actions} +
+ ) : null} +
+ {actions && !actionsBelow ? ( +
+ {actions} +
+ ) : null} +
+ ); +} + +export function FilterRow({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/ui/goose2/src/shared/ui/resizable.tsx b/ui/goose2/src/shared/ui/resizable.tsx index 09c3bf5d6f..4a9be8dbb3 100644 --- a/ui/goose2/src/shared/ui/resizable.tsx +++ b/ui/goose2/src/shared/ui/resizable.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { GripVerticalIcon } from "lucide-react"; +import { IconArrowsHorizontal, IconGripVertical } from "@tabler/icons-react"; import * as ResizablePrimitive from "react-resizable-panels"; import { cn } from "@/shared/lib/cn"; @@ -11,10 +11,7 @@ function ResizablePanelGroup({ return ( ); @@ -28,23 +25,47 @@ function ResizablePanel({ function ResizableHandle({ withHandle, + variant = "default", + indicator = "grip", className, + handleClassName, ...props }: React.ComponentProps & { withHandle?: boolean; + variant?: "default" | "subtle"; + indicator?: "grip" | "arrows"; + handleClassName?: string; }) { + const indicatorIcon = + indicator === "arrows" ? ( + + ) : ( + + ); + return ( div]:rotate-90", + "group focus-visible:ring-ring relative flex w-px shrink-0 items-center justify-center focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden after:absolute after:inset-y-0 after:left-1/2 after:-translate-x-1/2 aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:top-1/2 aria-[orientation=horizontal]:after:-translate-y-1/2 aria-[orientation=horizontal]:after:translate-x-0 [&[aria-orientation=horizontal]>div]:rotate-90", + variant === "subtle" + ? "bg-transparent transition-colors after:w-5 hover:bg-border data-[separator=active]:bg-border-default focus-visible:bg-border-default aria-[orientation=horizontal]:after:h-5 aria-[orientation=horizontal]:after:w-full" + : "bg-border-default after:w-1 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full", className, )} {...props} > {withHandle && ( -
- +
+ {indicatorIcon}
)} diff --git a/ui/goose2/tests/e2e/fixtures/mock-data.ts b/ui/goose2/tests/e2e/fixtures/mock-data.ts index 566f81eff4..a2ab1a41e6 100644 --- a/ui/goose2/tests/e2e/fixtures/mock-data.ts +++ b/ui/goose2/tests/e2e/fixtures/mock-data.ts @@ -75,18 +75,28 @@ export const MOCK_PROJECTS = [ ]; export const MOCK_SKILLS = [ + { + name: "layout", + description: "Improves layout, spacing, and visual hierarchy", + instructions: + "When asked to improve a UI layout, tighten spacing, strengthen hierarchy, and refine composition.", + path: "/mock/.agents/skills/layout/SKILL.md", + global: true, + }, { name: "code-review", description: "Reviews code for quality and best practices", instructions: "When asked to review code, analyze the diff and provide feedback on code quality, potential bugs, and best practices.", path: "/mock/.agents/skills/code-review/SKILL.md", + global: true, }, { name: "test-writer", description: "Generates unit tests for given code", instructions: "When asked to write tests, generate comprehensive unit tests covering edge cases, happy paths, and error scenarios.", - path: "/mock/.agents/skills/test-writer/SKILL.md", + path: "/tmp/alpha/.goose/skills/test-writer/SKILL.md", + global: false, }, ]; diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index e2bf21846b..9cb8676c80 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -95,7 +95,7 @@ export function buildInitScript(options?: { description: s.description, content: s.instructions ?? s.content ?? "", directory: (s.path ?? ("/mock/.agents/skills/" + s.name + "/SKILL.md")).replace(/\\/SKILL\\.md$/, ""), - global: true, + global: s.global ?? true, supportingFiles: [], }); @@ -196,10 +196,10 @@ export function buildInitScript(options?: { content: message.params?.content ?? "", directory: "/mock/.agents/skills/" + (message.params?.name ?? "new-skill"), global: message.params?.global ?? true, - supportingFiles: [], }, }); - case "_goose/sources/update": { + case "_goose/sources/update": + case "goose/sources/update": { const path = message.params?.path ?? "/mock/.agents/skills/updated-skill"; const nextName = message.params?.name; const name = @@ -218,14 +218,16 @@ export function buildInitScript(options?: { description: message.params?.description ?? "", content: message.params?.content ?? "", directory, - global: true, + global: message.params?.global ?? true, supportingFiles: [], }, }); } case "_goose/sources/delete": + case "goose/sources/delete": return jsonRpcResult(message.id, {}); - case "_goose/sources/export": { + case "_goose/sources/export": + case "goose/sources/export": { const path = message.params?.path ?? "/mock/.agents/skills/skill"; const name = String(path).split("/").filter(Boolean).at(-1) ?? "skill"; return jsonRpcResult(message.id, { diff --git a/ui/goose2/tests/e2e/skills.spec.ts b/ui/goose2/tests/e2e/skills.spec.ts index 6827fe8e7b..a37229e582 100644 --- a/ui/goose2/tests/e2e/skills.spec.ts +++ b/ui/goose2/tests/e2e/skills.spec.ts @@ -6,254 +6,125 @@ import { } from "./fixtures/tauri-mock"; test.describe("Skills view", () => { - test("navigates to skills view from sidebar", async ({ + test("navigates to skills view and shows the redesigned header", async ({ tauriMocked: page, }) => { await navigateToSkills(page); + await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible(); await expect( - page.getByText("Reusable instructions for your AI agents"), - ).toBeVisible(); - }); - - test("displays skills from mock data", async ({ tauriMocked: page }) => { - await navigateToSkills(page); - await expect(page.getByText("code-review")).toBeVisible(); - await expect(page.getByText("test-writer")).toBeVisible(); - await expect( - page.getByText("Reviews code for quality and best practices"), - ).toBeVisible(); - await expect( - page.getByText("Generates unit tests for given code"), - ).toBeVisible(); - }); - - test("shows New Skill and Import buttons", async ({ tauriMocked: page }) => { - await navigateToSkills(page); - // The header has "New Skill" and "Import" buttons - await expect( - page.getByRole("button", { name: "New Skill" }).first(), + page.getByText(/Skills are reusable instructions/), ).toBeVisible(); await expect(page.getByRole("button", { name: "Import" })).toBeVisible(); + await expect(page.getByRole("button", { name: "New Skill" })).toBeVisible(); }); - test("opens create skill dialog from header button", async ({ + test("shows skills in the list and opens a dedicated detail page", async ({ tauriMocked: page, }) => { await navigateToSkills(page); - // Click the first "New Skill" button (in the header) - await page.getByRole("button", { name: "New Skill" }).first().click(); + + await expect( + page.getByRole("button", { name: "Open layout details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open code-review details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open test-writer details" }), + ).toBeVisible(); + + await page + .getByRole("button", { name: "Open test-writer details" }) + .click(); + + await expect( + page.getByRole("button", { name: "Back to skills" }), + ).toBeVisible(); + await expect(page.getByText("alpha").first()).toBeVisible(); + await expect(page.getByText("Quality")).toBeVisible(); + await expect( + page.getByText("/tmp/alpha/.goose/skills/test-writer/SKILL.md"), + ).toBeVisible(); + }); + + test("category filtering isolates inferred groups", async ({ + tauriMocked: page, + }) => { + await navigateToSkills(page); + + await page.getByRole("button", { name: "Filter by category" }).click(); + await page.getByRole("menuitemcheckbox", { name: "Design" }).click(); + await page.keyboard.press("Escape"); + + await expect( + page.getByRole("button", { name: "Open layout details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open code-review details" }), + ).not.toBeVisible(); + await expect( + page.getByRole("button", { name: "Open test-writer details" }), + ).not.toBeVisible(); + }); + + test("search filters the list", async ({ tauriMocked: page }) => { + await navigateToSkills(page); + + await page.getByPlaceholder("Search skills").fill("review"); + + await expect(page.getByText("code-review")).toBeVisible(); + await expect(page.getByText("test-writer")).not.toBeVisible(); + }); + + test("project filtering isolates project skills", async ({ + tauriMocked: page, + }) => { + await navigateToSkills(page); + + await page + .getByRole("main") + .getByRole("button", { name: "Alpha", exact: true }) + .click(); + + await expect( + page.getByRole("button", { name: "Open test-writer details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open code-review details" }), + ).not.toBeVisible(); + }); + + test("opens the create skill dialog", async ({ tauriMocked: page }) => { + await navigateToSkills(page); + + await page.getByRole("button", { name: "New Skill" }).click(); + const dialog = page.getByRole("dialog"); await expect(dialog).toBeVisible(); await expect(dialog.locator("h2", { hasText: "New Skill" })).toBeVisible(); - // Check form fields await expect(dialog.getByPlaceholder("my-skill-name")).toBeVisible(); await expect( dialog.getByPlaceholder("What it does and when to use it..."), ).toBeVisible(); - await expect( - dialog.getByPlaceholder("Markdown instructions the agent will follow..."), - ).toBeVisible(); }); - test("create skill dialog has disabled Create Skill button when empty", async ({ + test("shows the empty state when no skills are available", async ({ tauriMocked: page, }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - const dialog = page.getByRole("dialog"); - await expect( - dialog.getByRole("button", { name: "Create Skill" }), - ).toBeDisabled(); - }); - - test("create skill dialog enables Create Skill when name and description filled", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - const dialog = page.getByRole("dialog"); - await dialog.getByPlaceholder("my-skill-name").fill("my-new-skill"); - await dialog - .getByPlaceholder("What it does and when to use it...") - .fill("A test skill"); - await expect( - dialog.getByRole("button", { name: "Create Skill" }), - ).toBeEnabled(); - }); - - test("skill name auto-formats to kebab-case", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - const dialog = page.getByRole("dialog"); - const nameInput = dialog.getByPlaceholder("my-skill-name"); - // Type mixed case with spaces — should auto-format - await nameInput.fill("My Skill Name"); - // The handleNameChange function lowercases and replaces non-alphanumeric with hyphens - await expect(nameInput).toHaveValue("my-skill-name"); - }); - - test("shows validation error for trailing hyphen", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - const dialog = page.getByRole("dialog"); - await dialog.getByPlaceholder("my-skill-name").pressSequentially("test "); - await expect( - dialog.getByText( - "Use 1–64 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.", - ), - ).toBeVisible(); - }); - - test("closes skill dialog via Close button", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - await expect(page.getByRole("dialog")).toBeVisible(); - await page.getByRole("button", { name: "Close" }).click(); - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("closes skill dialog via Cancel button", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByRole("button", { name: "New Skill" }).first().click(); - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - await dialog.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByRole("dialog")).not.toBeVisible(); - }); - - test("skill options menu shows correct items", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByLabel("Options for code-review").click(); - const menu = page.getByRole("menu"); - await expect(menu).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible(); - await expect( - menu.getByRole("menuitem", { name: "Duplicate" }), - ).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: "Export" })).toBeVisible(); - await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible(); - }); - - test("Edit opens edit dialog with pre-filled editable fields", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByLabel("Options for code-review").click(); - await page.getByRole("menuitem", { name: "Edit" }).click(); - - const dialog = page.getByRole("dialog"); - await expect(dialog).toBeVisible(); - await expect(dialog.locator("h2", { hasText: "Edit Skill" })).toBeVisible(); - - const nameInput = dialog.getByPlaceholder("my-skill-name"); - const descriptionInput = dialog.getByPlaceholder( - "What it does and when to use it...", - ); - const instructionsInput = dialog.getByPlaceholder( - "Markdown instructions the agent will follow...", - ); - - await expect(nameInput).toHaveValue("code-review"); - await expect(descriptionInput).toHaveValue( - "Reviews code for quality and best practices", - ); - await expect(instructionsInput).toHaveValue( - "When asked to review code, analyze the diff and provide feedback on code quality, potential bugs, and best practices.", - ); - await expect( - dialog.getByText( - "Path on disk: /mock/.agents/skills/code-review/SKILL.md", - ), - ).toBeVisible(); - - await nameInput.fill("renamed-skill"); - await expect(nameInput).toHaveValue("renamed-skill"); - await expect( - dialog.getByText( - "Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md", - ), - ).toBeVisible(); - }); - - test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => { - await navigateToSkills(page); - await page.getByLabel("Options for code-review").click(); - await page.getByRole("menuitem", { name: "Delete" }).click(); - await expect(page.getByText("Delete skill?")).toBeVisible(); - await expect( - page.getByText(/Are you sure you want to delete.*code-review/), - ).toBeVisible(); - }); - - test("Cancel in delete confirmation closes dialog", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page.getByLabel("Options for code-review").click(); - await page.getByRole("menuitem", { name: "Delete" }).click(); - await expect(page.getByText("Delete skill?")).toBeVisible(); - // Find the Cancel button within the delete confirmation container - const confirmDialog = page.locator(".max-w-sm", { - has: page.getByText("Delete skill?"), - }); - await confirmDialog.getByRole("button", { name: "Cancel" }).click(); - await expect(page.getByText("Delete skill?")).not.toBeVisible(); - // Skill should still be listed - await expect(page.getByText("code-review")).toBeVisible(); - }); - - test("search filters skills", async ({ tauriMocked: page }) => { - await navigateToSkills(page); - await page - .getByPlaceholder("Search skills by name or description...") - .fill("review"); - await expect(page.getByText("code-review")).toBeVisible(); - await expect(page.getByText("test-writer")).not.toBeVisible(); - // Clear search - await page - .getByPlaceholder("Search skills by name or description...") - .clear(); - await expect(page.getByText("code-review")).toBeVisible(); - await expect(page.getByText("test-writer")).toBeVisible(); - }); - - test("search with no results shows empty state", async ({ - tauriMocked: page, - }) => { - await navigateToSkills(page); - await page - .getByPlaceholder("Search skills by name or description...") - .fill("nonexistent-xyz"); - await expect(page.getByText("No matching skills")).toBeVisible(); - await expect(page.getByText("Try a different search term.")).toBeVisible(); - }); - - test("empty skills state shows create prompt", async ({ - tauriMocked: page, - }) => { - // Override with empty skills — must be called BEFORE navigateToSkills await page.addInitScript({ - content: buildInitScript({ personas: [], skills: [] }), + content: buildInitScript({ personas: [], projects: [], skills: [] }), }); + await navigateToSkills(page); + await expect(page.getByText("No skills yet")).toBeVisible(); await expect( - page.getByText("Create a skill or drop a .skill.json file here."), - ).toBeVisible(); - // New Skill button in empty state - await expect( - page.getByRole("button", { name: "New Skill" }).first(), + page.getByText("Create a skill or import one to get started."), ).toBeVisible(); + await expect(page.getByRole("button", { name: "New Skill" })).toHaveCount( + 2, + ); + await expect(page.getByRole("button", { name: "Import" })).toHaveCount(2); }); });