redesign skills library (#8868)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-04-27 23:58:17 -07:00
committed by GitHub
parent 96fa25bce8
commit b35eaf4bf9
33 changed files with 2879 additions and 767 deletions
+3 -3
View File
@@ -30,9 +30,9 @@ pub fn global_skills_dir() -> Option<PathBuf> {
}
/// Canonical writable location for project-scoped skills:
/// `<project>/.goose/skills`.
/// `<project>/.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<PathBuf, Error> {
@@ -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();
+63 -2
View File
@@ -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();
+5
View File
@@ -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:
@@ -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({
</AvatarFallback>
</AvatarRoot>
<div className="min-w-0 flex-1 space-y-2">
<div className="space-y-1">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.displayName")}
</p>
<h2 className="text-base font-semibold tracking-tight">
{displayName}
</h2>
</div>
<DetailField
label={t("editor.displayName")}
contentClassName="text-base font-semibold tracking-tight"
>
{displayName}
</DetailField>
<div className="flex flex-wrap items-center gap-2">
{personaSource === "builtin" ? (
<Badge variant="secondary">
@@ -69,33 +68,35 @@ export function PersonaDetails({
</section>
<section className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2 rounded-xl border border-border bg-background p-4">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.provider")}
</p>
<p className="text-sm font-medium text-foreground">
<div className="rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.provider")}
contentClassName="font-medium"
>
{providerLabel}
</p>
</DetailField>
</div>
<div className="space-y-2 rounded-xl border border-border bg-background p-4">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.model")}
</p>
<p className="text-sm font-medium text-foreground">{modelLabel}</p>
<div className="rounded-xl border border-border bg-background p-4">
<DetailField
label={t("editor.model")}
contentClassName="font-medium"
>
{modelLabel}
</DetailField>
</div>
</section>
<section className="space-y-2 rounded-xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<p className="text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground">
{t("editor.systemPrompt")}
</p>
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
</div>
<DetailField
label={t("editor.systemPrompt")}
meta={
<span className="text-[10px] text-muted-foreground">
{t("common:labels.characterCount", {
count: systemPrompt.length,
})}
</span>
}
/>
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3">
<MessageResponse className="min-w-0 text-sm leading-6">
{systemPrompt}
@@ -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",
]);
});
});
+111 -4
View File
@@ -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<SkillInfo[]> {
export async function listSkills(
projectDirs: string[] = [],
): Promise<SkillInfo[]> {
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<string>();
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<void> {
@@ -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<ProjectInfo, "id" | "name">
>();
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,
};
});
}
@@ -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<SkillCategory, string[]> = {
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<SkillInfo, "name" | "description" | "instructions">,
): 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);
}
@@ -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<string, string>();
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);
}
@@ -186,12 +186,12 @@ export function CreateSkillDialog({
/>
</div>
{isEditing && editingSkill && (
{isEditing && editingSkill ? (
<p className="-mt-2 break-all text-[11px] text-muted-foreground">
{t("dialog.pathOnDisk")}:{" "}
{getRenamedSkillFileLocation(editingSkill.fileLocation, name)}
</p>
)}
) : null}
{/* Instructions */}
<div className="space-y-1">
@@ -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<HTMLButtonElement> {
label: string;
icon: ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
}
function SkillHeaderActionButton({
label,
icon,
type = "button",
tooltipSide = "top",
...props
}: SkillHeaderActionButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type={type}
size="icon-xs"
variant="outline-flat"
aria-label={label}
{...props}
>
{icon}
<span className="sr-only">{label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side={tooltipSide} align="center" sideOffset={8}>
<p>{label}</p>
</TooltipContent>
</Tooltip>
);
}
export function SkillDetailPage({
skill,
onBack,
onEdit,
onReveal,
onShare,
onStartChat,
onDelete,
}: SkillDetailPageProps) {
const { t } = useTranslation(["skills", "common"]);
if (!skill) {
return (
<div className="flex h-full flex-col justify-center px-1 text-sm text-muted-foreground">
<p className="text-sm text-foreground">{t("view.detailEmptyTitle")}</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("view.detailEmptyDescription")}
</p>
</div>
);
}
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 (
<DetailPageShell>
<div className="space-y-5 border-b border-border pb-6">
<Button
type="button"
variant="back"
size="sm"
className="w-fit"
onClick={onBack}
>
{t("view.backToSkills")}
</Button>
<PageHeader
title={skill.name}
variant="detail"
description={skill.description}
actionsPlacement="below"
descriptionClassName="max-w-3xl leading-relaxed"
actions={
<>
{onStartChat ? (
<SkillHeaderActionButton
label={startChatLabel}
icon={<IconMessagePlus className="size-3.5" />}
tooltipSide="top"
onClick={() => onStartChat(skill)}
/>
) : null}
{skill.editable ? (
<SkillHeaderActionButton
label={editLabel}
icon={<IconPencil className="size-3.5" />}
tooltipSide="top"
onClick={() => onEdit(skill)}
/>
) : null}
<SkillHeaderActionButton
label={revealLabel}
icon={<IconFolderOpen className="size-3.5" />}
tooltipSide="top"
onClick={() => onReveal(skill)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="icon-xs"
variant="outline-flat"
aria-label={moreLabel}
>
<IconDots className="size-3.5" />
<span className="sr-only">{moreLabel}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8}>
<DropdownMenuItem onSelect={() => onShare(skill)}>
<IconShare className="size-3.5" />
{t("view.share")}
</DropdownMenuItem>
{skill.editable ? (
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(skill)}
>
<IconTrash className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</>
}
actionsClassName="gap-2"
/>
</div>
<PageColumns
defaultSidebarSize={28}
minSidebarSize={22}
maxSidebarSize={36}
minContentSize={52}
sidebar={
<aside className="space-y-5">
<section className="space-y-5 border-b border-border pb-5">
<DetailField
label={t("view.category")}
contentAs="p"
contentClassName="text-foreground"
>
{t(`view.categories.options.${skill.inferredCategory}`)}
</DetailField>
<DetailField
label={t("view.source")}
contentClassName="space-y-1 text-foreground"
>
{sourceLabels.map((label) => (
<p key={label}>{label}</p>
))}
</DetailField>
{skill.projectLinks.length > 0 ? (
<DetailField
label={t("view.projects")}
contentClassName="space-y-1.5"
>
{skill.projectLinks.map((project) => (
<div key={`${project.id}-${project.workingDir}`}>
<p>{project.name}</p>
<p className="text-xs text-muted-foreground">
{project.workingDir}
</p>
</div>
))}
</DetailField>
) : null}
<DetailField
label={t("view.location")}
contentAs="p"
contentClassName="break-all text-foreground"
>
{skill.fileLocation}
</DetailField>
</section>
</aside>
}
>
<section className="space-y-4 pb-6">
<DetailField label={t("view.instructions")} />
<MessageResponse className="min-w-0 text-sm leading-6">
{skill.instructions || " "}
</MessageResponse>
</section>
</PageColumns>
</DetailPageShell>
);
}
@@ -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<void>;
editingSkill?: {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
};
deletingSkill: SkillInfo | null;
onDeletingSkillChange: (skill: SkillInfo | null) => void;
onConfirmDelete: () => void | Promise<void>;
notification: string | null;
}
export function SkillsDialogs({
dialogOpen,
onDialogClose,
onCreated,
editingSkill,
deletingSkill,
onDeletingSkillChange,
onConfirmDelete,
notification,
}: SkillsDialogsProps) {
const { t } = useTranslation(["skills", "common"]);
return (
<>
<CreateSkillDialog
isOpen={dialogOpen}
onClose={onDialogClose}
onCreated={onCreated}
editingSkill={editingSkill}
/>
<AlertDialog
open={!!deletingSkill}
onOpenChange={(open) => !open && onDeletingSkillChange(null)}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>{t("view.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("view.deleteDescription", {
name: deletingSkill?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common:actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={onConfirmDelete}
>
{t("common:actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{notification && (
<div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 text-sm shadow-popover animate-in fade-in slide-in-from-bottom-2">
{notification}
</div>
)}
</>
);
}
@@ -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<HTMLDivElement>;
onNewSkill: () => void;
onImport: () => void;
}
export function SkillsEmptyState({
hasAnySkills,
isDragOver,
dropHandlers,
onNewSkill,
onImport,
}: SkillsEmptyStateProps) {
const { t } = useTranslation(["skills", "common"]);
return (
<div
{...dropHandlers}
className={cn(
"flex flex-col items-center justify-center gap-3 rounded-2xl py-16 text-muted-foreground transition-colors",
isDragOver ? "bg-muted/40" : "border-transparent",
)}
>
<AtSign className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-normal text-foreground">
{hasAnySkills ? t("view.noMatchesTitle") : t("view.emptyTitle")}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{hasAnySkills
? t("view.noMatchesDescription")
: t("view.emptyDescription")}
</p>
</div>
{!hasAnySkills ? (
<div className="mt-2 flex items-center gap-2">
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={onNewSkill}
>
<Plus className="size-3.5" />
{t("view.newSkill")}
</Button>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={onImport}
>
<Upload className="size-3.5" />
{t("common:actions.import")}
</Button>
</div>
) : null}
</div>
);
}
@@ -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 (
<Accordion
type="multiple"
value={expandedSectionIds}
onValueChange={onExpandedSectionIdsChange}
className="min-h-0 space-y-6"
>
{sections.map((section) => (
<AccordionItem
key={section.id}
value={section.id}
className="group/skills-section overflow-hidden rounded-2xl !border !border-border-soft bg-background"
>
<AccordionSectionTrigger
title={section.title}
meta={t("view.skillCount", {
count: section.skills.length,
displayCount: section.skills.length,
})}
/>
<AccordionContent className="pb-0">
<div className="motion-safe:group-data-[state=closed]/skills-section:animate-accordion-content-close motion-safe:group-data-[state=open]/skills-section:animate-accordion-content-open border-t border-border-soft-divider will-change-[opacity,transform]">
<div className="divide-y divide-border-soft-divider">
{section.skills.map((skill) => (
<div
key={`${section.id}-${skill.id}`}
className="group relative flex items-start gap-3 px-5 py-4 transition-colors hover:bg-muted/20"
>
<button
type="button"
className="absolute inset-0 cursor-pointer outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => onSelectSkill(skill)}
aria-label={t("view.openDetails", { name: skill.name })}
/>
<div className="pointer-events-none relative z-10 min-w-0 flex-1">
<p className="text-sm font-normal text-foreground">
{skill.name}
</p>
{skill.description ? (
<p className="mt-1 line-clamp-2 text-xs font-light text-muted-foreground">
{skill.description}
</p>
) : null}
</div>
{onStartChat ? (
<Button
type="button"
variant="inline-subtle"
size="xs"
className="relative z-20 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
onClick={() => onStartChat(skill)}
aria-label={t("view.startChat", {
name: skill.name,
})}
>
{t("view.useInChat")}
</Button>
) : null}
</div>
))}
</div>
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
);
}
+412 -267
View File
@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={t("view.optionsAria", { name: skill.name })}
className="size-6 rounded-md text-muted-foreground hover:text-foreground"
size="xs"
variant={selectedCategories.length > 0 ? "default" : "outline-flat"}
leftIcon={<IconAdjustmentsHorizontal />}
rightIcon={<IconChevronDown />}
aria-label={t("view.categories.filter")}
>
<MoreHorizontal className="size-3.5" />
{buttonLabel}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={4}>
<DropdownMenuItem onSelect={() => onEdit(skill)}>
<Pencil className="size-3.5" />
{t("common:actions.edit")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onDuplicate(skill)}>
<Copy className="size-3.5" />
{t("common:actions.duplicate")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onExport(skill)}>
<Download className="size-3.5" />
{t("common:actions.export")}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(skill)}
>
<Trash2 className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuLabel>{t("view.categories.label")}</DropdownMenuLabel>
<DropdownMenuSeparator />
{categories.map((category) => (
<DropdownMenuCheckboxItem
key={category}
checked={selectedCategories.includes(category)}
onSelect={(event) => event.preventDefault()}
onCheckedChange={() => toggleCategory(category)}
>
{t(`view.categories.options.${category}`)}
</DropdownMenuCheckboxItem>
))}
{selectedCategories.length > 0 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
onSelectedCategoriesChange([]);
}}
>
{t("view.categories.clear")}
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<Button
type="button"
size="xs"
variant={active ? "default" : "outline-flat"}
onClick={onClick}
>
{children}
</Button>
);
}
export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
const { t } = useTranslation(["skills", "common"]);
const projects = useProjectStore((state) => state.projects);
const [search, setSearch] = useState("");
const [activeFilter, setActiveFilter] = useState<SkillsFilter>("all");
const [selectedCategories, setSelectedCategories] = useState<SkillCategory[]>(
[],
);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<
| {
@@ -107,28 +165,164 @@ export function SkillsView() {
}
| undefined
>(undefined);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [skills, setSkills] = useState<SkillViewInfo[]>([]);
const [loading, setLoading] = useState(true);
const [deletingSkill, setDeletingSkill] = useState<SkillInfo | null>(null);
const [notification, setNotification] = useState<string | null>(null);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [expandedSectionIds, setExpandedSectionIds] = useState<string[]>([]);
const importInputRef = useRef<HTMLInputElement>(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<SkillsSection[]>(() => {
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<HTMLInputElement>) => {
const file = e.target.files?.[0];
async (event: React.ChangeEvent<HTMLInputElement>) => {
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 = (
<SkillsDialogs
dialogOpen={dialogOpen}
onDialogClose={handleDialogClose}
onCreated={loadSkills}
editingSkill={editingSkill}
deletingSkill={deletingSkill}
onDeletingSkillChange={setDeletingSkill}
onConfirmDelete={handleConfirmDeleteSkill}
notification={notification}
/>
);
return (
<div className="flex flex-1 flex-col h-full min-h-0">
<div className="flex-1 overflow-y-auto min-h-0">
<div className="max-w-5xl mx-auto w-full px-6 py-8 space-y-5 page-transition">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-lg font-semibold font-display tracking-tight">
{t("view.title")}
</h1>
<p className="text-xs text-muted-foreground">
{t("view.description")}
</p>
</div>
<div className="flex items-center gap-2">
<input
ref={importInputRef}
type="file"
accept=".skill.json,.json"
className="hidden"
onChange={handleImportFile}
/>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={() => importInputRef.current?.click()}
>
<Upload className="size-3.5" />
{t("common:actions.import")}
</Button>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={handleNewSkill}
>
<Plus className="size-3.5" />
{t("view.newSkill")}
</Button>
</div>
</div>
if (activeSkill) {
return (
<>
<SkillDetailPage
skill={activeSkill}
onBack={() => setActiveSkillId(null)}
onEdit={handleEdit}
onReveal={handleReveal}
onShare={handleExport}
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
onDelete={handleDelete}
/>
{dialogs}
</>
);
}
return (
<PageShell>
<PageHeader
title={t("view.title")}
description={t("view.description")}
titleClassName="font-normal text-foreground"
actions={
<>
<input
ref={importInputRef}
type="file"
accept=".skill.json,.json"
className="hidden"
onChange={handleImportFile}
/>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={() => importInputRef.current?.click()}
>
<Upload className="size-3.5" />
{t("common:actions.import")}
</Button>
<Button
type="button"
variant="outline-flat"
size="xs"
onClick={handleNewSkill}
>
<Plus className="size-3.5" />
{t("view.newSkill")}
</Button>
</>
}
/>
<div
{...dropHandlers}
className={cn(
"rounded-2xl transition-colors",
isDragOver && "bg-muted/50",
)}
>
<div className="space-y-3">
<SearchBar
value={search}
onChange={setSearch}
placeholder={t("view.searchPlaceholder")}
/>
{loading && (
<div className="py-8 text-sm text-muted-foreground" role="status">
{t("common:labels.loading")}
</div>
)}
{!loading && filtered.length > 0 && (
<div className="space-y-2">
{filtered.map((skill) => (
<div
key={skill.name}
className="flex items-start justify-between gap-3 rounded-lg border border-border px-4 py-3"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium">{skill.name}</p>
</div>
{skill.description && (
<p className="text-xs text-muted-foreground mt-0.5">
{skill.description}
</p>
)}
</div>
<SkillCardMenu
skill={skill}
onEdit={handleEdit}
onDuplicate={handleDuplicate}
onExport={handleExport}
onDelete={handleDelete}
/>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={handleNewSkill}
{...dropHandlers}
className={cn(
"h-auto w-full rounded-lg border border-dashed px-4 py-3 text-muted-foreground",
isDragOver
? "border-ring bg-muted"
: "border-border hover:border-border hover:bg-accent/50",
)}
>
<Plus className="size-4" />
<span className="text-sm">{t("view.newSkill")}</span>
<span className="ml-1 text-xs">{t("view.dropFile")}</span>
</Button>
</div>
)}
{!loading && filtered.length === 0 && (
<div
{...dropHandlers}
className={cn(
"flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground rounded-lg border border-dashed transition-colors",
isDragOver ? "border-ring bg-muted" : "border-transparent",
)}
<FilterRow>
<FilterButton
active={activeFilter === "all"}
onClick={() => setActiveFilter("all")}
>
<AtSign className="h-10 w-10 opacity-30" />
<div className="text-center">
<p className="text-sm font-medium">
{skills.length === 0
? t("view.emptyTitle")
: t("view.noMatchesTitle")}
</p>
<p className="text-xs text-muted-foreground mt-1">
{skills.length === 0
? t("view.emptyDescription")
: t("view.noMatchesDescription")}
</p>
</div>
{skills.length === 0 && (
<Button
type="button"
variant="outline"
size="xs"
onClick={handleNewSkill}
className="mt-2"
{t("view.filtersAllSources")}
</FilterButton>
<FilterButton
active={activeFilter === "global"}
onClick={() => setActiveFilter("global")}
>
{t("view.filtersGlobal")}
</FilterButton>
{projectFilters.map((project) => {
const filterValue = `project:${project.id}` as const;
return (
<FilterButton
key={project.id}
active={activeFilter === filterValue}
onClick={() => setActiveFilter(filterValue)}
>
<Plus className="size-3.5" />
{t("view.newSkill")}
</Button>
)}
</div>
)}
{project.name}
</FilterButton>
);
})}
{categoryFilters.length > 0 ? (
<SkillCategoryFilter
categories={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={setSelectedCategories}
/>
) : null}
</FilterRow>
</div>
</div>
{!loading && filteredSkills.length > 0 ? (
<SkillsListSections
sections={groupedSkills}
expandedSectionIds={expandedSectionIds}
onExpandedSectionIdsChange={setExpandedSectionIds}
onSelectSkill={handleSelectSkill}
onStartChat={onStartChatWithSkill ? handleStartChat : undefined}
/>
) : null}
{!loading && filteredSkills.length === 0 ? (
<SkillsEmptyState
hasAnySkills={skills.length > 0}
isDragOver={isDragOver}
dropHandlers={dropHandlers}
onNewSkill={handleNewSkill}
onImport={() => importInputRef.current?.click()}
/>
) : null}
<input
ref={dropFileInputRef}
type="file"
@@ -392,43 +573,7 @@ export function SkillsView() {
onChange={handleDropFileChange}
/>
<CreateSkillDialog
isOpen={dialogOpen}
onClose={handleDialogClose}
onCreated={loadSkills}
editingSkill={editingSkill}
/>
<AlertDialog
open={!!deletingSkill}
onOpenChange={(open) => !open && setDeletingSkill(null)}
>
<AlertDialogContent className="max-w-sm">
<AlertDialogHeader>
<AlertDialogTitle>{t("view.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("view.deleteDescription", {
name: deletingSkill?.name ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common:actions.cancel")}</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={handleConfirmDeleteSkill}
>
{t("common:actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{notification && (
<div className="fixed bottom-4 right-4 z-50 rounded-lg border border-border bg-background px-4 py-3 shadow-popover text-sm animate-in fade-in slide-in-from-bottom-2">
{notification}
</div>
)}
</div>
{dialogs}
</PageShell>
);
}
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (error?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
describe("SkillsView", () => {
describe("Rendering", () => {
it("shows Skills heading and subtitle", async () => {
render(<SkillsView />);
expect(screen.getByText("Skills")).toBeInTheDocument();
expect(
screen.getByText("Reusable instructions for your AI agents"),
).toBeInTheDocument();
});
it("shows New Skill and Import buttons", async () => {
render(<SkillsView />);
expect(screen.getByText("New Skill")).toBeInTheDocument();
expect(screen.getByText("Import")).toBeInTheDocument();
});
it("shows empty state when no skills exist", async () => {
render(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
await screen.findByText("code-review");
it("shows the empty state when no skills are available", async () => {
render(<SkillsView />);
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<typeof mockSkills>();
const secondLoad = createDeferred<typeof mockSkills>();
listSkills
.mockReturnValueOnce(firstLoad.promise)
.mockReturnValueOnce(secondLoad.promise);
const { rerender } = render(<SkillsView />);
expect(screen.getByText("code-review")).toBeInTheDocument();
await waitFor(() => {
expect(listSkills).toHaveBeenCalledTimes(1);
});
mockProjects = [
{
id: "project-beta",
name: "beta",
workingDirs: ["/tmp/beta"],
},
];
rerender(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
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(<SkillsView />);
await screen.findByText("test-writer");
await user.click(screen.getByRole("button", { name: "Goose" }));
expect(screen.getByText("test-writer")).toBeInTheDocument();
expect(screen.getByText("audit")).toBeInTheDocument();
});
});
@@ -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"
}
}
@@ -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"
}
}
+39
View File
@@ -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%,
+10 -1
View File
@@ -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 (
<div className={`flex flex-col ${backgroundColor} min-w-0 h-full min-h-0`}>
<div
className={cn(
"flex h-full min-h-0 min-w-0 flex-col",
backgroundColor,
className,
)}
>
{children}
</div>
);
+36 -3
View File
@@ -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<HTMLInputElement>;
/** 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<HTMLInputElement>;
}
export function SearchBar({
@@ -22,11 +44,22 @@ export function SearchBar({
placeholder,
onKeyDown,
className,
size = "default",
inputRef,
}: SearchBarProps) {
const styles = searchBarSizes[size];
return (
<div className={cn("relative", className)}>
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<div className={cn("relative w-full", styles.wrapper, className)}>
<Search
className={cn(
"pointer-events-none absolute top-1/2 -translate-y-1/2 text-placeholder",
styles.icon,
)}
/>
<Input
inputRef={inputRef}
variant={styles.inputVariant}
type="search"
autoComplete="off"
spellCheck={false}
@@ -34,7 +67,7 @@ export function SearchBar({
onChange={(e) => 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)}
/>
</div>
);
+60 -4
View File
@@ -26,20 +26,35 @@ function AccordionItem({
function AccordionTrigger({
className,
children,
indicatorClassName,
indicatorPosition = "end",
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
}: React.ComponentProps<typeof AccordionPrimitive.Trigger> & {
indicatorClassName?: string;
indicatorPosition?: "start" | "end" | "none";
}) {
const indicator = (
<ChevronDownIcon
className={cn(
"pointer-events-none size-4 shrink-0 text-muted-foreground transition-[opacity,transform] duration-200 group-data-[state=open]/accordion-trigger:rotate-180",
indicatorClassName,
)}
/>
);
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"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 [&[data-state=open]>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}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
{indicatorPosition === "end" ? indicator : null}
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
@@ -61,4 +76,45 @@ function AccordionContent({
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
function AccordionSectionTrigger({
className,
title,
meta,
indicatorClassName,
...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Trigger>, "children"> & {
title: React.ReactNode;
meta?: React.ReactNode;
indicatorClassName?: string;
}) {
return (
<AccordionTrigger
indicatorPosition="none"
className={cn("px-5 py-4 text-left hover:no-underline", className)}
{...props}
>
<div className="flex flex-1 items-baseline justify-between gap-3">
<div className="flex items-center gap-2">
<p className="text-base font-normal text-foreground">{title}</p>
<ChevronDownIcon
className={cn(
"size-4 shrink-0 text-muted-foreground opacity-0 transition-[opacity,transform] duration-200 group-hover/accordion-trigger:opacity-100 group-focus-visible/accordion-trigger:opacity-100 group-data-[state=open]/accordion-trigger:rotate-180",
indicatorClassName,
)}
/>
</div>
{meta ? (
<div className="text-xs font-light text-muted-foreground">{meta}</div>
) : null}
</div>
</AccordionTrigger>
);
}
export {
Accordion,
AccordionContent,
AccordionItem,
AccordionSectionTrigger,
AccordionTrigger,
};
+65
View File
@@ -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(
<Button size="sm" leftIcon={<IconArrowNarrowLeft data-testid="icon" />}>
Back
</Button>,
);
expect(screen.getByTestId("icon")).toHaveClass("size-3");
});
it("preserves an explicit icon class size", () => {
render(
<Button
size="sm"
leftIcon={<IconArrowNarrowLeft data-testid="icon" className="size-4" />}
>
Back
</Button>,
);
expect(screen.getByTestId("icon")).toHaveClass("size-4");
expect(screen.getByTestId("icon")).not.toHaveClass("size-3");
});
it("preserves an explicit icon size prop", () => {
render(
<Button
size="sm"
leftIcon={<IconArrowNarrowLeft data-testid="icon" size={18} />}
>
Back
</Button>,
);
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(
<Button variant="back" size="sm">
Back
</Button>,
);
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");
});
});
+87 -30
View File
@@ -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<VariantProps<typeof buttonVariants>["size"]>,
string
>;
type ButtonIconProps = {
className?: string;
size?: number | string;
width?: number | string;
height?: number | string;
style?: React.CSSProperties;
};
function hasExplicitIconDimensions(icon: React.ReactElement<ButtonIconProps>) {
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<typeof buttonVariants>["size"],
) {
if (!icon) {
return null;
}
const iconSizeClass = buttonIconSizeClasses[size ?? "default"];
const content =
React.isValidElement<ButtonIconProps>(icon) &&
icon.type !== React.Fragment &&
!hasExplicitIconDimensions(icon)
? React.cloneElement(icon, {
className: cn(iconSizeClass, icon.props.className),
})
: icon;
return (
<span
data-slot={slot}
className="inline-flex shrink-0 items-center justify-center"
>
{content}
</span>
);
}
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
@@ -113,6 +179,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref,
) => {
const Comp = asChild ? Slot : "button";
const resolvedLeftIcon =
variant === "back"
? (leftIcon ?? <IconChevronLeft aria-hidden="true" />)
: leftIcon;
return (
<Comp
data-slot="button"
@@ -124,23 +195,9 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
children
) : (
<>
{leftIcon ? (
<span
data-slot="button-left-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{leftIcon}
</span>
) : null}
{renderButtonIcon(resolvedLeftIcon, "button-left-icon", size)}
{children}
{rightIcon ? (
<span
data-slot="button-right-icon"
className="inline-flex shrink-0 items-center justify-center"
>
{rightIcon}
</span>
) : null}
{renderButtonIcon(rightIcon, "button-right-icon", size)}
</>
)}
</Comp>
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { cn } from "@/shared/lib/cn";
interface DetailFieldProps {
label: ReactNode;
children?: ReactNode;
meta?: ReactNode;
className?: string;
headerClassName?: string;
labelClassName?: string;
contentClassName?: string;
contentAs?: "div" | "p";
}
export function DetailField({
label,
children,
meta,
className,
headerClassName,
labelClassName,
contentClassName,
contentAs = "div",
}: DetailFieldProps) {
const Content = contentAs;
return (
<div className={cn("space-y-2", className)}>
<div
className={cn(
"flex items-center justify-between gap-3",
headerClassName,
)}
>
<p
className={cn(
"text-[11px] font-medium uppercase tracking-[0.12em] text-muted-foreground",
labelClassName,
)}
>
{label}
</p>
{meta}
</div>
{children !== undefined && children !== null ? (
<Content className={cn("text-sm text-foreground", contentClassName)}>
{children}
</Content>
) : null}
</div>
);
}
+9 -1
View File
@@ -14,11 +14,19 @@ const variantStyles = {
interface InputProps extends React.ComponentProps<"input"> {
variant?: keyof typeof variantStyles;
inputRef?: React.Ref<HTMLInputElement>;
}
function Input({ className, type, variant = "default", ...props }: InputProps) {
function Input({
className,
type,
variant = "default",
inputRef,
...props
}: InputProps) {
return (
<input
ref={inputRef}
type={type}
data-slot="input"
className={cn(...variantStyles[variant], className)}
@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PageColumns } from "./page-columns";
let mockIsMobile = false;
vi.mock("@/hooks/use-mobile", () => ({
useIsMobile: () => mockIsMobile,
}));
describe("PageColumns", () => {
beforeEach(() => {
mockIsMobile = false;
});
it("renders a resizable desktop split layout", () => {
render(
<PageColumns sidebar={<div>Metadata</div>}>
<div>Instructions</div>
</PageColumns>,
);
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(
<PageColumns sidebar={<div>Metadata</div>}>
<div>Instructions</div>
</PageColumns>,
);
expect(screen.getByText("Metadata")).toBeInTheDocument();
expect(screen.getByText("Instructions")).toBeInTheDocument();
expect(screen.queryByRole("separator")).not.toBeInTheDocument();
});
});
+100
View File
@@ -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 (
<div
className={cn(
"grid gap-10 lg:grid-cols-[minmax(0,var(--page-columns-sidebar-size))_minmax(0,1fr)]",
className,
)}
style={stackedLayoutStyle}
>
<div className={cn("min-w-0", sidebarClassName)}>{sidebar}</div>
<div className={cn("min-w-0", contentClassName)}>{children}</div>
</div>
);
}
return (
<ResizablePanelGroup
orientation="horizontal"
resizeTargetMinimumSize={{ coarse: 36, fine: 12 }}
className={cn("min-h-0 min-w-0 items-stretch", className)}
style={{ height: "auto", overflow: "visible" }}
>
<ResizablePanel
id={`${pageColumnsId}-sidebar`}
defaultSize={sidebarSize}
minSize={sidebarMinSize}
maxSize={sidebarMaxSize}
className="min-w-0 overflow-visible"
style={panelContentStyle}
>
<div className={cn("min-w-0 pr-5", sidebarClassName)}>{sidebar}</div>
</ResizablePanel>
<ResizableHandle
withHandle
variant="subtle"
indicator="arrows"
className="data-[separator=active]:bg-border-default hover:bg-border"
/>
<ResizablePanel
id={`${pageColumnsId}-content`}
defaultSize={contentSize}
minSize={contentMinSize}
className="min-w-0 overflow-visible"
style={panelContentStyle}
>
<div className={cn("min-w-0 pl-5", contentClassName)}>{children}</div>
</ResizablePanel>
</ResizablePanelGroup>
);
}
+146
View File
@@ -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 (
<MainPanelLayout className={className}>
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
contentClassName,
)}
>
{children}
</div>
</div>
</MainPanelLayout>
);
}
export function DetailPageShell({
children,
className,
contentClassName,
}: ShellProps) {
return (
<MainPanelLayout className={className}>
<div className="min-h-0 flex-1 overflow-y-scroll [scrollbar-gutter:stable]">
<div
className={cn(
"mx-auto flex w-full max-w-5xl flex-col gap-8 px-6 py-8 page-transition",
contentClassName,
)}
>
{children}
</div>
</div>
</MainPanelLayout>
);
}
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 (
<div
className={cn(
"flex flex-wrap items-start justify-between gap-4",
actionsBelow && "flex-col items-start justify-start",
className,
)}
>
<div className="min-w-0">
{eyebrow ? (
<div className={cn("mb-3", eyebrowClassName)}>{eyebrow}</div>
) : null}
<TitleElement className={cn(titleVariantClassName, titleClassName)}>
{title}
</TitleElement>
{description ? (
<p
className={cn(
"mt-1 text-sm font-light text-muted-foreground",
descriptionClassName,
)}
>
{description}
</p>
) : null}
{actions && actionsBelow ? (
<div
className={cn(
"mt-4 flex flex-wrap items-center gap-1",
actionsClassName,
)}
>
{actions}
</div>
) : null}
</div>
{actions && !actionsBelow ? (
<div className={cn("flex items-start gap-2", actionsClassName)}>
{actions}
</div>
) : null}
</div>
);
}
export function FilterRow({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("flex flex-wrap items-center gap-2", className)}>
{children}
</div>
);
}
+29 -8
View File
@@ -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 (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
className={cn("flex min-h-0 w-full", className)}
{...props}
/>
);
@@ -28,23 +25,47 @@ function ResizablePanel({
function ResizableHandle({
withHandle,
variant = "default",
indicator = "grip",
className,
handleClassName,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean;
variant?: "default" | "subtle";
indicator?: "grip" | "arrows";
handleClassName?: string;
}) {
const indicatorIcon =
indicator === "arrows" ? (
<IconArrowsHorizontal className="size-2.5" />
) : (
<IconGripVertical className="size-2.5" />
);
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border-default focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>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 && (
<div className="bg-border-default z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
<div
className={cn(
"z-10 flex items-center justify-center border text-muted-foreground",
variant === "subtle"
? "bg-background/95 shadow-xs h-5 w-5 rounded-full border-border-soft opacity-0 transition-all duration-150 ease-out group-hover:scale-100 group-hover:opacity-100 group-focus-visible:scale-100 group-focus-visible:opacity-100 group-data-[separator=active]:scale-100 group-data-[separator=active]:opacity-100 scale-95"
: "bg-border-default h-4 w-3 rounded-xs",
handleClassName,
)}
>
{indicatorIcon}
</div>
)}
</ResizablePrimitive.Separator>
+11 -1
View File
@@ -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,
},
];
+7 -5
View File
@@ -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, {
+91 -220
View File
@@ -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 164 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);
});
});