Skip to content

Commit 259d294

Browse files
Skills: full detail page with diff-confirmed saves (#175)
* Add tests for the skill detail page and diff flow Covers the line-diff utility (edits, insertions, removals, line numbering, empty sides), the skill detail page's diff-confirmed save (Save… opens the review with the diff and writes nothing, Keep editing keeps the edit, Confirm & save publishes), the version list with compare and restore, and the new read of a skill at one commit. The roster and route suites move to the roster-only contract: /skills lists, and a single skill lives at its own route. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti * Skills: full detail page with diff-confirmed saves /skills/<name> is now a real page instead of a placeholder: the skill's content editor, its version list, and a diff view — no new storage, since a skill's git history already is its version store. A save is never silent. "Save…" in the top bar opens a review step showing the diff between the published version and the editor buffer, with "Confirm & save" and "Keep editing"; the commit happens only on confirm. The same renderer draws the comparison between any earlier version and the current one, read through a new GET /:name/versions/:commitSha that reads a skill at one commit without writing anything. The line diff itself is @corbits/text-diff, a dependency-free longest-common-subsequence script so an edit in the middle of a document reads as that one edit. The roster keeps only what a roster does: the inline skill panel and the create dialog's edit mode are gone, so there is one skill editor rather than two. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti * Add tests for bounded diffs and the save head precondition Pins the diff's refusal to allocate for a wholly rewritten large document and its linear cost when the identical head and tail are trimmed, the collapse of long unchanged runs, and newline normalization. On the page: a save that lost the race is refused, re-diffed against the version that won, and never buries it; side-action failures stay local and never discard a dirty draft; a missing skill says so; and CRLF in the buffer neither reads as a change nor changes the bytes written. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti * Skills: bound the diff and refuse a save that lost the race Two defects in the review flow, both fixed at the root. The diff allocated a dense longest-common-subsequence table over the whole document, so a 12k-line skill cost gigabytes. It now normalizes newlines, trims the identical head and tail before building anything, refuses the quadratic walk past a hard cap (1500 changed lines per side, 400k characters) with an honest "too large to show line by line" summary, and collapses long unchanged runs to a single row. The table itself is Int32Array-backed, so the capped worst case is about 9MB. The script is computed once and both the rows and the change summary are read off that one result. The confirmed diff was computed against the version loaded when the page opened, so a save could silently bury whoever published in between. A save now carries the version it was reviewed against; PUT /:name refuses a stale one with a 409, and the page re-reads, keeps the edit, and re-opens the review against what is actually published, saying why. Also: the buffer is newline-normalized so the bytes reviewed are the bytes written, and the description is saved exactly as reviewed rather than trimmed on the way out; side actions (visibility, restore, compare) report failures next to themselves instead of replacing the page, and never reset an unsaved edit; failures read through describeApiError rather than rendering a server message, with a distinct "no skill named …" state for a 404; the new version read validates its id at the route and 404s a commit that isn't in the skill's history rather than dating it to 1970; and the versions list stacks at 1100px, as DESIGN.md specifies. Claude-Session: https://claude.ai/code/session_01Shhie5zM8L54bLHq5gFQti
1 parent d0b3ab3 commit 259d294

22 files changed

Lines changed: 2107 additions & 530 deletions

‎apps/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@corbits/shell-layout": "workspace:*",
3838
"@corbits/slug": "workspace:*",
3939
"@corbits/tasks-ui": "workspace:*",
40+
"@corbits/text-diff": "workspace:*",
4041
"@corbits/url-path": "workspace:*",
4142
"@corbits/workflow-catalog": "workspace:*",
4243
"@corbits/icons": "workspace:*",

‎apps/web/src/pages/create-skill-dialog.tsx‎

Lines changed: 21 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,10 @@
1212
// frontmatter must carry. Rejecting it here beats a server error after
1313
// the person has typed a whole skill body.
1414
//
15-
// CL-6355: the same form doubles as the edit surface — `mode="edit"` seeds
16-
// it from `initialValues` and locks the name field (a skill's name is its
17-
// identity; renaming means creating a new one). No second editor
18-
// component: `SkillDetailView`'s "Edit" affordance opens this dialog with
19-
// `mode="edit"` rather than duplicating the form.
15+
// Creation only. Editing an existing skill happens on its own page
16+
// (`skill-detail-page.tsx`, CL-6416), where a save is reviewed as a diff
17+
// before it publishes a new version — this dialog has no edit mode to
18+
// duplicate that flow.
2019

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

66-
const DESCRIPTION_AND_BODY_FIELDS: readonly IntakeField[] = [
65+
const FIELDS: readonly IntakeField[] = [
66+
NAME_FIELD,
6767
{
6868
name: "description",
6969
label: "Description",
@@ -82,33 +82,12 @@ const DESCRIPTION_AND_BODY_FIELDS: readonly IntakeField[] = [
8282
},
8383
];
8484

85-
const CREATE_FIELDS: readonly IntakeField[] = [
86-
NAME_FIELD,
87-
...DESCRIPTION_AND_BODY_FIELDS,
88-
];
89-
90-
/** Edit mode drops the name field entirely rather than disabling it — a
91-
* skill's name is its identity, not an editable property; renaming means
92-
* creating a differently-named skill. The dialog shows it as static text
93-
* instead (see `DialogDescription` below). */
94-
const EDIT_FIELDS: readonly IntakeField[] = DESCRIPTION_AND_BODY_FIELDS;
95-
9685
/** Every reason a submission is not yet valid, in plain language — never
9786
* a generic "invalid form". Exported so the create flow can be proven
9887
* without SSR-rendering the portal-based dialog (Radix portals yield no
99-
* static markup). `mode="edit"` skips name validation — the field isn't
100-
* shown, and the value carried through unchanged is already a valid name. */
101-
export function validationIssues(
102-
values: FormValues,
103-
mode: "create" | "edit" = "create",
104-
): readonly string[] {
88+
* static markup). */
89+
export function validationIssues(values: FormValues): readonly string[] {
10590
const issues: string[] = [];
106-
if (mode === "edit") {
107-
if (values.description.trim() === "")
108-
issues.push("Description is required.");
109-
if (values.body.trim() === "") issues.push("Skill body is required.");
110-
return issues;
111-
}
11291
const name = values.name.trim();
11392
if (name === "") {
11493
issues.push("Name is required.");
@@ -128,34 +107,26 @@ export function CreateSkillDialog({
128107
open,
129108
onOpenChange,
130109
onSubmit,
131-
mode = "create",
132-
initialValues,
133110
}: {
134111
readonly open: boolean;
135112
readonly onOpenChange: (open: boolean) => void;
136-
/** Writes the skill to the registry — `createSkill` in create mode,
137-
* `updateSkill` (a new version) in edit mode. A rejection's message is
138-
* shown inline and the form is left as typed. */
113+
/** Writes the skill to the registry. A rejection's message is shown
114+
* inline and the form is left as typed. */
139115
readonly onSubmit: (input: SkillCreateInput) => Promise<void>;
140-
readonly mode?: "create" | "edit";
141-
/** Required in edit mode: seeds the form with the skill being edited. */
142-
readonly initialValues?: SkillCreateInput;
143116
}) {
144-
const startingValues = initialValues ?? EMPTY_VALUES;
145-
const [values, setValues] = useState<FormValues>(startingValues);
117+
const [values, setValues] = useState<FormValues>(EMPTY_VALUES);
146118
const [showIssues, setShowIssues] = useState(false);
147119
const [serverError, setServerError] = useState<string | null>(null);
148120
const [submitting, setSubmitting] = useState(false);
149121

150122
function reset() {
151-
setValues(startingValues);
123+
setValues(EMPTY_VALUES);
152124
setShowIssues(false);
153125
setServerError(null);
154126
}
155127

156128
function handleOpenChange(next: boolean) {
157-
if (next) setValues(startingValues);
158-
else reset();
129+
reset();
159130
onOpenChange(next);
160131
}
161132

@@ -167,8 +138,7 @@ export function CreateSkillDialog({
167138
});
168139
}
169140

170-
const fields = mode === "edit" ? EDIT_FIELDS : CREATE_FIELDS;
171-
const issues = validationIssues(values, mode);
141+
const issues = validationIssues(values);
172142

173143
async function handleSubmit() {
174144
if (issues.length > 0) {
@@ -195,13 +165,10 @@ export function CreateSkillDialog({
195165
<Dialog open={open} onOpenChange={handleOpenChange}>
196166
<DialogContent>
197167
<DialogHeader>
198-
<DialogTitle>
199-
{mode === "edit" ? `Edit ${values.name}` : "Create skill"}
200-
</DialogTitle>
168+
<DialogTitle>Create skill</DialogTitle>
201169
<DialogDescription>
202-
{mode === "edit"
203-
? "Saving publishes a new version — the version it replaces stays in history and can be restored."
204-
: "Define a reusable capability an agent can declare and this workbench can share."}
170+
Define a reusable capability an agent can declare and this workbench
171+
can share.
205172
</DialogDescription>
206173
</DialogHeader>
207174
<DialogBody>
@@ -221,10 +188,10 @@ export function CreateSkillDialog({
221188
</p>
222189
)}
223190
<IntakeForm
224-
fields={fields}
191+
fields={FIELDS}
225192
values={values}
226193
onChange={handleFormChange}
227-
idPrefix={mode === "edit" ? "edit-skill" : "create-skill"}
194+
idPrefix="create-skill"
228195
/>
229196
</DialogBody>
230197
<DialogFooter>
@@ -238,9 +205,9 @@ export function CreateSkillDialog({
238205
<Button
239206
type="button"
240207
onClick={() => void handleSubmit()}
241-
disabled={submitting || !intakeFieldsComplete(fields, values)}
208+
disabled={submitting || !intakeFieldsComplete(FIELDS, values)}
242209
>
243-
{mode === "edit" ? "Save" : "Create skill"}
210+
Create skill
244211
</Button>
245212
</DialogFooter>
246213
</DialogContent>

‎apps/web/src/pages/detail-placeholders.tsx‎

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@
44
// testable before the page behind it exists.
55

66
import { Button, EmptyState, PageShell } from "@corbits/react-ui";
7-
import { Lightning, SquaresFour } from "@corbits/icons";
7+
import { SquaresFour } from "@corbits/icons";
88
import type { Slug } from "@corbits/slug";
99
import type { ReactNode } from "react";
1010

1111
import { Link } from "../navigation";
12-
import {
13-
PLUGINS_PATH_PREFIX,
14-
SKILLS_PATH_PREFIX,
15-
} from "../path-ids";
12+
import { PLUGINS_PATH_PREFIX } from "../path-ids";
1613
import { StageTopBar } from "../shell/stage-top-bar";
1714

1815
function DetailPlaceholder({
@@ -49,18 +46,6 @@ function DetailPlaceholder({
4946
);
5047
}
5148

52-
export function SkillDetailPlaceholder({ slug }: { readonly slug: Slug }) {
53-
return (
54-
<DetailPlaceholder
55-
slug={slug}
56-
entity="Skill"
57-
rosterLabel="Skills"
58-
rosterPath={SKILLS_PATH_PREFIX}
59-
icon={<Lightning />}
60-
/>
61-
);
62-
}
63-
6449
export function PluginDetailPlaceholder({ slug }: { readonly slug: Slug }) {
6550
return (
6651
<DetailPlaceholder

‎apps/web/src/pages/diff-view.tsx‎

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// One diff renderer for every surface that shows "what changed": the
2+
// save-confirmation step and the version comparison on a detail page both
3+
// mount this, so a diff always reads the same way. The line script comes
4+
// from `@corbits/text-diff`; this file is only its presentation, and it
5+
// computes the script exactly once per render — the change summary is read
6+
// off the same result the rows come from.
7+
8+
import { Badge } from "@corbits/react-ui";
9+
import { diffText } from "@corbits/text-diff";
10+
import type { DiffLine } from "@corbits/text-diff";
11+
import { useMemo } from "react";
12+
13+
const MARKER: Record<DiffLine["kind"], string> = {
14+
context: " ",
15+
added: "+",
16+
removed: "-",
17+
skipped: "⋯",
18+
};
19+
20+
const ROW_CLASS: Record<DiffLine["kind"], string> = {
21+
context: "text-muted-foreground",
22+
added: "bg-success/10 text-foreground",
23+
removed: "bg-destructive/10 text-foreground",
24+
skipped: "text-muted-foreground italic",
25+
};
26+
27+
function lineNumber(value: number | null): string {
28+
return value === null ? "" : String(value);
29+
}
30+
31+
export function DiffView({
32+
before,
33+
after,
34+
unchangedNotice = "No changes yet.",
35+
}: {
36+
readonly before: string;
37+
readonly after: string;
38+
readonly unchangedNotice?: string;
39+
}) {
40+
const diff = useMemo(() => diffText(before, after), [before, after]);
41+
42+
if (diff.status === "identical") {
43+
return (
44+
<p className="text-sm text-muted-foreground" data-testid="diff-unchanged">
45+
{unchangedNotice}
46+
</p>
47+
);
48+
}
49+
50+
if (diff.status === "too-large") {
51+
return (
52+
<div className="flex flex-col gap-1" data-testid="diff-too-large">
53+
<p className="text-sm text-foreground">
54+
This change is too large to show line by line — showing a summary
55+
only.
56+
</p>
57+
<p className="font-mono text-xs tabular-nums text-muted-foreground">
58+
{`${String(diff.beforeLines)} lines before, ${String(
59+
diff.afterLines,
60+
)} after — ${String(diff.changedBeforeLines)} rewritten to ${String(
61+
diff.changedAfterLines,
62+
)}`}
63+
</p>
64+
</div>
65+
);
66+
}
67+
68+
return (
69+
<div className="flex flex-col gap-2" data-testid="diff-view">
70+
<p className="font-mono text-xs tabular-nums text-muted-foreground">
71+
{`+${String(diff.totals.added)} added, −${String(
72+
diff.totals.removed,
73+
)} removed`}
74+
</p>
75+
<div className="max-h-96 overflow-auto rounded-md border border-border bg-muted/30">
76+
<table className="w-full border-collapse font-mono text-xs leading-relaxed">
77+
<tbody>
78+
{diff.lines.map((line, index) => (
79+
<tr
80+
key={`${String(index)}:${line.kind}`}
81+
className={ROW_CLASS[line.kind]}
82+
>
83+
<td className="w-10 select-none px-2 text-right tabular-nums text-muted-foreground">
84+
{lineNumber(line.beforeLineNumber)}
85+
</td>
86+
<td className="w-10 select-none px-2 text-right tabular-nums text-muted-foreground">
87+
{lineNumber(line.afterLineNumber)}
88+
</td>
89+
<td className="w-6 select-none px-1 text-center">
90+
{MARKER[line.kind]}
91+
</td>
92+
<td className="whitespace-pre-wrap break-words px-2 py-0.5">
93+
{line.text === "" ? " " : line.text}
94+
</td>
95+
</tr>
96+
))}
97+
</tbody>
98+
</table>
99+
</div>
100+
</div>
101+
);
102+
}
103+
104+
export function DiffHeading({
105+
beforeLabel,
106+
afterLabel,
107+
}: {
108+
readonly beforeLabel: string;
109+
readonly afterLabel: string;
110+
}) {
111+
return (
112+
<div className="flex flex-wrap items-center gap-2 text-xs">
113+
<Badge tone="neutral">{beforeLabel}</Badge>
114+
<span aria-hidden="true" className="text-muted-foreground">
115+
→
116+
</span>
117+
<Badge tone="info">{afterLabel}</Badge>
118+
</div>
119+
);
120+
}

0 commit comments

Comments
 (0)