From c24204d889d15cf3bb8281f5dfbde902efa28fbe Mon Sep 17 00:00:00 2001 From: Alejandro Perez Date: Fri, 3 Jul 2026 02:11:47 +0100 Subject: [PATCH 1/2] app: grouped skills view with review-pipeline toggle Rebuild the Skills view (PR 2 of the overnight UX program) as a source-grouped master/detail. The left list groups skills by install source with mono uppercase headers and counts: a pinned REVIEW PIPELINE group, PERSONAL, one PLUGINS group per marketplace, PROJECT, and a COCKPIT STORE group that always shows (it carries the Install/sync affordances). A substring search filters across all groups. The detail pane shows the selected skill's name, path, source chip, description, and body via the shared renderer, plus a "Use in review" card wired to set_skill_review_pipeline. The SKILL.md editor is gated strictly on source.kind === "CockpitStore"; external sources (personal/plugin/project) are read-only with a managed note, so an external skill can never be copied into the store via save_skill. Grouping and search are pure helpers in lib/skill-groups.ts with unit tests covering group order, marketplace splitting, pipeline pinning, cross-group search, and empty-source handling. Store gains a setSkillReviewPipeline action (optimistic flip, reconciled with the refreshed list). Co-Authored-By: Claude Fable 5 --- app/src/components/SkillsView.tsx | 790 ++++++++++++++++++++++-------- app/src/lib/skill-groups.test.ts | 193 ++++++++ app/src/lib/skill-groups.ts | 159 ++++++ app/src/store.ts | 31 ++ 4 files changed, 960 insertions(+), 213 deletions(-) create mode 100644 app/src/lib/skill-groups.test.ts create mode 100644 app/src/lib/skill-groups.ts diff --git a/app/src/components/SkillsView.tsx b/app/src/components/SkillsView.tsx index b3e4b44..0e198ad 100644 --- a/app/src/components/SkillsView.tsx +++ b/app/src/components/SkillsView.tsx @@ -1,12 +1,15 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useAppStore } from "../store"; import type { Skill } from "../bindings/Skill"; import type { SkillSource } from "../bindings/SkillSource"; import type { SyncReport } from "../bindings/SyncReport"; +import { groupSkills, filterSkills } from "@/lib/skill-groups"; +import { Markdown } from "./Markdown"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Badge } from "@/components/ui/badge"; +import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { EmptyState } from "./EmptyState"; import { @@ -24,16 +27,24 @@ import { Trash2, RefreshCw, Loader2, + Pencil, + Search, + Lock, + Sparkles, } from "lucide-react"; import { cn } from "@/lib/utils"; -/** Editor selection: an existing skill by name, a brand-new draft, or none. */ +/** + * What the detail pane is showing: nothing, an existing skill (viewing or + * editing its source), or a brand-new cockpit-store draft. Editing is only ever + * entered for a `CockpitStore` skill (external sources are read-only). + */ type Selection = | { readonly kind: "none" } - | { readonly kind: "existing"; readonly name: string } + | { readonly kind: "skill"; readonly path: string; readonly editing: boolean } | { readonly kind: "new" }; -/** Transient inline feedback shown after a sync/save action. */ +/** Transient inline feedback shown after a sync/save/delete action. */ type Feedback = | { readonly kind: "success"; readonly message: string } | { readonly kind: "error"; readonly message: string }; @@ -43,34 +54,102 @@ function syncReportMessage(report: SyncReport): string { return `Synced: ${String(report.installed)} installed, ${String(report.updated)} updated, ${String(report.skipped)} skipped.`; } -/** Short human label for a skill's source badge. */ -function sourceLabel(source: SkillSource): string { +/** Exhaustiveness guard: fails to compile if a `SkillSource` variant is added. */ +function assertNever(x: never): never { + throw new Error(`unreachable SkillSource: ${JSON.stringify(x)}`); +} + +/** Detail-pane source chip label (e.g. `"nexcade plugin"`, `"cockpit store"`). */ +function sourceChipLabel(source: SkillSource): string { switch (source.kind) { case "CockpitStore": - return "Cockpit"; + return "cockpit store"; case "Personal": - return "Personal"; + return "personal"; case "Plugin": - return source.marketplace; + return `${source.marketplace} plugin`; case "Project": - return "Project"; + return "project"; default: return assertNever(source); } } -/** Exhaustiveness guard: fails to compile if a `SkillSource` variant is added. */ -function assertNever(x: never): never { - throw new Error(`unreachable SkillSource: ${JSON.stringify(x)}`); +/** External (non-store) sources are managed by Claude Code and never edited. */ +function isEditable(source: SkillSource): boolean { + return source.kind === "CockpitStore"; +} + +/** + * A single row in the grouped list. Selecting the row loads it into the detail + * pane. Pipeline rows carry an inline switch to drop a skill from the pipeline; + * other rows show a static `ON` badge when they are in the pipeline. + */ +interface SkillRowProps { + readonly skill: Skill; + readonly selected: boolean; + readonly onSelect: () => void; + readonly inline: "toggle" | "badge"; + readonly onTogglePipeline: (enabled: boolean) => void; +} + +function SkillRow({ + skill, + selected, + onSelect, + inline, + onTogglePipeline, +}: SkillRowProps) { + return ( +
+ + {inline === "toggle" ? ( + + ) : ( + skill.in_review_pipeline && ( + + On + + ) + )} +
+ ); } /** * Skills management view. * - * A master/detail layout: the left column lists installed skills; selecting one - * loads its raw `SKILL.md` into a monospace textarea for editing. Supports - * creating a new skill, saving, deleting (guarded by a confirm dialog), and - * syncing from the configured GitHub source. + * A master/detail layout. The left column lists every discovered skill grouped + * by install source, with a pinned "Review pipeline" group at the top and a + * substring search across all groups. The right pane shows the selected skill: + * its rendered body, a "Use in review" toggle, and — only for cockpit-store + * skills — an editor. External sources (personal, plugin, project) are strictly + * read-only: cockpit never edits `~/.claude`. */ export function SkillsView() { const skills = useAppStore((s) => s.skills); @@ -80,10 +159,12 @@ export function SkillsView() { const saveSkill = useAppStore((s) => s.saveSkill); const deleteSkill = useAppStore((s) => s.deleteSkill); const syncSkills = useAppStore((s) => s.syncSkills); + const setSkillReviewPipeline = useAppStore((s) => s.setSkillReviewPipeline); const [selection, setSelection] = useState({ kind: "none" }); + const [search, setSearch] = useState(""); const [newName, setNewName] = useState(""); - const [contents, setContents] = useState(""); + const [editorText, setEditorText] = useState(""); const [feedback, setFeedback] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null); @@ -91,52 +172,87 @@ export function SkillsView() { void listSkills(); }, [listSkills]); - // Reconstruct the raw SKILL.md from the parsed skill: frontmatter block plus - // body. The list command returns parsed skills, so this reassembles a - // faithful editable source rather than persisting the parsed halves. + const groups = useMemo( + () => groupSkills(filterSkills(skills, search)), + [skills, search], + ); + + // The selected skill is resolved from the full list (not the filtered one) so + // a search that hides the current selection does not clear the detail pane. + const selectedSkill = + selection.kind === "skill" + ? (skills.find((s) => s.path === selection.path) ?? null) + : null; + + // Reassemble a faithful editable SKILL.md from the parsed skill: frontmatter + // block plus body. The list command returns parsed skills, so this rebuilds + // the source rather than persisting the parsed halves. const rebuildSource = useCallback((skill: Skill): string => { const tags = skill.tags.length > 0 ? `tags: [${skill.tags.join(", ")}]\n` : ""; return `---\nname: ${skill.name}\ndescription: ${skill.description}\n${tags}---\n\n${skill.body}`; }, []); - const handleSelect = useCallback( - (skill: Skill) => { - setSelection({ kind: "existing", name: skill.name }); - setContents(rebuildSource(skill)); - setFeedback(null); - }, - [rebuildSource], - ); + const handleSelect = useCallback((skill: Skill) => { + setSelection({ kind: "skill", path: skill.path, editing: false }); + setFeedback(null); + }, []); const handleNew = useCallback(() => { setSelection({ kind: "new" }); setNewName(""); - setContents( + setEditorText( "---\nname: \ndescription: \ntags: []\n---\n\n# Skill\n\nDescribe the skill here.\n", ); setFeedback(null); }, []); + const handleEdit = useCallback( + (skill: Skill) => { + setSelection({ kind: "skill", path: skill.path, editing: true }); + setEditorText(rebuildSource(skill)); + setFeedback(null); + }, + [rebuildSource], + ); + + const handleCancelEdit = useCallback(() => { + if (selection.kind === "skill") { + setSelection({ kind: "skill", path: selection.path, editing: false }); + } + setFeedback(null); + }, [selection]); + const handleSave = useCallback(async () => { setFeedback(null); let name: string; - if (selection.kind === "existing") { - name = selection.name; - } else if (selection.kind === "new") { + if (selection.kind === "new") { name = newName.trim(); if (name === "") { setFeedback({ kind: "error", message: "A skill name is required." }); return; } + } else if (selection.kind === "skill" && selectedSkill !== null) { + name = selectedSkill.name; } else { return; } - await saveSkill(name, contents); - setSelection({ kind: "existing", name }); + await saveSkill(name, editorText); + // The save refreshed the store; locate the persisted store skill so the + // pane returns to a read view of exactly what was written. + const saved = useAppStore + .getState() + .skills.find( + (s) => s.name === name && s.source.kind === "CockpitStore", + ); + setSelection( + saved !== undefined + ? { kind: "skill", path: saved.path, editing: false } + : { kind: "none" }, + ); setFeedback({ kind: "success", message: `Saved “${name}”.` }); - }, [selection, newName, contents, saveSkill]); + }, [selection, selectedSkill, newName, editorText, saveSkill]); const handleConfirmDelete = useCallback(async () => { if (confirmDelete === null) return; @@ -144,7 +260,6 @@ export function SkillsView() { setConfirmDelete(null); await deleteSkill(name); setSelection({ kind: "none" }); - setContents(""); setFeedback({ kind: "success", message: `Deleted “${name}”.` }); }, [confirmDelete, deleteSkill]); @@ -156,200 +271,217 @@ export function SkillsView() { } }, [syncSkills]); - const editing = selection.kind !== "none"; - const canSave = - editing && - (selection.kind === "existing" || - (selection.kind === "new" && newName.trim() !== "")); + const handleToggle = useCallback( + (skill: Skill, enabled: boolean) => { + void setSkillReviewPipeline(skill.name, enabled); + }, + [setSkillReviewPipeline], + ); + + const totalSkills = skills.length; return ( -
+
{/* Header */} -
-
- -
-

Skills

-

- Reusable review skills injected into rework prompts. -

-
-
-
- - +
+ +
+

+ Skills +

+

+ Reusable skills discovered across Claude Code and the cockpit store. +

- {error !== null && ( -

{error}

- )} - {feedback !== null && ( -

+ {error !== null && ( +

{error}

)} - > - {feedback.message} -

+ {feedback !== null && ( +

+ {feedback.message} +

+ )} +
)} -
- {/* ----------------------------------------------------------------- */} - {/* Skill list */} - {/* ----------------------------------------------------------------- */} -
- {skillsLoading && skills.length === 0 ? ( -

Loading skills...

- ) : skills.length === 0 ? ( - - ) : ( -
    - {skills.map((skill) => { - const active = - selection.kind === "existing" && - selection.name === skill.name; +
    + {/* --------------------------------------------------------------- */} + {/* Grouped list */} + {/* --------------------------------------------------------------- */} +
    +
    +
    + + { + setSearch(e.target.value); + }} + placeholder="Search skills…" + className="h-9 pl-8 text-sm" + /> +
    +
    + +
    + {skillsLoading && totalSkills === 0 ? ( +

    + Loading skills… +

    + ) : ( + groups.map((group) => { + const isStore = group.kind.kind === "store"; + const isPipeline = group.kind.kind === "pipeline"; return ( -
  • - +
  • -
    - {skill.description !== "" && ( -

    - {skill.description} -

    )} - - +
    + + {group.skills.length === 0 ? ( + isStore && ( +

    + No store skills yet. Sync or create one. +

    + ) + ) : ( +
    + {group.skills.map((skill) => ( + { + handleSelect(skill); + }} + inline={isPipeline ? "toggle" : "badge"} + onTogglePipeline={(enabled) => { + handleToggle(skill, enabled); + }} + /> + ))} +
    + )} +
); - })} - - )} + }) + )} +
- {/* ----------------------------------------------------------------- */} - {/* Editor */} - {/* ----------------------------------------------------------------- */} -
- {!editing ? ( -
- Select a skill to edit, or create a new one. + {/* --------------------------------------------------------------- */} + {/* Detail / editor */} + {/* --------------------------------------------------------------- */} +
+ {selection.kind === "new" ? ( + { + void handleSave(); + }} + onCancel={() => { + setSelection({ kind: "none" }); + }} + /> + ) : selectedSkill === null ? ( +
+
+ ) : selection.kind === "skill" && selection.editing ? ( + { + void handleSave(); + }} + onCancel={handleCancelEdit} + /> ) : ( - <> - {selection.kind === "new" && ( -
- - { - setNewName(e.target.value); - }} - placeholder="rust-testing" - autoFocus - /> -
- )} - -
- -