fix: keep renamed skills open in detail view (#8935)

This commit is contained in:
Kalvin C
2026-04-30 15:07:26 -07:00
committed by GitHub
parent ef73610cc1
commit 43478a2bf6
5 changed files with 107 additions and 20 deletions
@@ -11,21 +11,26 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { createSkill, updateSkill, type EditingSkill } from "../api/skills";
import {
createSkill,
updateSkill,
type EditingSkill,
type SkillInfo,
} from "../api/skills";
import { formatSkillName, isValidSkillName } from "../lib/skillsHelpers";
import { getRenamedSkillFileLocation } from "../lib/skillsPath";
interface SkillEditorProps {
isOpen: boolean;
onClose: () => void;
onCreated?: () => void;
onSaved?: (savedSkill?: SkillInfo) => void | Promise<void>;
editingSkill?: EditingSkill;
}
export function SkillEditor({
isOpen,
onClose,
onCreated,
onSaved,
editingSkill,
}: SkillEditorProps) {
const { t } = useTranslation(["skills", "common"]);
@@ -74,8 +79,9 @@ export function SkillEditor({
setSaving(true);
setError(null);
try {
let savedSkill: SkillInfo | undefined;
if (isEditing) {
await updateSkill(
savedSkill = await updateSkill(
editingSkill.path,
name,
description.trim(),
@@ -87,7 +93,7 @@ export function SkillEditor({
setName("");
setDescription("");
setInstructions("");
onCreated?.();
await onSaved?.(savedSkill);
onClose();
} catch (err) {
setError(String(err));
@@ -16,7 +16,7 @@ import type { EditingSkill, SkillInfo } from "../api/skills";
interface SkillsDialogsProps {
dialogOpen: boolean;
onDialogClose: () => void;
onCreated: () => void | Promise<void>;
onSaved: (savedSkill?: SkillInfo) => void | Promise<void>;
editingSkill?: EditingSkill;
deletingSkill: SkillInfo | null;
onDeletingSkillChange: (skill: SkillInfo | null) => void;
@@ -26,7 +26,7 @@ interface SkillsDialogsProps {
export function SkillsDialogs({
dialogOpen,
onDialogClose,
onCreated,
onSaved,
editingSkill,
deletingSkill,
onDeletingSkillChange,
@@ -39,7 +39,7 @@ export function SkillsDialogs({
<SkillEditor
isOpen={dialogOpen}
onClose={onDialogClose}
onCreated={onCreated}
onSaved={onSaved}
editingSkill={editingSkill}
/>
@@ -55,7 +55,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
const [expandedSectionIds, setExpandedSectionIds] = useState<string[]>([]);
const loadRequestIdRef = useRef(0);
const loadSkills = useCallback(async () => {
const loadSkills = useCallback(async (): Promise<SkillViewInfo[]> => {
const requestId = loadRequestIdRef.current + 1;
loadRequestIdRef.current = requestId;
setLoading(true);
@@ -64,16 +64,19 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
const projectDirs = projects.flatMap((project) => project.workingDirs);
const result = await listSkills(projectDirs);
if (loadRequestIdRef.current !== requestId) {
return;
return [];
}
setSkills(
withInferredSkillCategories(hydrateProjectNames(result, projects)),
const nextSkills = withInferredSkillCategories(
hydrateProjectNames(result, projects),
);
setSkills(nextSkills);
return nextSkills;
} catch {
if (loadRequestIdRef.current === requestId) {
setSkills([]);
toast.error(t("view.loadError"));
}
return [];
} finally {
if (loadRequestIdRef.current === requestId) {
setLoading(false);
@@ -190,6 +193,23 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
setDialogOpen(true);
};
const handleSkillSaved = useCallback(
async (savedSkill?: SkillInfo) => {
const refreshedSkills = await loadSkills();
if (
savedSkill &&
refreshedSkills.some((skill) => skill.id === savedSkill.id)
) {
setActiveSkillId(savedSkill.id);
}
},
[loadSkills],
);
const refreshSkills = useCallback(async () => {
await loadSkills();
}, [loadSkills]);
const {
fileInputRef,
isDragOver,
@@ -197,7 +217,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
handleFileChange,
openFilePicker,
handleExport,
} = useSkillImportExport(loadSkills);
} = useSkillImportExport(refreshSkills);
const handleSelectSkill = (skill: SkillViewInfo) => {
setActiveSkillId(skill.id);
@@ -207,7 +227,7 @@ export function SkillsView({ onStartChatWithSkill }: SkillsViewProps) {
<SkillsDialogs
dialogOpen={dialogOpen}
onDialogClose={handleDialogClose}
onCreated={loadSkills}
onSaved={handleSkillSaved}
editingSkill={editingSkill}
deletingSkill={deletingSkill}
onDeletingSkillChange={setDeletingSkill}
@@ -20,7 +20,7 @@ const { createSkill, updateSkill } = await import("../../api/skills");
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onCreated: vi.fn(),
onSaved: vi.fn(),
};
describe("SkillEditor", () => {
@@ -308,10 +308,10 @@ describe("SkillEditor", () => {
);
});
it("calls onCreated callback after successful save", async () => {
it("calls onSaved callback after successful save", async () => {
const user = userEvent.setup();
const onCreated = vi.fn();
render(<SkillEditor {...defaultProps} onCreated={onCreated} />);
const onSaved = vi.fn();
render(<SkillEditor {...defaultProps} onSaved={onSaved} />);
await user.type(screen.getByPlaceholderText("my-skill-name"), "my-skill");
await user.type(
@@ -320,7 +320,7 @@ describe("SkillEditor", () => {
);
await user.click(screen.getByRole("button", { name: /create skill/i }));
expect(onCreated).toHaveBeenCalled();
expect(onSaved).toHaveBeenCalled();
});
it("clears fields after save", async () => {
@@ -62,6 +62,18 @@ const mockSkills: SkillInfo[] = [
vi.mock("../../api/skills", () => ({
listSkills: vi.fn().mockResolvedValue([]),
createSkill: vi.fn().mockResolvedValue(undefined),
updateSkill: vi.fn().mockResolvedValue({
id: "global:/path/renamed-review",
name: "renamed-review",
description: "Reviews code",
instructions: "Review the code...",
path: "/path/renamed-review",
fileLocation: "/path/renamed-review/SKILL.md",
sourceKind: "global",
sourceLabel: "Personal",
projectLinks: [],
}),
deleteSkill: vi.fn().mockResolvedValue(undefined),
exportSkill: vi
.fn()
@@ -75,11 +87,12 @@ vi.mock("@/features/projects/stores/projectStore", () => ({
) => selector({ projects: mockProjects }),
}));
const { listSkills, deleteSkill } = (await import(
const { listSkills, deleteSkill, updateSkill } = (await import(
"../../api/skills"
)) as unknown as {
listSkills: ReturnType<typeof vi.fn>;
deleteSkill: ReturnType<typeof vi.fn>;
updateSkill: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
@@ -288,6 +301,54 @@ describe("SkillsView", () => {
expect(screen.queryByText("code-review")).not.toBeInTheDocument();
});
it("stays on the detail page after renaming a skill", async () => {
const renamedSkill: SkillInfo = {
...mockSkills[1],
id: "global:/path/renamed-review",
name: "renamed-review",
path: "/path/renamed-review",
fileLocation: "/path/renamed-review/SKILL.md",
};
listSkills
.mockResolvedValueOnce(mockSkills)
.mockResolvedValueOnce([mockSkills[0], renamedSkill, mockSkills[2]]);
updateSkill.mockResolvedValueOnce(renamedSkill);
const user = userEvent.setup();
render(<SkillsView />);
await screen.findByText("code-review");
await user.click(
screen.getByRole("button", { name: "Open code-review details" }),
);
await user.click(screen.getByRole("button", { name: "Edit" }));
const nameInput = screen.getByPlaceholderText("my-skill-name");
await user.clear(nameInput);
await user.type(nameInput, "renamed-review");
await user.click(screen.getByRole("button", { name: /save changes/i }));
await waitFor(() => {
expect(updateSkill).toHaveBeenCalledWith(
"/path/code-review",
"renamed-review",
"Reviews code",
"Review the code...",
);
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Back to skills" }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "renamed-review" }),
).toBeInTheDocument();
});
expect(
screen.queryByPlaceholderText("my-skill-name"),
).not.toBeInTheDocument();
});
it("filters skills by search text", async () => {
listSkills.mockResolvedValue(mockSkills);
const user = userEvent.setup();