chore: goose2 skill refactor (#8897)

This commit is contained in:
Lifei Zhou
2026-04-30 14:20:56 +10:00
committed by GitHub
parent a0fa5f5791
commit 66692e9517
17 changed files with 553 additions and 759 deletions
+10 -39
View File
@@ -1,12 +1,12 @@
import type { SourceEntry } from "@aaif/goose-sdk";
import { getClient } from "@/shared/api/acpConnection";
import {
basename,
deriveProjectRoot,
getSkillFileLocation,
} from "../lib/skillsPath";
const SKILL_SOURCE_TYPE = "skill" as const;
const PROJECT_SKILLS_MARKERS = [
"/.agents/skills/",
"/.goose/skills/",
"/.claude/skills/",
];
export interface SkillProjectLink {
id: string;
@@ -23,49 +23,22 @@ export interface SkillInfo {
instructions: string;
path: string;
fileLocation: string;
directoryPath: string;
sourceKind: SkillSourceKind;
sourceLabel: string;
projectLinks: SkillProjectLink[];
editable: boolean;
}
export type EditingSkill = Pick<
SkillInfo,
"name" | "description" | "instructions" | "path" | "fileLocation"
>;
type SkillSourceEntry = SourceEntry & { type: typeof SKILL_SOURCE_TYPE };
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)
? `${directory}SKILL.md`
: `${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
@@ -90,12 +63,10 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
instructions: source.content,
path: source.directory,
fileLocation: getSkillFileLocation(source.directory),
directoryPath: source.directory,
sourceKind,
sourceLabel:
sourceKind === "global" ? "Personal" : projectName || "Project",
projectLinks,
editable: true,
};
}
@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { exportSkill, importSkills, type SkillInfo } from "../api/skills";
import { downloadExport } from "../lib/skillsHelpers";
export function useSkillImportExport(onAfterImport: () => Promise<void>) {
const { t } = useTranslation(["skills"]);
const handleExport = async (skill: SkillInfo) => {
try {
const result = await exportSkill(skill.path);
downloadExport(result.json, result.filename);
toast.success(t("view.exportedTo", { filename: result.filename }));
} catch {
toast.error(t("view.exportError"));
}
};
const handleImport = async (fileBytes: number[], fileName: string) => {
try {
await importSkills(fileBytes, fileName);
await onAfterImport();
toast.success(t("view.importSuccess"));
} catch {
toast.error(t("view.importError"));
}
};
const fileImport = useFileImportZone({ onImportFile: handleImport });
return { ...fileImport, handleExport };
}
@@ -1,6 +1,6 @@
import type { SkillInfo } from "../api/skills";
export const SKILL_CATEGORY_ORDER = [
const SKILL_CATEGORY_ORDER = [
"design",
"engineering",
"quality",
@@ -242,7 +242,7 @@ function inferCategoryFromSlug(slug: string): SkillCategory | null {
return null;
}
export function inferSkillCategory(
function inferSkillCategory(
skill: Pick<SkillInfo, "name" | "description" | "instructions">,
): SkillCategory {
const slug = skill.name.toLowerCase();
@@ -272,7 +272,7 @@ export function inferSkillCategory(
return bestScore > 0 ? bestCategory : "general";
}
export function withInferredSkillCategory(skill: SkillInfo): SkillViewInfo {
function withInferredSkillCategory(skill: SkillInfo): SkillViewInfo {
return {
...skill,
inferredCategory: inferSkillCategory(skill),
@@ -284,3 +284,11 @@ export function withInferredSkillCategories(
): SkillViewInfo[] {
return skills.map(withInferredSkillCategory);
}
export function uniqueSkillCategories(
skills: SkillViewInfo[],
): SkillCategory[] {
return SKILL_CATEGORY_ORDER.filter((category) =>
skills.some((skill) => skill.inferredCategory === category),
);
}
@@ -1,5 +1,40 @@
import type { SkillInfo } from "../api/skills";
import { SKILL_CATEGORY_ORDER, type SkillViewInfo } from "./skillCategories";
import type { SkillCategory, SkillViewInfo } from "./skillCategories";
export type SkillsFilter = "all" | "global" | `project:${string}`;
export interface SkillsSection {
id: string;
title: string;
skills: SkillViewInfo[];
}
// Mirrors crates/goose/src/skills/mod.rs::validate_skill_name.
// Keep in sync with the Rust rule.
const MAX_SKILL_NAME_LENGTH = 64;
export function isValidSkillName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_SKILL_NAME_LENGTH &&
!name.startsWith("-") &&
!name.endsWith("-") &&
[...name].every(
(char) =>
(char >= "a" && char <= "z") ||
(char >= "0" && char <= "9") ||
char === "-",
)
);
}
export function formatSkillName(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-/, "")
.slice(0, MAX_SKILL_NAME_LENGTH);
}
export function uniqueProjectFilters(skills: SkillInfo[]) {
const seen = new Map<string, string>();
@@ -15,12 +50,6 @@ export function uniqueProjectFilters(skills: SkillInfo[]) {
.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" }) ||
@@ -29,6 +58,104 @@ export function compareSkillsByName(a: SkillInfo, b: SkillInfo) {
);
}
export function filterSkills(
skills: SkillViewInfo[],
filters: {
search: string;
activeFilter: SkillsFilter;
selectedCategories: SkillCategory[];
},
getCategoryLabel: (category: SkillCategory) => string,
): SkillViewInfo[] {
const searchTerm = filters.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) ||
getCategoryLabel(skill.inferredCategory)
.toLowerCase()
.includes(searchTerm);
const matchesFilter =
filters.activeFilter === "all"
? true
: filters.activeFilter === "global"
? skill.sourceKind === "global"
: skill.projectLinks.some(
(project) => `project:${project.id}` === filters.activeFilter,
);
const matchesCategory =
filters.selectedCategories.length === 0 ||
filters.selectedCategories.includes(skill.inferredCategory);
return matchesSearch && matchesFilter && matchesCategory;
});
}
export function groupSkills(
filteredSkills: SkillViewInfo[],
activeFilter: SkillsFilter,
projectFilters: { id: string; name: string }[],
labels: { personalTitle: string; projectsFallback: string },
): SkillsSection[] {
if (activeFilter === "global") {
return [
{
id: "personal",
title: labels.personalTitle,
skills: [...filteredSkills].sort(compareSkillsByName),
},
];
}
if (activeFilter.startsWith("project:")) {
const projectId = activeFilter.slice("project:".length);
const projectName =
projectFilters.find((project) => project.id === projectId)?.name ??
labels.projectsFallback;
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: labels.personalTitle,
skills: personalSkills,
},
]
: []),
...projectSections,
];
}
export function downloadExport(json: string, filename: string) {
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
@@ -0,0 +1,49 @@
const PROJECT_SKILLS_MARKERS = [
"/.agents/skills/",
"/.goose/skills/",
"/.claude/skills/",
];
export function normalizePath(path: string): string {
return path.replace(/\\/g, "/");
}
export function basename(path: string): string {
const trimmed = normalizePath(path).replace(/\/+$/, "");
const idx = trimmed.lastIndexOf("/");
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed;
}
export function getSkillFileLocation(directory: string): string {
const separator = directory.includes("\\") ? "\\" : "/";
return directory.endsWith(separator)
? `${directory}SKILL.md`
: `${directory}${separator}SKILL.md`;
}
export 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;
}
export function getRenamedSkillFileLocation(
fileLocation: string,
name: string,
): string {
const separator = fileLocation.includes("\\") ? "\\" : "/";
const parts = fileLocation.split(separator);
if (parts.length >= 2) {
parts[parts.length - 2] = name;
}
return parts.join(separator);
}
@@ -0,0 +1,93 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
IconAdjustmentsHorizontal,
IconChevronDown,
} from "@tabler/icons-react";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import type { SkillCategory } from "../lib/skillCategories";
interface SkillCategoryFilterProps {
categories: SkillCategory[];
selectedCategories: SkillCategory[];
onSelectedCategoriesChange: (categories: SkillCategory[]) => void;
}
export function SkillCategoryFilter({
categories,
selectedCategories,
onSelectedCategoriesChange,
}: SkillCategoryFilterProps) {
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"
size="xs"
variant={selectedCategories.length > 0 ? "default" : "outline-flat"}
leftIcon={<IconAdjustmentsHorizontal />}
rightIcon={<IconChevronDown />}
aria-label={t("view.categories.filter")}
>
{buttonLabel}
</Button>
</DropdownMenuTrigger>
<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>
);
}
@@ -128,14 +128,12 @@ export function SkillDetailPage({
onClick={() => onStartChat(skill)}
/>
) : null}
{skill.editable ? (
<SkillHeaderActionButton
label={editLabel}
icon={<IconPencil className="size-3.5" />}
tooltipSide="top"
onClick={() => onEdit(skill)}
/>
) : null}
<SkillHeaderActionButton
label={editLabel}
icon={<IconPencil className="size-3.5" />}
tooltipSide="top"
onClick={() => onEdit(skill)}
/>
<SkillHeaderActionButton
label={revealLabel}
icon={<IconFolderOpen className="size-3.5" />}
@@ -159,15 +157,13 @@ export function SkillDetailPage({
<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}
<DropdownMenuItem
variant="destructive"
onSelect={() => onDelete(skill)}
>
<IconTrash className="size-3.5" />
{t("common:actions.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
@@ -11,66 +11,23 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { createSkill, updateSkill } from "../api/skills";
import { createSkill, updateSkill, type EditingSkill } from "../api/skills";
import { formatSkillName, isValidSkillName } from "../lib/skillsHelpers";
import { getRenamedSkillFileLocation } from "../lib/skillsPath";
const MAX_SKILL_NAME_LENGTH = 64;
function isValidSkillName(name: string): boolean {
return (
name.length > 0 &&
name.length <= MAX_SKILL_NAME_LENGTH &&
!name.startsWith("-") &&
!name.endsWith("-") &&
[...name].every(
(char) =>
(char >= "a" && char <= "z") ||
(char >= "0" && char <= "9") ||
char === "-",
)
);
}
function formatSkillName(raw: string): string {
return raw
.toLowerCase()
.replace(/[^a-z0-9-]/g, "-")
.replace(/^-/, "")
.slice(0, MAX_SKILL_NAME_LENGTH);
}
function getRenamedSkillFileLocation(
fileLocation: string,
name: string,
): string {
const separator = fileLocation.includes("\\") ? "\\" : "/";
const parts = fileLocation.split(separator);
if (parts.length >= 2) {
parts[parts.length - 2] = name;
}
return parts.join(separator);
}
interface CreateSkillDialogProps {
interface SkillEditorProps {
isOpen: boolean;
onClose: () => void;
onCreated?: () => void;
editingSkill?: {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
};
editingSkill?: EditingSkill;
}
export function CreateSkillDialog({
export function SkillEditor({
isOpen,
onClose,
onCreated,
editingSkill,
}: CreateSkillDialogProps) {
}: SkillEditorProps) {
const { t } = useTranslation(["skills", "common"]);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
@@ -10,24 +10,17 @@ import {
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { buttonVariants } from "@/shared/ui/button";
import { CreateSkillDialog } from "./CreateSkillDialog";
import type { SkillInfo } from "../api/skills";
import { SkillEditor } from "./SkillEditor";
import type { EditingSkill, 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;
};
editingSkill?: EditingSkill;
deletingSkill: SkillInfo | null;
onDeletingSkillChange: (skill: SkillInfo | null) => void;
onConfirmDelete: () => void | Promise<void>;
notification: string | null;
}
export function SkillsDialogs({
@@ -38,13 +31,12 @@ export function SkillsDialogs({
deletingSkill,
onDeletingSkillChange,
onConfirmDelete,
notification,
}: SkillsDialogsProps) {
const { t } = useTranslation(["skills", "common"]);
return (
<>
<CreateSkillDialog
<SkillEditor
isOpen={dialogOpen}
onClose={onDialogClose}
onCreated={onCreated}
@@ -77,12 +69,6 @@ export function SkillsDialogs({
</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>
)}
</>
);
}
@@ -9,12 +9,7 @@ import { Button } from "@/shared/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { IconMessagePlus } from "@tabler/icons-react";
import type { SkillViewInfo } from "../lib/skillCategories";
export interface SkillsSection {
id: string;
title: string;
skills: SkillViewInfo[];
}
import type { SkillsSection } from "../lib/skillsHelpers";
interface SkillsListSectionsProps {
sections: SkillsSection[];
@@ -0,0 +1,107 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/shared/lib/cn";
import { SearchBar } from "@/shared/ui/SearchBar";
import { Button } from "@/shared/ui/button";
import { FilterRow } from "@/shared/ui/page-shell";
import { SkillCategoryFilter } from "./SkillCategoryFilter";
import type { SkillCategory } from "../lib/skillCategories";
import type { SkillsFilter } from "../lib/skillsHelpers";
interface SkillsToolbarProps {
search: string;
onSearchChange: (value: string) => void;
activeFilter: SkillsFilter;
onActiveFilterChange: (filter: SkillsFilter) => void;
projectFilters: { id: string; name: string }[];
categoryFilters: SkillCategory[];
selectedCategories: SkillCategory[];
onSelectedCategoriesChange: (categories: SkillCategory[]) => void;
dropHandlers?: React.HTMLAttributes<HTMLDivElement>;
isDragOver?: boolean;
}
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 SkillsToolbar({
search,
onSearchChange,
activeFilter,
onActiveFilterChange,
projectFilters,
categoryFilters,
selectedCategories,
onSelectedCategoriesChange,
dropHandlers,
isDragOver,
}: SkillsToolbarProps) {
const { t } = useTranslation(["skills"]);
return (
<div
{...dropHandlers}
className={cn(
"space-y-3 rounded-2xl transition-colors",
isDragOver && "bg-muted/50",
)}
>
<SearchBar
value={search}
onChange={onSearchChange}
placeholder={t("view.searchPlaceholder")}
/>
<FilterRow>
<FilterButton
active={activeFilter === "all"}
onClick={() => onActiveFilterChange("all")}
>
{t("view.filtersAllSources")}
</FilterButton>
<FilterButton
active={activeFilter === "global"}
onClick={() => onActiveFilterChange("global")}
>
{t("view.filtersGlobal")}
</FilterButton>
{projectFilters.map((project) => {
const filterValue = `project:${project.id}` as const;
return (
<FilterButton
key={project.id}
active={activeFilter === filterValue}
onClick={() => onActiveFilterChange(filterValue)}
>
{project.name}
</FilterButton>
);
})}
{categoryFilters.length > 0 ? (
<SkillCategoryFilter
categories={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={onSelectedCategoriesChange}
/>
) : null}
</FilterRow>
</div>
);
}
+55 -325
View File
@@ -1,151 +1,41 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Upload } from "lucide-react";
import {
IconAdjustmentsHorizontal,
IconChevronDown,
} from "@tabler/icons-react";
import { toast } from "sonner";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import { cn } from "@/shared/lib/cn";
import { SearchBar } from "@/shared/ui/SearchBar";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { FilterRow, PageHeader, PageShell } from "@/shared/ui/page-shell";
import { useFileImportZone } from "@/shared/hooks/useFileImportZone";
import { PageHeader, PageShell } from "@/shared/ui/page-shell";
import { revealInFileManager } from "@/shared/lib/fileManager";
import { useSkillImportExport } from "../hooks/useSkillImportExport";
import { SkillDetailPage } from "./SkillDetailPage";
import { SkillsDialogs } from "./SkillsDialogs";
import { SkillsEmptyState } from "./SkillsEmptyState";
import { SkillsListSections, type SkillsSection } from "./SkillsListSections";
import { SkillsListSections } from "./SkillsListSections";
import { SkillsToolbar } from "./SkillsToolbar";
import { hydrateProjectNames } from "../lib/projectHydration";
import {
compareSkillsByName,
downloadExport,
uniqueSkillCategories,
filterSkills,
groupSkills,
uniqueProjectFilters,
type SkillsFilter,
} from "../lib/skillsHelpers";
import {
deleteSkill,
exportSkill,
importSkills,
listSkills,
type EditingSkill,
type SkillInfo,
} from "../api/skills";
import {
uniqueSkillCategories,
withInferredSkillCategories,
type SkillCategory,
type SkillViewInfo,
} from "../lib/skillCategories";
type SkillsFilter = "all" | "global" | `project:${string}`;
function SkillCategoryFilter({
categories,
selectedCategories,
onSelectedCategoriesChange,
}: {
categories: SkillCategory[];
selectedCategories: SkillCategory[];
onSelectedCategoriesChange: (categories: SkillCategory[]) => void;
}) {
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"
size="xs"
variant={selectedCategories.length > 0 ? "default" : "outline-flat"}
leftIcon={<IconAdjustmentsHorizontal />}
rightIcon={<IconChevronDown />}
aria-label={t("view.categories.filter")}
>
{buttonLabel}
</Button>
</DropdownMenuTrigger>
<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>
);
}
interface SkillsViewProps {
onStartChatWithSkill?: (skill: SkillInfo, 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);
@@ -155,23 +45,14 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
[],
);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<
| {
name: string;
description: string;
instructions: string;
path: string;
fileLocation: string;
}
| undefined
>(undefined);
const [editingSkill, setEditingSkill] = useState<EditingSkill | undefined>(
undefined,
);
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 () => {
@@ -191,13 +72,14 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
} catch {
if (loadRequestIdRef.current === requestId) {
setSkills([]);
toast.error(t("view.loadError"));
}
} finally {
if (loadRequestIdRef.current === requestId) {
setLoading(false);
}
}
}, [projects]);
}, [projects, t]);
useEffect(() => {
loadSkills();
@@ -226,90 +108,24 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
);
}, [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 filteredSkills = useMemo(
() =>
filterSkills(
skills,
{ search, activeFilter, selectedCategories },
(category) => t(`view.categories.options.${category}`),
),
[skills, search, activeFilter, selectedCategories, t],
);
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]);
const groupedSkills = useMemo(
() =>
groupSkills(filteredSkills, activeFilter, projectFilters, {
personalTitle: t("view.filtersGlobal"),
projectsFallback: t("view.projects"),
}),
[filteredSkills, activeFilter, projectFilters, t],
);
useEffect(() => {
const nextIds = groupedSkills.map((section) => section.id);
@@ -335,8 +151,9 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
if (activeSkillId === deletingSkill.id) {
setActiveSkillId(null);
}
toast.success(t("view.deleteSuccess", { name: deletingSkill.name }));
} catch {
// best-effort
toast.error(t("view.deleteError"));
}
setDeletingSkill(null);
};
@@ -352,17 +169,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
setDialogOpen(true);
};
const handleExport = async (skill: SkillInfo) => {
try {
const result = await exportSkill(skill.path);
downloadExport(result.json, result.filename);
setNotification(t("view.exportedTo", { filename: result.filename }));
setTimeout(() => setNotification(null), 3000);
} catch (err) {
console.error("Failed to export skill:", err);
}
};
const handleReveal = useCallback((skill: SkillInfo) => {
void revealInFileManager(skill.path);
}, []);
@@ -374,27 +180,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
[onStartChatWithSkill],
);
const handleImportFile = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const arrayBuffer = await file.arrayBuffer();
const bytes = Array.from(new Uint8Array(arrayBuffer));
await importSkills(bytes, file.name);
await loadSkills();
} catch (error) {
console.error("Failed to import skill:", error);
}
if (importInputRef.current) {
importInputRef.current.value = "";
}
},
[loadSkills],
);
const handleDialogClose = () => {
setDialogOpen(false);
setEditingSkill(undefined);
@@ -405,24 +190,14 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
setDialogOpen(true);
};
const handleDropImport = useCallback(
async (fileBytes: number[], fileName: string) => {
try {
await importSkills(fileBytes, fileName);
await loadSkills();
} catch (error) {
console.error("Failed to import skill:", error);
}
},
[loadSkills],
);
const {
fileInputRef: dropFileInputRef,
fileInputRef,
isDragOver,
dropHandlers,
handleFileChange: handleDropFileChange,
} = useFileImportZone({ onImportFile: handleDropImport });
handleFileChange,
openFilePicker,
handleExport,
} = useSkillImportExport(loadSkills);
const handleSelectSkill = (skill: SkillViewInfo) => {
setActiveSkillId(skill.id);
@@ -437,7 +212,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
deletingSkill={deletingSkill}
onDeletingSkillChange={setDeletingSkill}
onConfirmDelete={handleConfirmDeleteSkill}
notification={notification}
/>
);
@@ -466,18 +240,11 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
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()}
onClick={openFilePicker}
>
<Upload className="size-3.5" />
{t("common:actions.import")}
@@ -495,55 +262,18 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
}
/>
<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")}
/>
<FilterRow>
<FilterButton
active={activeFilter === "all"}
onClick={() => setActiveFilter("all")}
>
{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)}
>
{project.name}
</FilterButton>
);
})}
{categoryFilters.length > 0 ? (
<SkillCategoryFilter
categories={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={setSelectedCategories}
/>
) : null}
</FilterRow>
</div>
</div>
<SkillsToolbar
search={search}
onSearchChange={setSearch}
activeFilter={activeFilter}
onActiveFilterChange={setActiveFilter}
projectFilters={projectFilters}
categoryFilters={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={setSelectedCategories}
dropHandlers={dropHandlers}
isDragOver={isDragOver}
/>
{!loading && filteredSkills.length > 0 ? (
<SkillsListSections
@@ -561,16 +291,16 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
isDragOver={isDragOver}
dropHandlers={dropHandlers}
onNewSkill={handleNewSkill}
onImport={() => importInputRef.current?.click()}
onImport={openFilePicker}
/>
) : null}
<input
ref={dropFileInputRef}
ref={fileInputRef}
type="file"
accept=".skill.json,.json"
className="hidden"
onChange={handleDropFileChange}
onChange={handleFileChange}
/>
{dialogs}
@@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { CreateSkillDialog } from "../CreateSkillDialog";
import { SkillEditor } from "../SkillEditor";
vi.mock("../../api/skills", () => ({
createSkill: vi.fn().mockResolvedValue(undefined),
@@ -23,7 +23,7 @@ const defaultProps = {
onCreated: vi.fn(),
};
describe("CreateSkillDialog", () => {
describe("SkillEditor", () => {
beforeEach(() => {
vi.clearAllMocks();
});
@@ -32,23 +32,23 @@ describe("CreateSkillDialog", () => {
describe("rendering", () => {
it("does not render when isOpen is false", () => {
render(<CreateSkillDialog {...defaultProps} isOpen={false} />);
render(<SkillEditor {...defaultProps} isOpen={false} />);
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("renders dialog when isOpen is true", () => {
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
expect(screen.getByRole("dialog")).toBeInTheDocument();
});
it('shows "New Skill" title in create mode', () => {
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
expect(screen.getByText("New skill")).toBeInTheDocument();
});
it('shows "Edit Skill" title when editingSkill is provided', () => {
render(
<CreateSkillDialog
<SkillEditor
{...defaultProps}
editingSkill={{
name: "my-skill",
@@ -68,7 +68,7 @@ describe("CreateSkillDialog", () => {
describe("name validation", () => {
it("allows consecutive hyphens to match backend validation", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
const descriptionInput = screen.getByPlaceholderText(
"What it does and when to use it...",
@@ -88,7 +88,7 @@ describe("CreateSkillDialog", () => {
it("auto-formats input (uppercase to lowercase, spaces to hyphens)", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.type(nameInput, "My Skill");
@@ -97,7 +97,7 @@ describe("CreateSkillDialog", () => {
it("shows validation error for invalid name with trailing hyphen", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.type(nameInput, "a-");
@@ -109,7 +109,7 @@ describe("CreateSkillDialog", () => {
it("truncates names at 64 characters", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
const longName = "a".repeat(65);
@@ -122,7 +122,7 @@ describe("CreateSkillDialog", () => {
});
it("save button is disabled when name is empty", () => {
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
const saveButton = screen.getByRole("button", { name: /create skill/i });
expect(saveButton).toBeDisabled();
});
@@ -140,9 +140,7 @@ describe("CreateSkillDialog", () => {
};
it("pre-fills fields with existing skill data", () => {
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
render(<SkillEditor {...defaultProps} editingSkill={editingSkill} />);
expect(screen.getByPlaceholderText("my-skill-name")).toHaveValue(
"code-review",
);
@@ -158,9 +156,7 @@ describe("CreateSkillDialog", () => {
it("name field is editable in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
render(<SkillEditor {...defaultProps} editingSkill={editingSkill} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
@@ -170,9 +166,7 @@ describe("CreateSkillDialog", () => {
});
it("shows the skill path on disk as minimal helper text in edit mode", () => {
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
render(<SkillEditor {...defaultProps} editingSkill={editingSkill} />);
expect(
screen.getByText(
@@ -183,9 +177,7 @@ describe("CreateSkillDialog", () => {
it("updates the path helper text when the name changes in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
render(<SkillEditor {...defaultProps} editingSkill={editingSkill} />);
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
@@ -199,9 +191,7 @@ describe("CreateSkillDialog", () => {
});
it('save button text is "Save Changes" in edit mode', () => {
render(
<CreateSkillDialog {...defaultProps} editingSkill={editingSkill} />,
);
render(<SkillEditor {...defaultProps} editingSkill={editingSkill} />);
expect(
screen.getByRole("button", { name: /save changes/i }),
).toBeInTheDocument();
@@ -209,7 +199,7 @@ describe("CreateSkillDialog", () => {
it("allows editing skills whose names contain consecutive hyphens", () => {
render(
<CreateSkillDialog
<SkillEditor
{...defaultProps}
editingSkill={{
name: "double--hyphen",
@@ -235,7 +225,7 @@ describe("CreateSkillDialog", () => {
describe("form submission", () => {
it("calls createSkill API on save in create mode", async () => {
const user = userEvent.setup();
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
await user.type(screen.getByPlaceholderText("my-skill-name"), "my-skill");
await user.type(
@@ -260,7 +250,7 @@ describe("CreateSkillDialog", () => {
it("calls updateSkill API on save in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog
<SkillEditor
{...defaultProps}
editingSkill={{
name: "code-review",
@@ -292,7 +282,7 @@ describe("CreateSkillDialog", () => {
it("calls updateSkill API with the renamed skill name in edit mode", async () => {
const user = userEvent.setup();
render(
<CreateSkillDialog
<SkillEditor
{...defaultProps}
editingSkill={{
name: "code-review",
@@ -321,7 +311,7 @@ describe("CreateSkillDialog", () => {
it("calls onCreated callback after successful save", async () => {
const user = userEvent.setup();
const onCreated = vi.fn();
render(<CreateSkillDialog {...defaultProps} onCreated={onCreated} />);
render(<SkillEditor {...defaultProps} onCreated={onCreated} />);
await user.type(screen.getByPlaceholderText("my-skill-name"), "my-skill");
await user.type(
@@ -336,7 +326,7 @@ describe("CreateSkillDialog", () => {
it("clears fields after save", async () => {
const user = userEvent.setup();
// Re-render with isOpen toggling to verify fields are cleared
const { rerender } = render(<CreateSkillDialog {...defaultProps} />);
const { rerender } = render(<SkillEditor {...defaultProps} />);
await user.type(screen.getByPlaceholderText("my-skill-name"), "my-skill");
await user.type(
@@ -346,7 +336,7 @@ describe("CreateSkillDialog", () => {
await user.click(screen.getByRole("button", { name: /create skill/i }));
// Dialog closes after save; reopen to check fields are cleared
rerender(<CreateSkillDialog {...defaultProps} />);
rerender(<SkillEditor {...defaultProps} />);
expect(screen.getByPlaceholderText("my-skill-name")).toHaveValue("");
expect(
@@ -358,7 +348,7 @@ describe("CreateSkillDialog", () => {
const user = userEvent.setup();
vi.mocked(createSkill).mockRejectedValueOnce(new Error("Network error"));
render(<CreateSkillDialog {...defaultProps} />);
render(<SkillEditor {...defaultProps} />);
await user.type(screen.getByPlaceholderText("my-skill-name"), "my-skill");
await user.type(
@@ -26,11 +26,9 @@ const mockSkills: SkillInfo[] = [
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",
@@ -39,11 +37,9 @@ const mockSkills: SkillInfo[] = [
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",
@@ -52,7 +48,6 @@ const mockSkills: SkillInfo[] = [
instructions: "Write tests...",
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: [
@@ -62,7 +57,6 @@ const mockSkills: SkillInfo[] = [
workingDir: "/tmp/alpha",
},
],
editable: true,
},
];
@@ -164,7 +158,6 @@ describe("SkillsView", () => {
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: [
{
@@ -199,7 +192,6 @@ describe("SkillsView", () => {
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: [
{
@@ -402,7 +394,6 @@ describe("SkillsView", () => {
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: [
{
@@ -418,7 +409,6 @@ describe("SkillsView", () => {
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: [
{
@@ -17,6 +17,8 @@
"view": {
"backToSkills": "Back to skills",
"deleteDescription": "This skill and its configuration will be permanently removed.",
"deleteError": "Failed to delete skill",
"deleteSuccess": "Deleted \"{{name}}\"",
"deleteTitle": "Delete \"{{name}}\" permanently?",
"category": "Category",
"categories": {
@@ -42,11 +44,15 @@
"dropFile": "or drop a file",
"emptyDescription": "Create a skill or import one to get started.",
"emptyTitle": "No skills yet",
"exportError": "Failed to export skill",
"exportedTo": "Exported to {{filename}}",
"filePath": "File path",
"filtersAllSources": "All",
"filtersGlobal": "Personal",
"importError": "Failed to import skill",
"importSuccess": "Skill imported",
"instructions": "Instructions",
"loadError": "Failed to load skills",
"location": "Location",
"more": "More",
"newSkill": "New skill",
@@ -35,6 +35,8 @@
}
},
"deleteDescription": "Esta habilidad y su configuración se eliminarán de forma permanente.",
"deleteError": "Error al eliminar la skill",
"deleteSuccess": "Skill \"{{name}}\" eliminada",
"deleteTitle": "¿Eliminar \"{{name}}\" de forma permanente?",
"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.",
@@ -42,11 +44,15 @@
"dropFile": "o suelta un archivo",
"emptyDescription": "Crea una skill o importa una para empezar.",
"emptyTitle": "Aún no hay skills",
"exportError": "Error al exportar la skill",
"exportedTo": "Exportado a {{filename}}",
"filePath": "Ruta del archivo",
"filtersAllSources": "Todas",
"filtersGlobal": "Personal",
"importError": "Error al importar la skill",
"importSuccess": "Skill importada",
"instructions": "Instrucciones",
"loadError": "Error al cargar las skills",
"location": "Ubicación",
"more": "Más",
"newSkill": "Nueva skill",