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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@corbits/shell-layout": "workspace:*",
"@corbits/slug": "workspace:*",
"@corbits/tasks-ui": "workspace:*",
"@corbits/text-diff": "workspace:*",
"@corbits/url-path": "workspace:*",
"@corbits/workflow-catalog": "workspace:*",
"@corbits/icons": "workspace:*",
Expand Down
75 changes: 21 additions & 54 deletions apps/web/src/pages/create-skill-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
// frontmatter must carry. Rejecting it here beats a server error after
// the person has typed a whole skill body.
//
// CL-6355: the same form doubles as the edit surface — `mode="edit"` seeds
// it from `initialValues` and locks the name field (a skill's name is its
// identity; renaming means creating a new one). No second editor
// component: `SkillDetailView`'s "Edit" affordance opens this dialog with
// `mode="edit"` rather than duplicating the form.
// Creation only. Editing an existing skill happens on its own page
// (`skill-detail-page.tsx`, CL-6416), where a save is reviewed as a diff
// before it publishes a new version — this dialog has no edit mode to
// duplicate that flow.

import {
Button,
Expand Down Expand Up @@ -63,7 +62,8 @@ const NAME_FIELD: IntakeField = {
help: "Lowercase letters, digits, and hyphens — this becomes the skill's name in the registry.",
};

const DESCRIPTION_AND_BODY_FIELDS: readonly IntakeField[] = [
const FIELDS: readonly IntakeField[] = [
NAME_FIELD,
{
name: "description",
label: "Description",
Expand All @@ -82,33 +82,12 @@ const DESCRIPTION_AND_BODY_FIELDS: readonly IntakeField[] = [
},
];

const CREATE_FIELDS: readonly IntakeField[] = [
NAME_FIELD,
...DESCRIPTION_AND_BODY_FIELDS,
];

/** Edit mode drops the name field entirely rather than disabling it — a
* skill's name is its identity, not an editable property; renaming means
* creating a differently-named skill. The dialog shows it as static text
* instead (see `DialogDescription` below). */
const EDIT_FIELDS: readonly IntakeField[] = DESCRIPTION_AND_BODY_FIELDS;

/** 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 (Radix portals yield no
* static markup). `mode="edit"` skips name validation — the field isn't
* shown, and the value carried through unchanged is already a valid name. */
export function validationIssues(
values: FormValues,
mode: "create" | "edit" = "create",
): readonly string[] {
* static markup). */
export function validationIssues(values: FormValues): readonly string[] {
const issues: string[] = [];
if (mode === "edit") {
if (values.description.trim() === "")
issues.push("Description is required.");
if (values.body.trim() === "") issues.push("Skill body is required.");
return issues;
}
const name = values.name.trim();
if (name === "") {
issues.push("Name is required.");
Expand All @@ -128,34 +107,26 @@ export function CreateSkillDialog({
open,
onOpenChange,
onSubmit,
mode = "create",
initialValues,
}: {
readonly open: boolean;
readonly onOpenChange: (open: boolean) => void;
/** Writes the skill to the registry — `createSkill` in create mode,
* `updateSkill` (a new version) in edit mode. A rejection's message is
* shown inline and the form is left as typed. */
/** Writes the skill to the registry. A rejection's message is shown
* inline and the form is left as typed. */
readonly onSubmit: (input: SkillCreateInput) => Promise<void>;
readonly mode?: "create" | "edit";
/** Required in edit mode: seeds the form with the skill being edited. */
readonly initialValues?: SkillCreateInput;
}) {
const startingValues = initialValues ?? EMPTY_VALUES;
const [values, setValues] = useState<FormValues>(startingValues);
const [values, setValues] = useState<FormValues>(EMPTY_VALUES);
const [showIssues, setShowIssues] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);

function reset() {
setValues(startingValues);
setValues(EMPTY_VALUES);
setShowIssues(false);
setServerError(null);
}

function handleOpenChange(next: boolean) {
if (next) setValues(startingValues);
else reset();
reset();
onOpenChange(next);
}

Expand All @@ -167,8 +138,7 @@ export function CreateSkillDialog({
});
}

const fields = mode === "edit" ? EDIT_FIELDS : CREATE_FIELDS;
const issues = validationIssues(values, mode);
const issues = validationIssues(values);

async function handleSubmit() {
if (issues.length > 0) {
Expand All @@ -195,13 +165,10 @@ export function CreateSkillDialog({
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "edit" ? `Edit ${values.name}` : "Create skill"}
</DialogTitle>
<DialogTitle>Create skill</DialogTitle>
<DialogDescription>
{mode === "edit"
? "Saving publishes a new version — the version it replaces stays in history and can be restored."
: "Define a reusable capability an agent can declare and this workbench can share."}
Define a reusable capability an agent can declare and this workbench
can share.
</DialogDescription>
</DialogHeader>
<DialogBody>
Expand All @@ -221,10 +188,10 @@ export function CreateSkillDialog({
</p>
)}
<IntakeForm
fields={fields}
fields={FIELDS}
values={values}
onChange={handleFormChange}
idPrefix={mode === "edit" ? "edit-skill" : "create-skill"}
idPrefix="create-skill"
/>
</DialogBody>
<DialogFooter>
Expand All @@ -238,9 +205,9 @@ export function CreateSkillDialog({
<Button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting || !intakeFieldsComplete(fields, values)}
disabled={submitting || !intakeFieldsComplete(FIELDS, values)}
>
{mode === "edit" ? "Save" : "Create skill"}
Create skill
</Button>
</DialogFooter>
</DialogContent>
Expand Down
19 changes: 2 additions & 17 deletions apps/web/src/pages/detail-placeholders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
// testable before the page behind it exists.

import { Button, EmptyState, PageShell } from "@corbits/react-ui";
import { Lightning, SquaresFour } from "@corbits/icons";
import { SquaresFour } from "@corbits/icons";
import type { Slug } from "@corbits/slug";
import type { ReactNode } from "react";

import { Link } from "../navigation";
import {
PLUGINS_PATH_PREFIX,
SKILLS_PATH_PREFIX,
} from "../path-ids";
import { PLUGINS_PATH_PREFIX } from "../path-ids";
import { StageTopBar } from "../shell/stage-top-bar";

function DetailPlaceholder({
Expand Down Expand Up @@ -49,18 +46,6 @@ function DetailPlaceholder({
);
}

export function SkillDetailPlaceholder({ slug }: { readonly slug: Slug }) {
return (
<DetailPlaceholder
slug={slug}
entity="Skill"
rosterLabel="Skills"
rosterPath={SKILLS_PATH_PREFIX}
icon={<Lightning />}
/>
);
}

export function PluginDetailPlaceholder({ slug }: { readonly slug: Slug }) {
return (
<DetailPlaceholder
Expand Down
120 changes: 120 additions & 0 deletions apps/web/src/pages/diff-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// One diff renderer for every surface that shows "what changed": the
// save-confirmation step and the version comparison on a detail page both
// mount this, so a diff always reads the same way. The line script comes
// from `@corbits/text-diff`; this file is only its presentation, and it
// computes the script exactly once per render — the change summary is read
// off the same result the rows come from.

import { Badge } from "@corbits/react-ui";
import { diffText } from "@corbits/text-diff";
import type { DiffLine } from "@corbits/text-diff";
import { useMemo } from "react";

const MARKER: Record<DiffLine["kind"], string> = {
context: " ",
added: "+",
removed: "-",
skipped: "⋯",
};

const ROW_CLASS: Record<DiffLine["kind"], string> = {
context: "text-muted-foreground",
added: "bg-success/10 text-foreground",
removed: "bg-destructive/10 text-foreground",
skipped: "text-muted-foreground italic",
};

function lineNumber(value: number | null): string {
return value === null ? "" : String(value);
}

export function DiffView({
before,
after,
unchangedNotice = "No changes yet.",
}: {
readonly before: string;
readonly after: string;
readonly unchangedNotice?: string;
}) {
const diff = useMemo(() => diffText(before, after), [before, after]);

if (diff.status === "identical") {
return (
<p className="text-sm text-muted-foreground" data-testid="diff-unchanged">
{unchangedNotice}
</p>
);
}

if (diff.status === "too-large") {
return (
<div className="flex flex-col gap-1" data-testid="diff-too-large">
<p className="text-sm text-foreground">
This change is too large to show line by line — showing a summary
only.
</p>
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{`${String(diff.beforeLines)} lines before, ${String(
diff.afterLines,
)} after — ${String(diff.changedBeforeLines)} rewritten to ${String(
diff.changedAfterLines,
)}`}
</p>
</div>
);
}

return (
<div className="flex flex-col gap-2" data-testid="diff-view">
<p className="font-mono text-xs tabular-nums text-muted-foreground">
{`+${String(diff.totals.added)} added, −${String(
diff.totals.removed,
)} removed`}
</p>
<div className="max-h-96 overflow-auto rounded-md border border-border bg-muted/30">
<table className="w-full border-collapse font-mono text-xs leading-relaxed">
<tbody>
{diff.lines.map((line, index) => (
<tr
key={`${String(index)}:${line.kind}`}
className={ROW_CLASS[line.kind]}
>
<td className="w-10 select-none px-2 text-right tabular-nums text-muted-foreground">
{lineNumber(line.beforeLineNumber)}
</td>
<td className="w-10 select-none px-2 text-right tabular-nums text-muted-foreground">
{lineNumber(line.afterLineNumber)}
</td>
<td className="w-6 select-none px-1 text-center">
{MARKER[line.kind]}
</td>
<td className="whitespace-pre-wrap break-words px-2 py-0.5">
{line.text === "" ? " " : line.text}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

export function DiffHeading({
beforeLabel,
afterLabel,
}: {
readonly beforeLabel: string;
readonly afterLabel: string;
}) {
return (
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge tone="neutral">{beforeLabel}</Badge>
<span aria-hidden="true" className="text-muted-foreground">
</span>
<Badge tone="info">{afterLabel}</Badge>
</div>
);
}
Loading
Loading