mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
redesign skills library (#8868)
Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -6,254 +6,125 @@ import {
|
||||
} from "./fixtures/tauri-mock";
|
||||
|
||||
test.describe("Skills view", () => {
|
||||
test("navigates to skills view from sidebar", async ({
|
||||
test("navigates to skills view and shows the redesigned header", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
|
||||
await expect(page.locator("h1", { hasText: "Skills" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Reusable instructions for your AI agents"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("displays skills from mock data", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
await expect(page.getByText("code-review")).toBeVisible();
|
||||
await expect(page.getByText("test-writer")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Reviews code for quality and best practices"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Generates unit tests for given code"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows New Skill and Import buttons", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
// The header has "New Skill" and "Import" buttons
|
||||
await expect(
|
||||
page.getByRole("button", { name: "New Skill" }).first(),
|
||||
page.getByText(/Skills are reusable instructions/),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Import" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Skill" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens create skill dialog from header button", async ({
|
||||
test("shows skills in the list and opens a dedicated detail page", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
// Click the first "New Skill" button (in the header)
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open layout details" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open code-review details" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open test-writer details" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page
|
||||
.getByRole("button", { name: "Open test-writer details" })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Back to skills" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("alpha").first()).toBeVisible();
|
||||
await expect(page.getByText("Quality")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("/tmp/alpha/.goose/skills/test-writer/SKILL.md"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("category filtering isolates inferred groups", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
|
||||
await page.getByRole("button", { name: "Filter by category" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Design" }).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open layout details" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open code-review details" }),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open test-writer details" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("search filters the list", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
|
||||
await page.getByPlaceholder("Search skills").fill("review");
|
||||
|
||||
await expect(page.getByText("code-review")).toBeVisible();
|
||||
await expect(page.getByText("test-writer")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("project filtering isolates project skills", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
|
||||
await page
|
||||
.getByRole("main")
|
||||
.getByRole("button", { name: "Alpha", exact: true })
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open test-writer details" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open code-review details" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("opens the create skill dialog", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
|
||||
await page.getByRole("button", { name: "New Skill" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator("h2", { hasText: "New Skill" })).toBeVisible();
|
||||
// Check form fields
|
||||
await expect(dialog.getByPlaceholder("my-skill-name")).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByPlaceholder("What it does and when to use it..."),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByPlaceholder("Markdown instructions the agent will follow..."),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("create skill dialog has disabled Create Skill button when empty", async ({
|
||||
test("shows the empty state when no skills are available", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Create Skill" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("create skill dialog enables Create Skill when name and description filled", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByPlaceholder("my-skill-name").fill("my-new-skill");
|
||||
await dialog
|
||||
.getByPlaceholder("What it does and when to use it...")
|
||||
.fill("A test skill");
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Create Skill" }),
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
test("skill name auto-formats to kebab-case", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
const nameInput = dialog.getByPlaceholder("my-skill-name");
|
||||
// Type mixed case with spaces — should auto-format
|
||||
await nameInput.fill("My Skill Name");
|
||||
// The handleNameChange function lowercases and replaces non-alphanumeric with hyphens
|
||||
await expect(nameInput).toHaveValue("my-skill-name");
|
||||
});
|
||||
|
||||
test("shows validation error for trailing hyphen", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByPlaceholder("my-skill-name").pressSequentially("test ");
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Use 1–64 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("closes skill dialog via Close button", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("closes skill dialog via Cancel button", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByRole("button", { name: "New Skill" }).first().click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByRole("dialog")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("skill options menu shows correct items", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByLabel("Options for code-review").click();
|
||||
const menu = page.getByRole("menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Edit" })).toBeVisible();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Duplicate" }),
|
||||
).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Export" })).toBeVisible();
|
||||
await expect(menu.getByRole("menuitem", { name: "Delete" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Edit opens edit dialog with pre-filled editable fields", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByLabel("Options for code-review").click();
|
||||
await page.getByRole("menuitem", { name: "Edit" }).click();
|
||||
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.locator("h2", { hasText: "Edit Skill" })).toBeVisible();
|
||||
|
||||
const nameInput = dialog.getByPlaceholder("my-skill-name");
|
||||
const descriptionInput = dialog.getByPlaceholder(
|
||||
"What it does and when to use it...",
|
||||
);
|
||||
const instructionsInput = dialog.getByPlaceholder(
|
||||
"Markdown instructions the agent will follow...",
|
||||
);
|
||||
|
||||
await expect(nameInput).toHaveValue("code-review");
|
||||
await expect(descriptionInput).toHaveValue(
|
||||
"Reviews code for quality and best practices",
|
||||
);
|
||||
await expect(instructionsInput).toHaveValue(
|
||||
"When asked to review code, analyze the diff and provide feedback on code quality, potential bugs, and best practices.",
|
||||
);
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Path on disk: /mock/.agents/skills/code-review/SKILL.md",
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await nameInput.fill("renamed-skill");
|
||||
await expect(nameInput).toHaveValue("renamed-skill");
|
||||
await expect(
|
||||
dialog.getByText(
|
||||
"Path on disk: /mock/.agents/skills/renamed-skill/SKILL.md",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Delete triggers confirmation dialog", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByLabel("Options for code-review").click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(page.getByText("Delete skill?")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Are you sure you want to delete.*code-review/),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Cancel in delete confirmation closes dialog", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page.getByLabel("Options for code-review").click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
await expect(page.getByText("Delete skill?")).toBeVisible();
|
||||
// Find the Cancel button within the delete confirmation container
|
||||
const confirmDialog = page.locator(".max-w-sm", {
|
||||
has: page.getByText("Delete skill?"),
|
||||
});
|
||||
await confirmDialog.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(page.getByText("Delete skill?")).not.toBeVisible();
|
||||
// Skill should still be listed
|
||||
await expect(page.getByText("code-review")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search filters skills", async ({ tauriMocked: page }) => {
|
||||
await navigateToSkills(page);
|
||||
await page
|
||||
.getByPlaceholder("Search skills by name or description...")
|
||||
.fill("review");
|
||||
await expect(page.getByText("code-review")).toBeVisible();
|
||||
await expect(page.getByText("test-writer")).not.toBeVisible();
|
||||
// Clear search
|
||||
await page
|
||||
.getByPlaceholder("Search skills by name or description...")
|
||||
.clear();
|
||||
await expect(page.getByText("code-review")).toBeVisible();
|
||||
await expect(page.getByText("test-writer")).toBeVisible();
|
||||
});
|
||||
|
||||
test("search with no results shows empty state", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
await navigateToSkills(page);
|
||||
await page
|
||||
.getByPlaceholder("Search skills by name or description...")
|
||||
.fill("nonexistent-xyz");
|
||||
await expect(page.getByText("No matching skills")).toBeVisible();
|
||||
await expect(page.getByText("Try a different search term.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty skills state shows create prompt", async ({
|
||||
tauriMocked: page,
|
||||
}) => {
|
||||
// Override with empty skills — must be called BEFORE navigateToSkills
|
||||
await page.addInitScript({
|
||||
content: buildInitScript({ personas: [], skills: [] }),
|
||||
content: buildInitScript({ personas: [], projects: [], skills: [] }),
|
||||
});
|
||||
|
||||
await navigateToSkills(page);
|
||||
|
||||
await expect(page.getByText("No skills yet")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("Create a skill or drop a .skill.json file here."),
|
||||
).toBeVisible();
|
||||
// New Skill button in empty state
|
||||
await expect(
|
||||
page.getByRole("button", { name: "New Skill" }).first(),
|
||||
page.getByText("Create a skill or import one to get started."),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "New Skill" })).toHaveCount(
|
||||
2,
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "Import" })).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user