remove skill categories (#9008)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-05-05 11:47:35 -07:00
committed by GitHub
parent cadae27bb0
commit 2a6df2e796
11 changed files with 16 additions and 541 deletions
@@ -1,294 +0,0 @@
import type { SkillInfo } from "../api/skills";
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;
}
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";
}
function withInferredSkillCategory(skill: SkillInfo): SkillViewInfo {
return {
...skill,
inferredCategory: inferSkillCategory(skill),
};
}
export function withInferredSkillCategories(
skills: SkillInfo[],
): SkillViewInfo[] {
return skills.map(withInferredSkillCategory);
}
export function uniqueSkillCategories(
skills: SkillViewInfo[],
): SkillCategory[] {
return SKILL_CATEGORY_ORDER.filter((category) =>
skills.some((skill) => skill.inferredCategory === category),
);
}
@@ -1,12 +1,11 @@
import type { SkillInfo } from "../api/skills";
import type { SkillCategory, SkillViewInfo } from "./skillCategories";
export type SkillsFilter = "all" | "global" | `project:${string}`;
export interface SkillsSection {
id: string;
title: string;
skills: SkillViewInfo[];
skills: SkillInfo[];
}
// Mirrors crates/goose/src/skills/mod.rs::validate_skill_name.
@@ -59,24 +58,19 @@ export function compareSkillsByName(a: SkillInfo, b: SkillInfo) {
}
export function filterSkills(
skills: SkillViewInfo[],
skills: SkillInfo[],
filters: {
search: string;
activeFilter: SkillsFilter;
selectedCategories: SkillCategory[];
},
getCategoryLabel: (category: SkillCategory) => string,
): SkillViewInfo[] {
): SkillInfo[] {
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);
skill.sourceLabel.toLowerCase().includes(searchTerm);
const matchesFilter =
filters.activeFilter === "all"
@@ -87,16 +81,12 @@ export function filterSkills(
(project) => `project:${project.id}` === filters.activeFilter,
);
const matchesCategory =
filters.selectedCategories.length === 0 ||
filters.selectedCategories.includes(skill.inferredCategory);
return matchesSearch && matchesFilter && matchesCategory;
return matchesSearch && matchesFilter;
});
}
export function groupSkills(
filteredSkills: SkillViewInfo[],
filteredSkills: SkillInfo[],
activeFilter: SkillsFilter,
projectFilters: { id: string; name: string }[],
labels: { personalTitle: string; projectsFallback: string },
@@ -1,93 +0,0 @@
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>
);
}
@@ -21,10 +21,9 @@ 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;
skill: SkillInfo | null;
onBack: () => void;
onEdit: (skill: SkillInfo) => void;
onReveal: (skill: SkillInfo) => void;
@@ -180,14 +179,6 @@ export function SkillDetailPage({
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"
@@ -8,15 +8,15 @@ import {
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";
import type { SkillInfo } from "../api/skills";
import type { SkillsSection } from "../lib/skillsHelpers";
interface SkillsListSectionsProps {
sections: SkillsSection[];
expandedSectionIds: string[];
onExpandedSectionIdsChange: (ids: string[]) => void;
onSelectSkill: (skill: SkillViewInfo) => void;
onStartChat?: (skill: SkillViewInfo) => void;
onSelectSkill: (skill: SkillInfo) => void;
onStartChat?: (skill: SkillInfo) => void;
}
export function SkillsListSections({
@@ -3,8 +3,6 @@ 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 {
@@ -13,9 +11,6 @@ interface SkillsToolbarProps {
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;
}
@@ -47,9 +42,6 @@ export function SkillsToolbar({
activeFilter,
onActiveFilterChange,
projectFilters,
categoryFilters,
selectedCategories,
onSelectedCategoriesChange,
dropHandlers,
isDragOver,
}: SkillsToolbarProps) {
@@ -94,13 +86,6 @@ export function SkillsToolbar({
</FilterButton>
);
})}
{categoryFilters.length > 0 ? (
<SkillCategoryFilter
categories={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={onSelectedCategoriesChange}
/>
) : null}
</FilterRow>
</div>
);
@@ -25,12 +25,6 @@ import {
type EditingSkill,
type SkillInfo,
} from "../api/skills";
import {
uniqueSkillCategories,
withInferredSkillCategories,
type SkillCategory,
type SkillViewInfo,
} from "../lib/skillCategories";
interface SkillsViewProps {
onStartChatWithSkill?: (skill: SkillInfo, projectId?: string | null) => void;
@@ -41,21 +35,18 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
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<EditingSkill | undefined>(
undefined,
);
const [skills, setSkills] = useState<SkillViewInfo[]>([]);
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [loading, setLoading] = useState(true);
const [deletingSkill, setDeletingSkill] = useState<SkillInfo | null>(null);
const [activeSkillId, setActiveSkillId] = useState<string | null>(null);
const [expandedSectionIds, setExpandedSectionIds] = useState<string[]>([]);
const loadRequestIdRef = useRef(0);
const loadSkills = useCallback(async (): Promise<SkillViewInfo[]> => {
const loadSkills = useCallback(async (): Promise<SkillInfo[]> => {
const requestId = loadRequestIdRef.current + 1;
loadRequestIdRef.current = requestId;
setLoading(true);
@@ -66,9 +57,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
if (loadRequestIdRef.current !== requestId) {
return [];
}
const nextSkills = withInferredSkillCategories(
hydrateProjectNames(result, projects),
);
const nextSkills = hydrateProjectNames(result, projects);
setSkills(nextSkills);
return nextSkills;
} catch {
@@ -89,10 +78,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
}, [loadSkills]);
const projectFilters = useMemo(() => uniqueProjectFilters(skills), [skills]);
const categoryFilters = useMemo(
() => uniqueSkillCategories(skills),
[skills],
);
useEffect(() => {
if (!activeFilter.startsWith("project:")) {
@@ -105,20 +90,9 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
}
}, [activeFilter, projectFilters]);
useEffect(() => {
setSelectedCategories((current) =>
current.filter((category) => categoryFilters.includes(category)),
);
}, [categoryFilters]);
const filteredSkills = useMemo(
() =>
filterSkills(
skills,
{ search, activeFilter, selectedCategories },
(category) => t(`view.categories.options.${category}`),
),
[skills, search, activeFilter, selectedCategories, t],
() => filterSkills(skills, { search, activeFilter }),
[skills, search, activeFilter],
);
const groupedSkills = useMemo(
@@ -219,7 +193,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
handleExport,
} = useSkillImportExport(refreshSkills);
const handleSelectSkill = (skill: SkillViewInfo) => {
const handleSelectSkill = (skill: SkillInfo) => {
setActiveSkillId(skill.id);
};
@@ -288,9 +262,6 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
activeFilter={activeFilter}
onActiveFilterChange={setActiveFilter}
projectFilters={projectFilters}
categoryFilters={categoryFilters}
selectedCategories={selectedCategories}
onSelectedCategoriesChange={setSelectedCategories}
dropHandlers={dropHandlers}
isDragOver={isDragOver}
/>
@@ -244,7 +244,6 @@ describe("SkillsView", () => {
expect(
screen.getByText("/tmp/alpha/.goose/skills/test-writer/SKILL.md"),
).toBeInTheDocument();
expect(screen.getByText("Quality")).toBeInTheDocument();
});
it("starts a chat with the selected skill from the list", async () => {
@@ -378,23 +377,6 @@ describe("SkillsView", () => {
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();
@@ -20,24 +20,6 @@
"deleteError": "Failed to delete skill",
"deleteSuccess": "Deleted \"{{name}}\"",
"deleteTitle": "Delete \"{{name}}\" permanently?",
"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": "Use skills to add specific instructions or behaviors to any agent.",
"detailEmptyDescription": "Select a skill to inspect its source, location, and instructions.",
"detailEmptyTitle": "Choose a skill",
@@ -16,24 +16,6 @@
},
"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": "Esta habilidad y su configuración se eliminarán de forma permanente.",
"deleteError": "Error al eliminar la skill",
"deleteSuccess": "Skill \"{{name}}\" eliminada",
-21
View File
@@ -42,32 +42,11 @@ test.describe("Skills view", () => {
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);