mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
chore: goose2 skill refactor (#8897)
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
+7
-50
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+24
-34
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user