+ {/* --------------------------------------------------------------- */}
+ {/* 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 (
-
-
- {/* ----------------------------------------------------------------- */}
- {/* 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
- />
-
- )}
-
-
-
-
-
-
- {selection.kind === "existing" ? (
- {
- setConfirmDelete(selection.name);
- }}
- disabled={skillsLoading}
- >
-
- Delete
-
- ) : (
-
- )}
- {
- void handleSave();
- }}
- disabled={!canSave || skillsLoading}
- >
- {skillsLoading ? (
-
- ) : (
-
- )}
- Save
-
-
- >
+ {
+ handleToggle(selectedSkill, enabled);
+ }}
+ togglePending={pendingSkillToggles.includes(selectedSkill.name)}
+ onEdit={() => {
+ handleEdit(selectedSkill);
+ }}
+ onDelete={() => {
+ setConfirmDelete(selectedSkill.name);
+ }}
+ deleting={skillsLoading}
+ />
)}
@@ -365,7 +509,7 @@ export function SkillsView() {
Delete skill
- This permanently removes “{confirmDelete}” from your local skills.
+ This permanently removes “{confirmDelete}” from your cockpit store.
This cannot be undone.
@@ -393,3 +537,243 @@ export function SkillsView() {
);
}
+
+/** The "Use in review" card: the review-pipeline toggle plus its rationale. */
+interface UseInReviewCardProps {
+ readonly enabled: boolean;
+ readonly onToggle: (enabled: boolean) => void;
+ readonly pending: boolean;
+}
+
+function UseInReviewCard({ enabled, onToggle, pending }: UseInReviewCardProps) {
+ return (
+
+
+
+
+
+
+ Use in review
+
+
+
+ The pre-review agent is instructed to run this skill when the diff is
+ large or touches rendered UI. Its output renders in the Briefing tab.
+
+
+
+
+
+ );
+}
+
+/** Read-only detail view for the selected skill. */
+interface SkillDetailProps {
+ readonly skill: Skill;
+ readonly onToggle: (enabled: boolean) => void;
+ readonly togglePending: boolean;
+ readonly onEdit: () => void;
+ readonly onDelete: () => void;
+ readonly deleting: boolean;
+}
+
+function SkillDetail({
+ skill,
+ onToggle,
+ togglePending,
+ onEdit,
+ onDelete,
+ deleting,
+}: SkillDetailProps) {
+ const editable = isEditable(skill.source);
+ return (
+
+
+
+
+ {skill.name}
+
+
+ {skill.path}
+
+
+
+ {sourceChipLabel(skill.source)}
+
+
+
+ {skill.tags.length > 0 && (
+
+ {skill.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ )}
+
+
+
+ {skill.description !== "" && (
+
{skill.description}
+ )}
+
+ {skill.body.trim() !== "" && (
+
+
+
+ )}
+
+ {editable ? (
+
+
+
+ Delete
+
+
+
+ Edit
+
+
+ ) : (
+
+
+ Managed by Claude Code — cockpit never edits ~/.claude.
+
+ )}
+
+ );
+}
+
+/** Editor for an existing cockpit-store skill's raw SKILL.md. */
+interface ExistingSkillEditorProps {
+ readonly skill: Skill;
+ readonly text: string;
+ readonly onTextChange: (text: string) => void;
+ readonly saving: boolean;
+ readonly onSave: () => void;
+ readonly onCancel: () => void;
+}
+
+function ExistingSkillEditor({
+ skill,
+ text,
+ onTextChange,
+ saving,
+ onSave,
+ onCancel,
+}: ExistingSkillEditorProps) {
+ return (
+
+
+
+ {skill.name}
+
+
+ {skill.path}
+
+
+
+
+
+
+
+ Cancel
+
+
+ {saving ? : }
+ Save
+
+
+
+ );
+}
+
+/** Editor for a brand-new cockpit-store skill (name + SKILL.md). */
+interface NewSkillEditorProps {
+ readonly name: string;
+ readonly onNameChange: (name: string) => void;
+ readonly text: string;
+ readonly onTextChange: (text: string) => void;
+ readonly saving: boolean;
+ readonly onSave: () => void;
+ readonly onCancel: () => void;
+}
+
+function NewSkillEditor({
+ name,
+ onNameChange,
+ text,
+ onTextChange,
+ saving,
+ onSave,
+ onCancel,
+}: NewSkillEditorProps) {
+ const canSave = name.trim() !== "";
+ return (
+
+
+ New skill
+
+
+
+ {
+ onNameChange(e.target.value);
+ }}
+ placeholder="rust-testing"
+ autoFocus
+ />
+
+
+
+
+
+
+ Cancel
+
+
+ {saving ? : }
+ Save
+
+
+
+ );
+}
diff --git a/app/src/lib/skill-groups.test.ts b/app/src/lib/skill-groups.test.ts
new file mode 100644
index 0000000..c0183c6
--- /dev/null
+++ b/app/src/lib/skill-groups.test.ts
@@ -0,0 +1,193 @@
+import { describe, it, expect } from "vitest";
+import { groupSkills, filterSkills } from "./skill-groups";
+import type { Skill } from "../bindings/Skill";
+import type { SkillSource } from "../bindings/SkillSource";
+
+/**
+ * Build a `Skill` matching the wire shape exactly. `satisfies Skill` keeps the
+ * fixture honest against the generated binding without widening via `as`.
+ */
+function makeSkill(
+ name: string,
+ source: SkillSource,
+ overrides: Partial
= {},
+): Skill {
+ return {
+ name,
+ description: `${name} description`,
+ tags: [],
+ body: `# ${name}`,
+ path: `/skills/${name}`,
+ source,
+ in_review_pipeline: false,
+ ...overrides,
+ } satisfies Skill;
+}
+
+const store: SkillSource = { kind: "CockpitStore" };
+const personal: SkillSource = { kind: "Personal" };
+const project: SkillSource = { kind: "Project" };
+const nexcade: SkillSource = { kind: "Plugin", marketplace: "nexcade" };
+const acme: SkillSource = { kind: "Plugin", marketplace: "acme" };
+
+describe("groupSkills — display order", () => {
+ it("orders groups pipeline, personal, plugins, project, store", () => {
+ // Input arrives in the backend's discovery order: store, personal, plugin,
+ // project. The view reorders it for display.
+ const skills = [
+ makeSkill("store-a", store),
+ makeSkill("personal-a", personal),
+ makeSkill("plugin-a", nexcade),
+ makeSkill("project-a", project),
+ ];
+
+ const groups = groupSkills(skills);
+
+ expect(groups.map((g) => g.kind.kind)).toEqual([
+ "personal",
+ "plugin",
+ "project",
+ "store",
+ ]);
+ });
+
+ it("splits plugins into one group per marketplace, sorted by marketplace", () => {
+ const skills = [
+ makeSkill("z-plugin", nexcade),
+ makeSkill("a-plugin", acme),
+ ];
+
+ const groups = groupSkills(skills);
+ const pluginGroups = groups.filter((g) => g.kind.kind === "plugin");
+
+ expect(pluginGroups.map((g) => g.hint)).toEqual(["acme", "nexcade"]);
+ // The plugin header label is fixed; the marketplace rides in the hint.
+ expect(pluginGroups.every((g) => g.label === "PLUGINS")).toBe(true);
+ });
+
+ it("carries the path hint on each fixed group header", () => {
+ const skills = [
+ makeSkill("p", personal),
+ makeSkill("j", project),
+ makeSkill("s", store),
+ ];
+ const byKind = new Map(groupSkills(skills).map((g) => [g.kind.kind, g]));
+
+ expect(byKind.get("personal")?.hint).toBe("~/.claude/skills");
+ expect(byKind.get("project")?.hint).toBe(".claude/skills");
+ expect(byKind.get("store")?.hint).toBe("~/.cockpit/skills");
+ });
+
+ it("preserves discovery order of skills within a group", () => {
+ const skills = [
+ makeSkill("first", personal),
+ makeSkill("second", personal),
+ makeSkill("third", personal),
+ ];
+ const personalGroup = groupSkills(skills).find(
+ (g) => g.kind.kind === "personal",
+ );
+
+ expect(personalGroup?.skills.map((s) => s.name)).toEqual([
+ "first",
+ "second",
+ "third",
+ ]);
+ });
+});
+
+describe("groupSkills — review pipeline pin", () => {
+ it("pins a REVIEW PIPELINE group first with every in-pipeline skill", () => {
+ const skills = [
+ makeSkill("store-on", store, { in_review_pipeline: true }),
+ makeSkill("personal-off", personal),
+ makeSkill("plugin-on", nexcade, { in_review_pipeline: true }),
+ ];
+
+ const groups = groupSkills(skills);
+
+ expect(groups[0]?.kind.kind).toBe("pipeline");
+ expect(groups[0]?.label).toBe("REVIEW PIPELINE");
+ expect(groups[0]?.hint).toBeNull();
+ // Pinned pipeline collects across sources, regardless of origin group.
+ expect(groups[0]?.skills.map((s) => s.name)).toEqual([
+ "store-on",
+ "plugin-on",
+ ]);
+ });
+
+ it("keeps in-pipeline skills in their own source group too", () => {
+ const skills = [makeSkill("store-on", store, { in_review_pipeline: true })];
+
+ const groups = groupSkills(skills);
+ const storeGroup = groups.find((g) => g.kind.kind === "store");
+
+ expect(storeGroup?.skills.map((s) => s.name)).toEqual(["store-on"]);
+ });
+
+ it("omits the pipeline group when no skill is in the pipeline", () => {
+ const groups = groupSkills([makeSkill("plain", personal)]);
+ expect(groups.some((g) => g.kind.kind === "pipeline")).toBe(false);
+ });
+});
+
+describe("groupSkills — empty-source handling", () => {
+ it("always emits the store group even with no skills", () => {
+ const groups = groupSkills([]);
+ expect(groups.map((g) => g.kind.kind)).toEqual(["store"]);
+ expect(groups[0]?.skills).toEqual([]);
+ });
+
+ it("omits empty non-store groups", () => {
+ const groups = groupSkills([makeSkill("only-personal", personal)]);
+ const kinds = groups.map((g) => g.kind.kind);
+
+ expect(kinds).toContain("personal");
+ expect(kinds).toContain("store");
+ expect(kinds).not.toContain("project");
+ expect(kinds).not.toContain("plugin");
+ expect(kinds).not.toContain("pipeline");
+ });
+});
+
+describe("filterSkills", () => {
+ const skills = [
+ makeSkill("rust-testing", personal, {
+ description: "Write idiomatic Rust tests for review",
+ }),
+ makeSkill("ui-review", nexcade, {
+ description: "Check rendered UI diffs",
+ }),
+ makeSkill("SQL-audit", store, { description: "Inspect queries" }),
+ ];
+
+ it("returns everything for an empty or whitespace query", () => {
+ expect(filterSkills(skills, "")).toHaveLength(3);
+ expect(filterSkills(skills, " ")).toHaveLength(3);
+ });
+
+ it("matches on name, case-insensitively", () => {
+ const hits = filterSkills(skills, "RUST");
+ expect(hits.map((s) => s.name)).toEqual(["rust-testing"]);
+ });
+
+ it("matches on description, case-insensitively", () => {
+ const hits = filterSkills(skills, "rendered");
+ expect(hits.map((s) => s.name)).toEqual(["ui-review"]);
+ });
+
+ it("filters across all groups before grouping", () => {
+ // "review" hits the personal skill's description and the plugin skill's
+ // name — search is applied first, then grouping spans both source groups.
+ const filtered = filterSkills(skills, "review");
+ const groups = groupSkills(filtered);
+ const kinds = groups.map((g) => g.kind.kind);
+
+ expect(filtered.map((s) => s.name)).toEqual(["rust-testing", "ui-review"]);
+ expect(kinds).toContain("personal");
+ expect(kinds).toContain("plugin");
+ // The store group is always emitted (it carries the Install action) but has
+ // no matches under this query.
+ expect(groups.find((g) => g.kind.kind === "store")?.skills).toEqual([]);
+ });
+});
diff --git a/app/src/lib/skill-groups.ts b/app/src/lib/skill-groups.ts
new file mode 100644
index 0000000..5292fd2
--- /dev/null
+++ b/app/src/lib/skill-groups.ts
@@ -0,0 +1,159 @@
+/**
+ * Pure grouping/filtering helpers for the Skills view.
+ *
+ * The view is a master/detail on a single axis: the skill's install *source*.
+ * These helpers turn the flat `Skill[]` (arriving in the backend's stable
+ * discovery order) into the ordered, source-grouped shape the list renders,
+ * plus a case-insensitive search filter. Kept pure and framework-free so the
+ * ordering contract can be unit-tested without mounting React.
+ */
+import type { Skill } from "../bindings/Skill";
+import type { SkillSource } from "../bindings/SkillSource";
+
+/**
+ * Which bucket a rendered group represents. Mirrors [`SkillSource`] plus the
+ * synthetic pinned `pipeline` bucket, so a `switch` over it is exhaustive.
+ */
+export type SkillGroupKind =
+ | { readonly kind: "pipeline" }
+ | { readonly kind: "personal" }
+ | { readonly kind: "plugin"; readonly marketplace: string }
+ | { readonly kind: "project" }
+ | { readonly kind: "store" };
+
+/** A rendered list group: a header (label + optional path hint) and its skills. */
+export interface SkillGroup {
+ /** Stable React key / test handle. */
+ readonly key: string;
+ /** Discriminated bucket identity, carrying the marketplace for plugin groups. */
+ readonly kind: SkillGroupKind;
+ /** Mono uppercase header label (e.g. `"PERSONAL"`). */
+ readonly label: string;
+ /** Second header segment after `·` (path or marketplace); `null` for pipeline. */
+ readonly hint: string | null;
+ /** The skills in this group, preserving the input's discovery order. */
+ readonly skills: readonly Skill[];
+}
+
+/** Exhaustiveness guard: fails to compile if a `SkillSource` variant is added. */
+function assertNever(x: never): never {
+ throw new Error(`unreachable SkillSource: ${JSON.stringify(x)}`);
+}
+
+/**
+ * Filter skills by a case-insensitive substring match on name or description.
+ *
+ * An empty/whitespace query returns the input unchanged. Matching preserves the
+ * input order so grouping downstream stays stable.
+ */
+export function filterSkills(
+ skills: readonly Skill[],
+ query: string,
+): readonly Skill[] {
+ const needle = query.trim().toLowerCase();
+ if (needle === "") return skills;
+ return skills.filter(
+ (skill) =>
+ skill.name.toLowerCase().includes(needle) ||
+ skill.description.toLowerCase().includes(needle),
+ );
+}
+
+/**
+ * Group skills by install source into the view's display order.
+ *
+ * Order: the pinned `REVIEW PIPELINE` bucket first (every skill with
+ * `in_review_pipeline`, regardless of source), then `PERSONAL`, one `PLUGINS`
+ * group per marketplace (marketplaces sorted), `PROJECT`, and finally the
+ * `COCKPIT STORE`. Empty groups are omitted, except the store group, which is
+ * always emitted because it carries the Install/sync affordances. Skills keep
+ * the input's discovery order within each group.
+ */
+export function groupSkills(skills: readonly Skill[]): readonly SkillGroup[] {
+ const personal: Skill[] = [];
+ const project: Skill[] = [];
+ const store: Skill[] = [];
+ const plugins = new Map();
+
+ for (const skill of skills) {
+ const source: SkillSource = skill.source;
+ switch (source.kind) {
+ case "Personal":
+ personal.push(skill);
+ break;
+ case "Project":
+ project.push(skill);
+ break;
+ case "CockpitStore":
+ store.push(skill);
+ break;
+ case "Plugin": {
+ const bucket = plugins.get(source.marketplace);
+ if (bucket === undefined) {
+ plugins.set(source.marketplace, [skill]);
+ } else {
+ bucket.push(skill);
+ }
+ break;
+ }
+ default:
+ assertNever(source);
+ }
+ }
+
+ const pipeline = skills.filter((skill) => skill.in_review_pipeline);
+
+ const groups: SkillGroup[] = [];
+
+ if (pipeline.length > 0) {
+ groups.push({
+ key: "pipeline",
+ kind: { kind: "pipeline" },
+ label: "REVIEW PIPELINE",
+ hint: null,
+ skills: pipeline,
+ });
+ }
+ if (personal.length > 0) {
+ groups.push({
+ key: "personal",
+ kind: { kind: "personal" },
+ label: "PERSONAL",
+ hint: "~/.claude/skills",
+ skills: personal,
+ });
+ }
+ for (const marketplace of [...plugins.keys()].sort()) {
+ const bucket = plugins.get(marketplace);
+ // INVARIANT: `marketplace` came from `plugins.keys()`, so the bucket exists
+ // and is non-empty. The guard satisfies noUncheckedIndexedAccess.
+ if (bucket === undefined) continue;
+ groups.push({
+ key: `plugin:${marketplace}`,
+ kind: { kind: "plugin", marketplace },
+ label: "PLUGINS",
+ hint: marketplace,
+ skills: bucket,
+ });
+ }
+ if (project.length > 0) {
+ groups.push({
+ key: "project",
+ kind: { kind: "project" },
+ label: "PROJECT",
+ hint: ".claude/skills",
+ skills: project,
+ });
+ }
+ // The store group always shows: it carries the Install/sync affordances even
+ // when empty (design decision, see OVERNIGHT_UX_PLAN.md).
+ groups.push({
+ key: "store",
+ kind: { kind: "store" },
+ label: "COCKPIT STORE",
+ hint: "~/.cockpit/skills",
+ skills: store,
+ });
+
+ return groups;
+}
diff --git a/app/src/store.test.ts b/app/src/store.test.ts
index 949af9f..b06d085 100644
--- a/app/src/store.test.ts
+++ b/app/src/store.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import type { PendingPermission } from "./store";
import type { Review } from "./bindings/Review";
+import type { Skill } from "./bindings/Skill";
import type { CiCheck } from "./bindings/CiCheck";
import type { SubmitReviewResult } from "./bindings/SubmitReviewResult";
import type { EvidenceSummary } from "./bindings/EvidenceSummary";
@@ -843,3 +844,74 @@ describe("applyRestackProgress", () => {
expect(progress["root-b"]?.status).toBe("done");
});
});
+
+describe("setSkillReviewPipeline", () => {
+ const storeSkill = {
+ name: "house-style",
+ description: "d",
+ tags: [],
+ body: "b",
+ path: "/tmp/store/house-style",
+ source: { kind: "CockpitStore" },
+ in_review_pipeline: false,
+ } satisfies Skill;
+
+ it("flips optimistically, then reconciles with the returned list", async () => {
+ useAppStore.setState({ skills: [storeSkill] });
+ const returned = [{ ...storeSkill, in_review_pipeline: true }];
+ let observedDuringFlight: boolean | undefined;
+ mockInvoke("set_skill_review_pipeline", () => {
+ observedDuringFlight =
+ useAppStore.getState().skills[0]?.in_review_pipeline;
+ return returned;
+ });
+
+ await useAppStore
+ .getState()
+ .setSkillReviewPipeline("house-style", true);
+
+ expect(observedDuringFlight).toBe(true);
+ expect(useAppStore.getState().skills).toEqual(returned);
+ expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
+ });
+
+ it("rolls back via a re-read when the command fails", async () => {
+ useAppStore.setState({ skills: [storeSkill] });
+ mockInvokeReject("set_skill_review_pipeline", "config write failed");
+ mockInvoke("list_skills", () => [storeSkill]);
+
+ await useAppStore
+ .getState()
+ .setSkillReviewPipeline("house-style", true);
+
+ expect(useAppStore.getState().skills[0]?.in_review_pipeline).toBe(false);
+ expect(useAppStore.getState().error).toContain("config write failed");
+ expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
+ });
+
+ it("ignores a re-entrant toggle while one is in flight", async () => {
+ useAppStore.setState({ skills: [storeSkill] });
+ let release: (() => void) | undefined;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ mockInvoke("set_skill_review_pipeline", async () => {
+ await gate;
+ return [{ ...storeSkill, in_review_pipeline: true }];
+ });
+
+ const first = useAppStore
+ .getState()
+ .setSkillReviewPipeline("house-style", true);
+ // Second click while the first is pending must be dropped, not queued.
+ const second = useAppStore
+ .getState()
+ .setSkillReviewPipeline("house-style", false);
+ release?.();
+ await Promise.all([first, second]);
+
+ expect(callsFor("set_skill_review_pipeline")).toHaveLength(1);
+ expect(useAppStore.getState().skills[0]?.in_review_pipeline).toBe(true);
+ expect(useAppStore.getState().pendingSkillToggles).toEqual([]);
+ });
+});
diff --git a/app/src/store.ts b/app/src/store.ts
index 96d2c8a..1894e63 100644
--- a/app/src/store.ts
+++ b/app/src/store.ts
@@ -415,6 +415,24 @@ interface AppStore {
/** Sync skills from the configured GitHub source. */
syncSkills: () => Promise;
+ /**
+ * Skill names with a review-pipeline toggle currently in flight. The UI
+ * disables those switches so a rapid double-toggle can never race two
+ * commands whose stale response would clobber the user's final click.
+ */
+ readonly pendingSkillToggles: readonly string[];
+
+ /**
+ * Toggle a skill's membership in the review pipeline (explicit user action).
+ *
+ * Flips `in_review_pipeline` optimistically for instant feedback, then
+ * reconciles `skills` with the refreshed list the command returns. On failure
+ * it surfaces the error and re-reads the authoritative list so the optimistic
+ * flip never sticks. Re-entrant toggles for a skill already in flight are
+ * ignored (the switch is disabled while pending).
+ */
+ setSkillReviewPipeline: (name: string, enabled: boolean) => Promise;
+
// -------------------------------------------------------------------------
// Agent prompts
// -------------------------------------------------------------------------
@@ -525,6 +543,7 @@ export const useAppStore = create((set, get) => ({
kickoffResult: null,
skills: [],
skillsLoading: false,
+ pendingSkillToggles: [],
authoredPrs: [],
reviewRequests: [],
prFetchLoading: false,
@@ -1190,6 +1209,37 @@ export const useAppStore = create((set, get) => ({
}
},
+ setSkillReviewPipeline: async (name: string, enabled: boolean) => {
+ if (get().pendingSkillToggles.includes(name)) return;
+ // Optimistic flip so the toggle feels instant; reconciled below.
+ set({
+ pendingSkillToggles: [...get().pendingSkillToggles, name],
+ skills: get().skills.map((s) =>
+ s.name === name ? { ...s, in_review_pipeline: enabled } : s,
+ ),
+ error: null,
+ });
+ try {
+ const skills = await invoke("set_skill_review_pipeline", {
+ name,
+ enabled,
+ });
+ set({ skills });
+ } catch (e: unknown) {
+ // Re-read the authoritative list so the optimistic flip does not stick,
+ // THEN surface the error — listSkills clears `error` on entry, so the
+ // opposite order would wipe the message before anyone saw it.
+ await get().listSkills();
+ set({ error: String(e) });
+ } finally {
+ set({
+ pendingSkillToggles: get().pendingSkillToggles.filter(
+ (n) => n !== name,
+ ),
+ });
+ }
+ },
+
// -------------------------------------------------------------------------
// Agent prompts
// -------------------------------------------------------------------------