From d3f94a88798141d76ac6561b5307a68bdf1445fd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:23:17 -0700 Subject: [PATCH 1/4] CL-5790: Skills page K1 shell (toolbar, empty state, create dialog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Skills page is no longer a stub — it renders the agents-style chrome (toolbar with search + view toggle), an honest empty state with a primary Create skill action, and a CreateSkillDialog that collects name, body, and description. The dialog never POSTs (no skill registry yet) but collects the values a future registry will expect. --- apps/web/src/pages/create-skill-dialog.tsx | 172 +++++++++++++++++++++ apps/web/src/pages/skills-page.tsx | 71 +++++++-- apps/web/test/pages.test.tsx | 7 +- apps/web/test/skills-page.test.tsx | 49 ++++++ 4 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/pages/create-skill-dialog.tsx create mode 100644 apps/web/test/skills-page.test.tsx diff --git a/apps/web/src/pages/create-skill-dialog.tsx b/apps/web/src/pages/create-skill-dialog.tsx new file mode 100644 index 000000000..511540957 --- /dev/null +++ b/apps/web/src/pages/create-skill-dialog.tsx @@ -0,0 +1,172 @@ +// The create-skill form: identity (name, description) and the skill +// body itself (the instructions/tools text). There is no skill +// registry in the hub yet, so this dialog never POSTs — it collects +// the values a future registry will expect and hands them to +// `onCreated` so the page can decide what to do once a seam is real. + +import { + Button, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + IntakeForm, + intakeFieldsComplete, +} from "@corbits/react-ui"; +import type { IntakeField } from "@corbits/react-ui"; +import { useState } from "react"; + +export type SkillDraft = { + readonly name: string; + readonly description: string; + readonly body: string; +}; + +type FormValues = { + readonly name: string; + readonly description: string; + readonly body: string; +}; + +const EMPTY_VALUES: FormValues = { + name: "", + description: "", + body: "", +}; + +const FIELDS: readonly IntakeField[] = [ + { + name: "name", + label: "Name", + type: "text", + required: true, + placeholder: "Summarize transcript", + }, + { + name: "description", + label: "Description", + type: "textarea", + placeholder: "What this skill does and when to use it", + }, + { + name: "body", + label: "Skill body", + type: "textarea", + required: true, + placeholder: "Instructions, tools, and guardrails this skill packages…", + help: "The instructions an agent definition can declare and a bench can install.", + }, +]; + +/** Every reason a submission is not yet valid, in plain language — never + * a generic "invalid form". Exported so the create flow can be proven + * without SSR-rendering the portal-based dialog (see chat-ui's + * NewChannelDialog note: Radix portals yield no static markup). */ +export function validationIssues(values: FormValues): readonly string[] { + const issues: string[] = []; + if (values.name.trim() === "") issues.push("Name is required."); + if (values.body.trim() === "") issues.push("Skill body is required."); + return issues; +} + +export function CreateSkillDialog({ + open, + onOpenChange, + onCreated, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + /** Receives the drafted values; the page owns what (if anything) happens + * with them. No backend is wired up at this stage. */ + readonly onCreated: (draft: SkillDraft) => void; +}) { + const [values, setValues] = useState(EMPTY_VALUES); + const [showIssues, setShowIssues] = useState(false); + + function reset() { + setValues(EMPTY_VALUES); + setShowIssues(false); + } + + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); + } + + function handleFormChange(next: Record) { + setValues({ + name: typeof next.name === "string" ? next.name : values.name, + description: + typeof next.description === "string" ? next.description : "", + body: typeof next.body === "string" ? next.body : values.body, + }); + } + + const issues = validationIssues(values); + + function handleSubmit() { + if (issues.length > 0) { + setShowIssues(true); + return; + } + const draft: SkillDraft = { + name: values.name.trim(), + description: values.description.trim(), + body: values.body.trim(), + }; + reset(); + onOpenChange(false); + onCreated(draft); + } + + return ( + + + + Create skill + + Define a reusable capability an agent can declare and a bench can + install. + + + + {showIssues && issues.length > 0 && ( +
    + {issues.map((issue) => ( +
  • {issue}
  • + ))} +
+ )} + +
+ + + + +
+
+ ); +} diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index f4cf0d724..3e350392d 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -1,23 +1,74 @@ -import { PageShell, RichEmptyState } from "@corbits/react-ui"; +import { + LibrarySearchInput, + PageShell, + RichEmptyState, + ViewToggle, +} from "@corbits/react-ui"; +import type { ViewMode } from "@corbits/react-ui"; import { Sparkles } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { CreateSkillDialog } from "./create-skill-dialog"; /** - * An honest stub, not a placeholder: there is no skill registry in the hub - * yet, so this page describes what a skill will be rather than rendering - * invented rows against a surface with nothing real behind it. + * The Skills page shell. There is no skill registry in the hub yet, so + * this surface renders the agents-style chrome — toolbar (search + view + * toggle), an honest empty state with a primary "Create skill" action — + * against a list that is, for now, always empty. The create dialog + * collects a draft but never POSTs; once a seam is real it will feed a + * list instead of an empty state. */ export function SkillsPage() { + const [query, setQuery] = useState(""); + const [viewMode, setViewMode] = useState("grid"); + const [createOpen, setCreateOpen] = useState(false); + + // Mirror the agents page: a `workbench:skills:create` window event + // opens the create dialog from anywhere (e.g. the command palette). + useEffect(() => { + const onCreate = () => setCreateOpen(true); + window.addEventListener("workbench:skills:create", onCreate); + return () => + window.removeEventListener("workbench:skills:create", onCreate); + }, []); + return ( - - } - title="Skills aren't built yet" - description="A skill will be a named, reusable capability — instructions, tools, and guardrails packaged together — that an agent definition can declare and a bench can install. There's no skill registry in the hub yet, so this page has nothing real to list." + <> +
+ + +
+ + } + title="No skills yet" + description="A skill is a named, reusable capability — instructions, tools, and guardrails packaged together — that an agent definition can declare and a bench can install. There's no skill registry yet, so this page has nothing real to list." + actions={[ + { + label: "Create skill", + onClick: () => setCreateOpen(true), + variant: "primary", + }, + ]} + /> + + { + /* No registry yet — the draft is accepted and dropped. */ + }} /> -
+ ); } +/** A thin wrapper kept for parity with the other route exports; the + * shell owns no tenant/data wiring yet, so the route is the page. */ export function SkillsRoute() { return ; } diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index f8c357e47..a27e7be9c 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -41,10 +41,11 @@ describe("empty states", () => { expect(markup).toContain("This workbench has no assets yet"); }); - test("skills describes itself instead of faking content", () => { + test("skills renders the shell with an honest empty state and Create action", () => { const markup = renderToStaticMarkup(); - expect(markup).toContain("Skills aren"); - expect(markup).toContain("built yet"); + expect(markup).toContain("No skills yet"); + expect(markup).toContain("Create skill"); + expect(markup).toContain("Search skills"); }); test("agents reports a missing session instead of empty panels", () => { diff --git a/apps/web/test/skills-page.test.tsx b/apps/web/test/skills-page.test.tsx new file mode 100644 index 000000000..1d58693f5 --- /dev/null +++ b/apps/web/test/skills-page.test.tsx @@ -0,0 +1,49 @@ +// Screen-level proof for the Skills page shell (UI only). The page is +// honest about having no registry yet: it renders an empty state and a +// Create action, and the create dialog collects a draft but never POSTs. +// Mirrors the SSR shape used by pages.test.tsx / agents-page.test.tsx. + +import { describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { validationIssues } from "../src/pages/create-skill-dialog"; +import { SkillsPage } from "../src/pages/skills-page"; + +describe("SkillsPage shell", () => { + test("renders the toolbar and the honest empty state", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("Search skills"); + expect(markup).toContain("No skills yet"); + expect(markup).toContain("reusable capability"); + }); + + test("exposes a primary Create skill action from the empty state", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("Create skill"); + }); +}); + +describe("CreateSkillDialog", () => { + // The dialog renders through @corbits/react-ui's Radix Dialog.Portal, which + // needs a real DOM and yields no markup under renderToStaticMarkup (same + // reason chat-ui's NewChannelDialog has no render test). Its validation + // logic is exported and tested directly instead. + test("an empty draft is missing a name and a body, never a description", () => { + expect(validationIssues({ name: "", description: "", body: "" })).toEqual([ + "Name is required.", + "Skill body is required.", + ]); + }); + + test("a name without a body still cannot be submitted", () => { + expect(validationIssues({ name: "Summarize", description: "", body: "" })).toEqual([ + "Skill body is required.", + ]); + }); + + test("a complete draft has no validation issues", () => { + expect( + validationIssues({ name: "Summarize", description: "x", body: "do the thing" }), + ).toEqual([]); + }); +}); From ce4ddfa3c0993b6060f752d80fb6a636a921f63a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:25:52 -0700 Subject: [PATCH 2/4] Format with Prettier for CI --- apps/web/src/pages/create-skill-dialog.tsx | 3 +-- apps/web/test/skills-page.test.tsx | 12 ++++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/pages/create-skill-dialog.tsx b/apps/web/src/pages/create-skill-dialog.tsx index 511540957..cb4ae6a98 100644 --- a/apps/web/src/pages/create-skill-dialog.tsx +++ b/apps/web/src/pages/create-skill-dialog.tsx @@ -99,8 +99,7 @@ export function CreateSkillDialog({ function handleFormChange(next: Record) { setValues({ name: typeof next.name === "string" ? next.name : values.name, - description: - typeof next.description === "string" ? next.description : "", + description: typeof next.description === "string" ? next.description : "", body: typeof next.body === "string" ? next.body : values.body, }); } diff --git a/apps/web/test/skills-page.test.tsx b/apps/web/test/skills-page.test.tsx index 1d58693f5..7945e2549 100644 --- a/apps/web/test/skills-page.test.tsx +++ b/apps/web/test/skills-page.test.tsx @@ -36,14 +36,18 @@ describe("CreateSkillDialog", () => { }); test("a name without a body still cannot be submitted", () => { - expect(validationIssues({ name: "Summarize", description: "", body: "" })).toEqual([ - "Skill body is required.", - ]); + expect( + validationIssues({ name: "Summarize", description: "", body: "" }), + ).toEqual(["Skill body is required."]); }); test("a complete draft has no validation issues", () => { expect( - validationIssues({ name: "Summarize", description: "x", body: "do the thing" }), + validationIssues({ + name: "Summarize", + description: "x", + body: "do the thing", + }), ).toEqual([]); }); }); From d2e0de53ddacc384abe8d5d57f71b5ce30e6121c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:13:55 -0700 Subject: [PATCH 3/4] CL-5790: Add tests for hiding toolbar when skills list is empty --- apps/web/test/skills-page.test.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/web/test/skills-page.test.tsx b/apps/web/test/skills-page.test.tsx index 7945e2549..145536327 100644 --- a/apps/web/test/skills-page.test.tsx +++ b/apps/web/test/skills-page.test.tsx @@ -10,13 +10,17 @@ import { validationIssues } from "../src/pages/create-skill-dialog"; import { SkillsPage } from "../src/pages/skills-page"; describe("SkillsPage shell", () => { - test("renders the toolbar and the honest empty state", () => { + test("renders the honest empty state", () => { const markup = renderToStaticMarkup(); - expect(markup).toContain("Search skills"); expect(markup).toContain("No skills yet"); expect(markup).toContain("reusable capability"); }); + test("hides the toolbar (search + view toggle) when there are no skills", () => { + const markup = renderToStaticMarkup(); + expect(markup).not.toContain("Search skills"); + }); + test("exposes a primary Create skill action from the empty state", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("Create skill"); From c17ed771affaa38248307e92d34d353fdda304c9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 17:13:59 -0700 Subject: [PATCH 4/4] CL-5790: Hide toolbar controls when no skills exist --- apps/web/src/pages/skills-page.tsx | 27 +++++++++++++++++++-------- apps/web/test/pages.test.tsx | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index 3e350392d..a4d5f64ce 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -17,12 +17,21 @@ import { CreateSkillDialog } from "./create-skill-dialog"; * against a list that is, for now, always empty. The create dialog * collects a draft but never POSTs; once a seam is real it will feed a * list instead of an empty state. + * + * The toolbar (search + view toggle) is gated behind a non-empty skills + * list: with nothing to search or toggle, those controls would be inert + * chrome, so the empty state's "Create skill" action is the sole + * affordance until a registry exists. */ export function SkillsPage() { const [query, setQuery] = useState(""); const [viewMode, setViewMode] = useState("grid"); const [createOpen, setCreateOpen] = useState(false); + // No skill registry yet — the list is always empty. Kept as a local so + // the toolbar gate reads honestly and is ready to wire to real data. + const skills: unknown[] = []; + // Mirror the agents page: a `workbench:skills:create` window event // opens the create dialog from anywhere (e.g. the command palette). useEffect(() => { @@ -34,14 +43,16 @@ export function SkillsPage() { return ( <> -
- - -
+ {skills.length > 0 && ( +
+ + +
+ )} } diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index a27e7be9c..b752589d6 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -45,7 +45,7 @@ describe("empty states", () => { const markup = renderToStaticMarkup(); expect(markup).toContain("No skills yet"); expect(markup).toContain("Create skill"); - expect(markup).toContain("Search skills"); + expect(markup).not.toContain("Search skills"); }); test("agents reports a missing session instead of empty panels", () => {