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
-250
View File
@@ -1,250 +0,0 @@
---
name: ui-refactor-review
description: >-
Review ui/goose2 frontend changes specifically for refactoring opportunities
and long-term maintainability. Use when the user wants cleanup feedback or
wants to improve structure, decomposition, layering, type hygiene,
duplication, dead code, readability, and extensibility in goose2 React +
TypeScript + Tauri UI code.
---
# UI Refactor Quality
Use this skill for `ui/goose2` pull requests.
Keep the focus on behavior-preserving frontend improvement. Favor the repo's
existing architecture and patterns over generic frontend advice.
## Goals
- Review changed code for refactor quality, not just correctness.
- Bias toward detecting maintainability smells even when the code is still functionally correct.
- Review the final shape of the changed code, not whether it is better than what came before.
- Judge changes by whether they leave the code easier to maintain and extend in future work, not just whether they are correct today.
- Produce an actionable checklist instead of vague feedback.
- Ask for approval before making code changes unless the user explicitly asks for fixes.
- Preserve `ui/goose2` boundaries: `ui/`, `hooks/`, `api/`, `lib/`, `stores/`, and `shared/`.
## Workflow
1. Determine the review scope.
- Review only the changed lines in the branch or working tree.
- If both committed and uncommitted changes exist, clarify which scope to review when needed.
- If the change mixes feature work with refactoring, call that out explicitly and review the feature changes separately from the cleanup quality.
2. Inspect only the changed lines in that scope, but follow each changed code path into surrounding modules when needed to judge whether the shape is clean.
3. Run the `Smell Checklist` below before looking for bugs.
- Do not require a user-visible bug before calling out a maintainability problem.
- Prefer concrete findings like "this responsibility is misplaced" or "this logic should be extracted" over generic style commentary.
- If a Smell Checklist item is true, turn it into an `Issue`.
- Do not leave a confirmed smell unaccounted for in the final review.
- Do not suppress an `Issue` because the PR already improved the previous version.
- Do not treat partial cleanup as resolution. A smell that remains in the post-PR code is still an `Issue`.
4. Evaluate the changed code against the `Rules` below and identify what the PR already improved and what should still be refactored or cleaned up.
5. Review the changed code separately for these buckets:
- decomposition
- layering
- hooks/effects
- pure helpers
- type shapes
- duplication
- tests
- feature wiring
- For each bucket, explicitly determine whether zero, one, or multiple `Issues` remain.
- If any remain, report all distinct `Issues` you can verify in that bucket.
6. Verify each non-trivial issue against the actual code before turning it into a task.
- Trace the relevant code path end to end.
- Check whether the issue is already handled elsewhere.
- Confirm the suggested cleanup would actually simplify the code.
- Keep the finding if the maintainability problem is real, local, and behavior-preserving to fix, even when the code still works.
- Drop speculative or preference-only findings.
7. Before finalizing the review, run a second pass focused only on finding issues not already listed.
- Discover issues before prioritizing them.
- Do not stop after the first few findings.
- Do not omit a verified issue because it is lower priority than other findings.
8. Produce review output in this order:
- `Applied Well`
- `Issues`
- one ordered `Checklist` for the whole reviewed scope
- Do not stop after the findings until the ordered checklist is complete.
9. Stop after the checklist and ask for approval before making code changes, unless the user explicitly asked to implement fixes.
10. Fix approved checklist items in order, using the `Rules` below as the quality bar for the implementation.
- State the main maintainability problem in one sentence.
- Fix the highest-value items first.
- Make the smallest behavior-preserving change that clearly improves the code.
11. Summarize what changed, what remains, and what verification ran.
## Strict Mode
- Any confirmed smell in the changed code must be reported as an `Issue`.
- Review the post-PR shape. Do not grade on a curve.
- Partial extraction, partial deduplication, or partial cleanup does not clear a remaining smell.
- If multiple distinct smells remain in one file, report each distinct responsibility problem separately.
## Smell Checklist
Before finalizing the review, explicitly ask:
- Is any view or page component still doing too many jobs?
- Is any pure derivation logic still trapped in a component instead of `lib/`?
- Is any repeated async UI workflow ready for a focused hook?
- Are helpers duplicated or living in the wrong layer?
- Are any inline object shapes large enough to deserve a named type?
- Did logic move without moving or adding the right tests?
- Did the refactor preserve feature wiring while improving structure?
## Rules
### Size And Decomposition
- Treat these as smell thresholds, not hard limits:
- components around 200 lines
- functions around 40 lines
- files around 300 lines
- JSX nesting around 4 levels
- Treat "many unrelated state variables + many handlers + many effects in one view" as a smell even when the line count is still tolerated.
- Treat a file that owns multiple unrelated responsibilities across data loading, derivation, mutation, and rendering orchestration as a smell unless there is a strong reason to keep it together.
- If a component does more than its name claims, rename it or split it.
- Split by responsibility, not by arbitrary line count.
- When a view contains substantial pure derivation logic, prefer extracting it into `lib/` helpers with direct tests.
- When a view contains substantial effectful workflow logic, prefer extracting it into a focused hook.
- Do not suppress a decomposition `Issue` just because the PR already extracted some responsibilities.
- If the remaining file still does too many jobs, report that as an `Issue`.
- File size alone is not the finding. The finding is the number of unrelated responsibilities still owned by the final file.
### Naming Reveals Intent
- Use names that describe intent, not implementation trivia.
- Prefer domain terms over generic placeholders like `data`, `value`, or `handler`.
- A helper name should describe what it returns or decides, not how it computes it.
- Rename misleading functions before adding comments to explain them.
### Layer Discipline
- `ui/`: rendering and light view logic only.
- `hooks/`: glue between React state/effects and lower layers.
- `api/`: backend transport wrappers and DTO adaptation only.
- `lib/`: pure functions and domain helpers only.
- `stores/`: shared feature state only.
- Keep business logic out of render-heavy components when a hook or utility would make it clearer.
- If a component mixes pure transforms and UI event orchestration, split the pure transforms out first.
- Do not move simple local state into a store unless multiple consumers truly need it.
- Keep `api/` free of UI imports, path logic, and unrelated domain policy.
- Keep `lib/` free of React, DOM, `window`, and I/O.
- Prefer shared domain helpers in `lib/` when the same normalization, formatting, or parsing logic appears in multiple modules.
- If logic lives in the wrong layer after the PR, report that as an `Issue` even if the PR reduced the amount of misplaced logic.
### Module Encapsulation
- Export the minimum surface a module needs to share.
- Keep helpers, constants, and intermediate transforms private unless another module genuinely needs them.
- Treat removing stale exports as a quality improvement.
- If a helper is used in only one module, default to keeping it local.
- If similar helpers appear across two modules, default to extracting them.
### DRY And Hooks
- Extract shared behavior once the duplication is clear and the shared abstraction is stable.
- Two call sites can be enough when the shared shape is obvious and both call sites become simpler.
- Prefer a hook when the shared logic is stateful or effectful.
- Keep each hook focused on one job.
- Keep hook return shapes stable so callers are not forced to handle shifting contracts.
- Do not use a hook as the default extraction target for oversized components.
- If the logic is pure and React-independent, report extraction to `lib/`.
- If the logic coordinates React state, effects, async actions, or UI event orchestration, report extraction to `hooks/`.
- Treat repeated pure UI derivation logic as helper extraction candidates.
- If repeated effectful orchestration remains in the changed code, report that as an `Issue`.
- If repeated pure transforms remain in the changed code, report that as an `Issue`.
### Type Hygiene
- Keep canonical cross-feature types in `src/shared/types/`.
- Do not duplicate types across features when one shared type should exist.
- Give inline object types with 3 or more fields a name when they start obscuring the code.
- Prefer `Pick`, `Omit`, and `Partial` over restating shapes by hand.
- Avoid `any`, unchecked `as`, non-null assertions, and string-encoded pseudo-unions when a discriminated union would be clearer.
- Treat repeated or verbose inline object shapes as extraction candidates for named types.
- If verbose or repeated inline shapes remain after the PR, report that as an `Issue`.
### React And UI
- Prefer straight-line render logic, guard clauses, and early returns over deep nesting.
- Prefer controlled components where practical.
- Use semantic HTML like `<main>`, `<nav>`, `<header>`, and `<aside>`.
- Prefer existing shared UI button primitives over plain `<button>` elements.
- Treat new plain `<button>` usage as a refactor smell unless there is a specific semantic or integration reason.
- If a plain `<button>` is genuinely necessary, it must use `type="button"` in goose2.
- Use `cn()` from `@/shared/lib/cn` for Tailwind class merging.
- Prefer existing shared UI primitives before creating new one-off markup patterns.
- Avoid inline styles except for truly dynamic values.
- Respect reduced-motion behavior when touching animation.
### Notifications, Localization, And Accessibility
- Route success and error feedback through the app's shared notification primitive.
- Route user-facing Goose UI copy through `react-i18next` in already-migrated surfaces.
- Prefer stable translation keys over inline English strings.
- Avoid raw user-facing strings inside `catch` blocks.
- Add text alternatives for icon-only or color-only affordances.
- Keep interactive semantics explicit with labels, roles, and selected state where applicable.
### Tauri And Backend Boundaries
- Frontend-to-core communication goes through `SDK -> ACP -> goose`.
- Do not add ad hoc `fetch()` calls for goose core behavior.
- Do not add `invoke()` calls as proxies to goose core behavior; reserve them for desktop-shell concerns.
- Do not call ACP clients directly from UI components; keep backend access in `shared/api/` or `features/*/api/`.
### Errors, State Drift, And Dead Code
- Handle errors explicitly and close to the source.
- Keep the happy path easy to see.
- In async UI flows, keep local state, persisted state, and backend-confirmed state from drifting apart.
- Delete unused exports, imports, parameters, fields, and commented-out code.
- Remove tests that only protect deleted internals rather than user-visible behavior.
- When logic moves across modules, expect coverage to move with it rather than disappear.
- Treat coverage loss in refactors as suspicious unless the behavior was intentionally removed.
- If behavior-preserving logic moved but coverage did not move with it, report that as an `Issue`.
- Report redundant props, fields, parameters, and intermediate values as `Issues`.
## Review Output
### Applied Well
- List what the PR already improved.
- Use concrete examples with file references.
- Skip generic praise.
### Issues
- List only issues that are actually in scope for the changed code.
- For each issue, explain:
- what is wrong
- why it matters
- the smallest change that would improve it
- Only include issues that survived a verification pass against the actual code.
### Checklist
- End with one ordered actionable checklist for the whole reviewed scope.
- Do not create a separate checklist per issue.
- Each item should be specific enough to implement directly.
- Each item should be small enough to fix as one unit.
- If an item would require sub-steps, split it into multiple checklist items instead of nesting.
- Treat `Checklist` as the complete fix inventory for the reviewed scope.
- Do not defer concrete fix items from `Checklist` into later sections.
- Each checklist item must describe a concrete code change, not a high-level goal.
- A user should be able to implement the fix directly from the `Checklist`.
- Order the checklist by implementation sequence:
- boundary and layering issues first
- naming and decomposition next
- type and hook cleanup after that
- dead code and polish last
## Done Criteria
- No unresolved in-scope boundary violations remain.
- The code is clearer without changing intended behavior.
- No new dead code or needless exports were introduced.
- Naming and decomposition are improved where the review identified them.
- Review findings were verified before being turned into fix tasks.
- Verification was run when appropriate, or explicitly called out if not run.
+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",