Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions apps/web/src/pages/create-skill-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// 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<FormValues>(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<string, unknown>) {
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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create skill</DialogTitle>
<DialogDescription>
Define a reusable capability an agent can declare and a bench can
install.
</DialogDescription>
</DialogHeader>
<DialogBody>
{showIssues && issues.length > 0 && (
<ul
className="mb-3 list-inside list-disc text-sm text-destructive"
role="alert"
>
{issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<IntakeForm
fields={FIELDS}
values={values}
onChange={handleFormChange}
idPrefix="create-skill"
/>
</DialogBody>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSubmit}
disabled={!intakeFieldsComplete(FIELDS, values)}
>
Create skill
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
82 changes: 72 additions & 10 deletions apps/web/src/pages/skills-page.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,85 @@
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.
*
* 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<ViewMode>("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(() => {
const onCreate = () => setCreateOpen(true);
window.addEventListener("workbench:skills:create", onCreate);
return () =>
window.removeEventListener("workbench:skills:create", onCreate);
}, []);

return (
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<Sparkles />}
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."
<>
{skills.length > 0 && (
<div className="page-toolbar">
<LibrarySearchInput
label="Search skills"
value={query}
onChange={setQuery}
/>
<ViewToggle mode={viewMode} onChange={setViewMode} />
</div>
)}
<PageShell width="full" className="page-fill">
<RichEmptyState
icon={<Sparkles />}
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",
},
]}
/>
</PageShell>
<CreateSkillDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={() => {
/* No registry yet — the draft is accepted and dropped. */
}}
/>
</PageShell>
</>
);
}

/** 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 <SkillsPage />;
}
7 changes: 4 additions & 3 deletions apps/web/test/pages.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<SkillsPage />);
expect(markup).toContain("Skills aren");
expect(markup).toContain("built yet");
expect(markup).toContain("No skills yet");
expect(markup).toContain("Create skill");
expect(markup).not.toContain("Search skills");
});

test("agents reports a missing session instead of empty panels", () => {
Expand Down
57 changes: 57 additions & 0 deletions apps/web/test/skills-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// 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 honest empty state", () => {
const markup = renderToStaticMarkup(<SkillsPage />);
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(<SkillsPage />);
expect(markup).not.toContain("Search skills");
});

test("exposes a primary Create skill action from the empty state", () => {
const markup = renderToStaticMarkup(<SkillsPage />);
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([]);
});
});
Loading