- );
-}
-
/**
- * The Skills stage: master-detail over one workbench's skill registry, with
- * its own top-nav contract (CL-6409) — the trail says where the reader is
- * (`Skills / `, the parent crumb deep-linking back to `/skills`) and
- * the top bar's action slot is the only home for "New skill". `tenantId` is
- * the registry every read and write is scoped to; `navigate`/`entityId`
- * drive the `/skills/:name` deep link when passed (see `SkillsRoute`
- * below), or stay local to the component otherwise — same
- * optional-controlled-selection contract `AgentsSection` uses.
+ * The Skills roster over one workbench's skill registry, with its own
+ * top-nav contract (CL-6409): the trail says where the reader is and the
+ * top bar's action slot is the only home for "New skill". `tenantId` is the
+ * registry every read is scoped to; opening a row navigates to that skill's
+ * own page at `/skills/` (CL-6416), which is where editing, versions,
+ * and diffs live — this page never renders a skill inline.
*/
export function SkillsPage({
tenantId,
navigate,
- entityId,
- now = Date.now(),
}: {
readonly tenantId: string | null;
readonly navigate?: (to: string) => void;
- readonly entityId?: string | null;
- readonly now?: number;
}) {
const [state, setState] = useState({ status: "loading" });
const [query, setQuery] = useState("");
- const [selected, setSelected] = useState(entityId ?? null);
const [createOpen, setCreateOpen] = useState(false);
const reload = useCallback(async () => {
@@ -339,14 +89,6 @@ export function SkillsPage({
void reload();
}, [reload]);
- // The route is the source of truth for which skill is open: the same
- // component instance stays mounted across `/skills/` → `/skills`
- // (both match the one route entry), so a crumb click that only changes
- // the URL has to move the view with it.
- useEffect(() => {
- setSelected(entityId ?? null);
- }, [entityId]);
-
useEffect(() => {
if (consumePendingNewSkill()) setCreateOpen(true);
}, []);
@@ -358,13 +100,8 @@ export function SkillsPage({
window.removeEventListener("workbench:skills:create", onCreate);
}, []);
- function select(name: string | null) {
- setSelected(name);
- navigate?.(
- name === null
- ? SKILLS_PATH_PREFIX
- : `${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`,
- );
+ function open(name: string) {
+ navigate?.(`${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`);
}
async function handleCreate(input: SkillCreateInput) {
@@ -372,7 +109,7 @@ export function SkillsPage({
const skill = await createSkill(tenantId, input);
setCreateOpen(false);
await reload();
- select(skill.name);
+ open(skill.name);
}
const createDialog = (
@@ -383,10 +120,7 @@ export function SkillsPage({
/>
);
- const crumbs =
- selected === null
- ? [{ label: "Skills" }]
- : [{ label: "Skills", href: SKILLS_PATH_PREFIX }, { label: selected }];
+ const crumbs = [{ label: "Skills" }];
function stage(actions: ReactNode, body: ReactNode) {
return (
@@ -432,21 +166,6 @@ export function SkillsPage({
);
}
- if (selected !== null) {
- return stage(
- newSkillButton,
-
- void reload()}
- />
- {createDialog}
-
,
- );
- }
-
const { skills } = state;
if (skills.length === 0) {
@@ -504,7 +223,7 @@ export function SkillsPage({
select(skill.name))}
+ {...rowActivationProps(() => open(skill.name))}
>
{skill.name}
@@ -527,26 +246,17 @@ export function SkillsPage({
}
/**
- * Skills stage mount at `/skills` (CL-6355): a thin adapter that resolves
- * the bench tenant and the `/skills/:name` deep link. The stage chrome
- * itself (breadcrumb trail, action slot) belongs to `SkillsPage`, which is
- * the component that knows which skill is open.
+ * Skills roster mount at `/skills` (CL-6355): a thin adapter that resolves
+ * which workbench's registry is listed. The stage chrome (breadcrumb trail,
+ * action slot) belongs to `SkillsPage`; a single skill has its own route
+ * (`/skills/`, `skill-detail-page.tsx`).
*/
export function SkillsRoute({
- path,
navigate,
}: {
- readonly path: string;
readonly navigate: (to: string) => void;
}) {
const { selectedTenantId } = useBench();
- const entityId = skillIdFromPath(path);
- return (
-
- );
+ return ;
}
diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx
index 907d3c563..12e444282 100644
--- a/apps/web/src/routes.tsx
+++ b/apps/web/src/routes.tsx
@@ -84,8 +84,8 @@ const SettingsRoute = lazy(async () => ({
const AgentDetailRoute = lazy(async () => ({
default: (await import("./pages/agent-detail-page")).AgentDetailRoute,
}));
-const SkillDetailPlaceholder = lazy(async () => ({
- default: (await import("./pages/detail-placeholders")).SkillDetailPlaceholder,
+const SkillDetailRoute = lazy(async () => ({
+ default: (await import("./pages/skill-detail-page")).SkillDetailRoute,
}));
const PluginDetailPlaceholder = lazy(async () => ({
default: (await import("./pages/detail-placeholders"))
@@ -342,16 +342,14 @@ export const APP_ROUTES: readonly AppRoute[] = [
path: SKILL_DETAIL_PATH,
label: "Skill",
icon: ,
- render: (path: string) => (
-
- ),
+ render: (path: string) => ,
},
{
path: "/skills",
label: "Skills",
icon: ,
- render: (path: string, navigate: (to: string) => void) => (
-
+ render: (_path: string, navigate: (to: string) => void) => (
+
),
},
{
diff --git a/apps/web/src/skills-api.ts b/apps/web/src/skills-api.ts
index bf763db76..8ffc4c122 100644
--- a/apps/web/src/skills-api.ts
+++ b/apps/web/src/skills-api.ts
@@ -46,6 +46,7 @@ const SkillDetailResponse = type({
pinnedBy: PinnedByEntry.array(),
});
const SkillVersionsResponse = type({ versions: SkillVersion.array() });
+const SkillAtVersionResponse = type({ skill: SkillDetail });
const SkillResponse = type({ skill: SkillSummary });
const ErrorEnvelope = type({ error: { message: "string" } });
@@ -139,6 +140,22 @@ export function listSkillVersions(
).then((page) => page.versions);
}
+/**
+ * The skill as it stood at one commit — the "before" side of a diff
+ * against the current version. A read, never a write: nothing is restored
+ * by looking.
+ */
+export function loadSkillAtVersion(
+ tenantId: string,
+ name: string,
+ commitSha: string,
+): Promise {
+ return request(
+ `${base(tenantId)}/${encodeURIComponent(name)}/versions/${encodeURIComponent(commitSha)}`,
+ SkillAtVersionResponse,
+ ).then((page) => page.skill);
+}
+
const DEFAULT_CREATE_SCOPE: SkillScope = "private";
/**
diff --git a/bun.lock b/bun.lock
index d7e89d06d..79353ea47 100644
--- a/bun.lock
+++ b/bun.lock
@@ -150,6 +150,7 @@
"@corbits/shell-layout": "workspace:*",
"@corbits/slug": "workspace:*",
"@corbits/tasks-ui": "workspace:*",
+ "@corbits/text-diff": "workspace:*",
"@corbits/url-path": "workspace:*",
"@corbits/workflow-catalog": "workspace:*",
"@intx/types": "0.3.0",
@@ -1293,6 +1294,14 @@
"typescript": "catalog:",
},
},
+ "packages/text-diff": {
+ "name": "@corbits/text-diff",
+ "version": "0.0.1",
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "typescript": "catalog:",
+ },
+ },
"packages/tool-registry-publish": {
"name": "@corbits/tool-registry-publish",
"version": "0.0.1",
@@ -2023,6 +2032,8 @@
"@corbits/tasks-ui": ["@corbits/tasks-ui@workspace:packages/tasks-ui"],
+ "@corbits/text-diff": ["@corbits/text-diff@workspace:packages/text-diff"],
+
"@corbits/tool-registry-publish": ["@corbits/tool-registry-publish@workspace:packages/tool-registry-publish"],
"@corbits/tools-skills": ["@corbits/tools-skills@workspace:packages/tools-skills"],
@@ -3303,7 +3314,7 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="],
+ "@corbits/memory-hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
@@ -3327,7 +3338,9 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="],
- "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#81049ed", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-81049ed", "sha512-oTE0iFDyQdz0ifG1epo39pwaCaYaw19YcKXwfaZqAEQ56a1g9YIozXwH9CG4NaUTwcJKUeYGuNls6oJsMPisCw=="],
+ "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="],
+
+ "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="],
"ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
diff --git a/packages/icons/src/index.tsx b/packages/icons/src/index.tsx
index d0290fef4..15791e475 100644
--- a/packages/icons/src/index.tsx
+++ b/packages/icons/src/index.tsx
@@ -46,6 +46,7 @@ export {
FlowArrow,
FolderOpen,
GitBranch,
+ GitDiff,
GitPullRequest,
Hash,
Key,
diff --git a/packages/skills/src/registry.ts b/packages/skills/src/registry.ts
index 4daa13534..b1547ab4e 100644
--- a/packages/skills/src/registry.ts
+++ b/packages/skills/src/registry.ts
@@ -60,6 +60,14 @@ export type SkillRegistry = {
search(caller: SkillCaller, query: string): Promise;
load(caller: SkillCaller, name: string): Promise;
versions(caller: SkillCaller, name: string): Promise;
+ /** The skill exactly as it stood at one commit — what a diff against the
+ * current version is computed from. Read-only: nothing is written and no
+ * version is cut. */
+ versionContent(
+ caller: SkillCaller,
+ name: string,
+ commitSha: string,
+ ): Promise;
restore(
caller: SkillCaller,
name: string,
@@ -293,6 +301,34 @@ export function createSkillRegistry(
}));
},
+ async versionContent(caller, name, commitSha) {
+ const { row } = await resolveVisible(caller, name);
+ const contents = await assets.readSkillMd({
+ assetId: row.assetId,
+ skillName: row.skillName,
+ commitSha,
+ });
+ if (contents === null) {
+ throw new SkillRegistryError(
+ "not_found",
+ `skill "${name}" has no SKILL.md at commit ${commitSha}`,
+ );
+ }
+ const parsed = parseSkillMd(contents);
+ const commit = (await assets.history(row.assetId)).find(
+ (entry) => entry.commitSha === commitSha,
+ );
+ return {
+ assetId: row.assetId,
+ name: parsed.name,
+ description: parsed.description,
+ body: parsed.body,
+ scope: row.scope,
+ creatorPrincipalId: row.creatorPrincipalId,
+ updatedAtIso: commit?.committedAtIso ?? new Date(0).toISOString(),
+ };
+ },
+
async restore(caller, name, commitSha) {
const { row } = await resolveVisible(caller, name);
requireOwnTenant(row, caller, name, "restore");
diff --git a/packages/skills/src/routes.ts b/packages/skills/src/routes.ts
index 84a93f16f..e59de886b 100644
--- a/packages/skills/src/routes.ts
+++ b/packages/skills/src/routes.ts
@@ -142,6 +142,20 @@ export function createSkillRoutes({
});
});
+ app.get(
+ "/:name/versions/:commitSha",
+ requireGrant("asset:*", "read"),
+ async (c) => {
+ return c.json({
+ skill: await registry.versionContent(
+ caller(c),
+ c.req.param("name"),
+ c.req.param("commitSha"),
+ ),
+ });
+ },
+ );
+
app.post("/:name/restore", requireGrant("asset:*", "create"), async (c) => {
const body = RestoreBody(await c.req.json().catch(() => undefined));
if (body instanceof type.errors) {
diff --git a/packages/text-diff/package.json b/packages/text-diff/package.json
new file mode 100644
index 000000000..5c0423abc
--- /dev/null
+++ b/packages/text-diff/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@corbits/text-diff",
+ "private": true,
+ "description": "Line-level text diff: the shortest add/remove script between two revisions of a document, for review surfaces that show what a save is about to change",
+ "version": "0.0.1",
+ "license": "LGPL-2.1-or-later",
+ "type": "module",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "bun test"
+ },
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "typescript": "catalog:"
+ }
+}
diff --git a/packages/text-diff/src/index.ts b/packages/text-diff/src/index.ts
new file mode 100644
index 000000000..399312f51
--- /dev/null
+++ b/packages/text-diff/src/index.ts
@@ -0,0 +1,8 @@
+export {
+ diffLines,
+ diffTotals,
+ hasChanges,
+ type DiffLine,
+ type DiffLineKind,
+ type DiffTotals,
+} from "./line-diff";
diff --git a/packages/text-diff/src/line-diff.ts b/packages/text-diff/src/line-diff.ts
new file mode 100644
index 000000000..44778caef
--- /dev/null
+++ b/packages/text-diff/src/line-diff.ts
@@ -0,0 +1,133 @@
+// A line-level diff over two revisions of the same document. The shape it
+// returns is the shape a review surface renders: one row per line, in
+// reading order, each row knowing whether it was kept, added, or removed
+// and which line number it carries on each side.
+//
+// The script is the shortest one — a longest-common-subsequence walk — so
+// an edit in the middle of a document reads as that one edit rather than
+// as "everything from here down changed".
+
+export type DiffLineKind = "context" | "added" | "removed";
+
+export type DiffLine = {
+ readonly kind: DiffLineKind;
+ readonly text: string;
+ /** Line number in the before revision, or null for an added line. */
+ readonly beforeLineNumber: number | null;
+ /** Line number in the after revision, or null for a removed line. */
+ readonly afterLineNumber: number | null;
+};
+
+export type DiffTotals = {
+ readonly added: number;
+ readonly removed: number;
+};
+
+function splitLines(text: string): readonly string[] {
+ if (text === "") return [];
+ return text.replace(/\r\n/g, "\n").split("\n");
+}
+
+/**
+ * Lengths of the longest common subsequence for every suffix pair, so the
+ * walk below can always take the branch that keeps more lines in common.
+ */
+function commonSuffixLengths(
+ before: readonly string[],
+ after: readonly string[],
+): readonly (readonly number[])[] {
+ const table: number[][] = Array.from({ length: before.length + 1 }, () =>
+ new Array(after.length + 1).fill(0),
+ );
+ for (let i = before.length - 1; i >= 0; i -= 1) {
+ for (let j = after.length - 1; j >= 0; j -= 1) {
+ const row = table[i];
+ const next = table[i + 1];
+ if (row === undefined || next === undefined) continue;
+ row[j] =
+ before[i] === after[j]
+ ? (next[j + 1] ?? 0) + 1
+ : Math.max(next[j] ?? 0, row[j + 1] ?? 0);
+ }
+ }
+ return table;
+}
+
+/**
+ * The add/remove/keep script that turns `before` into `after`, one line per
+ * entry. A trailing newline is a line of its own on the side that has it,
+ * which is what makes "added a blank line at the end" visible.
+ */
+export function diffLines(before: string, after: string): readonly DiffLine[] {
+ const beforeLines = splitLines(before);
+ const afterLines = splitLines(after);
+ const table = commonSuffixLengths(beforeLines, afterLines);
+
+ const out: DiffLine[] = [];
+ let i = 0;
+ let j = 0;
+ while (i < beforeLines.length && j < afterLines.length) {
+ const beforeLine = beforeLines[i] ?? "";
+ const afterLine = afterLines[j] ?? "";
+ if (beforeLine === afterLine) {
+ out.push({
+ kind: "context",
+ text: beforeLine,
+ beforeLineNumber: i + 1,
+ afterLineNumber: j + 1,
+ });
+ i += 1;
+ j += 1;
+ continue;
+ }
+ const dropBefore = table[i + 1]?.[j] ?? 0;
+ const dropAfter = table[i]?.[j + 1] ?? 0;
+ if (dropBefore >= dropAfter) {
+ out.push({
+ kind: "removed",
+ text: beforeLine,
+ beforeLineNumber: i + 1,
+ afterLineNumber: null,
+ });
+ i += 1;
+ } else {
+ out.push({
+ kind: "added",
+ text: afterLine,
+ beforeLineNumber: null,
+ afterLineNumber: j + 1,
+ });
+ j += 1;
+ }
+ }
+ while (i < beforeLines.length) {
+ out.push({
+ kind: "removed",
+ text: beforeLines[i] ?? "",
+ beforeLineNumber: i + 1,
+ afterLineNumber: null,
+ });
+ i += 1;
+ }
+ while (j < afterLines.length) {
+ out.push({
+ kind: "added",
+ text: afterLines[j] ?? "",
+ beforeLineNumber: null,
+ afterLineNumber: j + 1,
+ });
+ j += 1;
+ }
+ return out;
+}
+
+export function diffTotals(lines: readonly DiffLine[]): DiffTotals {
+ return {
+ added: lines.filter((line) => line.kind === "added").length,
+ removed: lines.filter((line) => line.kind === "removed").length,
+ };
+}
+
+export function hasChanges(lines: readonly DiffLine[]): boolean {
+ return lines.some((line) => line.kind !== "context");
+}
diff --git a/packages/text-diff/tsconfig.json b/packages/text-diff/tsconfig.json
new file mode 100644
index 000000000..12ec9a862
--- /dev/null
+++ b/packages/text-diff/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["bun"]
+ },
+ "include": ["src"]
+}
From 9b47abbd5cd16b238bbe68f861f7e50a586748e8 Mon Sep 17 00:00:00 2001
From: Sawyer
Date: Thu, 20 Aug 2026 16:26:03 -0700
Subject: [PATCH 3/4] 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
---
apps/web/test/skill-detail-page.test.tsx | 318 ++++++++++++++++++-----
packages/skills/test/routes.test.ts | 84 ++++++
packages/text-diff/src/line-diff.test.ts | 122 +++++++--
3 files changed, 442 insertions(+), 82 deletions(-)
diff --git a/apps/web/test/skill-detail-page.test.tsx b/apps/web/test/skill-detail-page.test.tsx
index aa3225e74..b9deb801c 100644
--- a/apps/web/test/skill-detail-page.test.tsx
+++ b/apps/web/test/skill-detail-page.test.tsx
@@ -1,7 +1,8 @@
// The skill detail page at /skills/ (CL-6416): the editor, the
-// diff-confirmed save, the version list, compare, and restore. Every case
-// stubs `fetch` at the registry seam the page reads
-// (`/api/tenants/:id/skills/...`) — no live server.
+// diff-confirmed save and its head precondition, newline handling, the
+// version list, compare, and restore. Every case stubs `fetch` at the
+// registry seam the page reads (`/api/tenants/:id/skills/...`) — no live
+// server.
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act } from "react";
@@ -13,6 +14,8 @@ import { TestQueryProvider } from "./test-query-provider";
const TENANT = "tnt_1";
const NAME = "triage";
const HEAD_BODY = "Read the report.\nPick one label.";
+const HEAD_SHA = "abcdef1234";
+const OLDER_SHA = "0123456789";
const SKILL = {
assetId: "ast_1",
@@ -26,14 +29,14 @@ const SKILL = {
const VERSIONS = [
{
- commitSha: "abcdef1234",
+ commitSha: HEAD_SHA,
message: "Update triage",
author: "Ada",
committedAtIso: "2026-08-05T11:00:00.000Z",
current: true,
},
{
- commitSha: "0123456789",
+ commitSha: OLDER_SHA,
message: "Create triage",
author: "Grace",
committedAtIso: "2026-08-04T11:00:00.000Z",
@@ -43,19 +46,31 @@ const VERSIONS = [
const BASE = `/api/tenants/${TENANT}/skills/${NAME}`;
-const ROUTES: Record = {
- [`GET ${BASE}`]: {
- skill: SKILL,
- pinnedBy: [{ definitionId: "def_1", name: "Research Buddy" }],
- },
- [`GET ${BASE}/versions`]: { versions: VERSIONS },
- [`GET ${BASE}/versions/0123456789`]: {
- skill: { ...SKILL, body: "Read the report." },
- },
- [`PUT ${BASE}`]: { skill: SKILL },
- [`POST ${BASE}/restore`]: { skill: SKILL },
-};
+type Reply = { readonly status: number; readonly body: unknown };
+
+/** One reply per key, or a queue consumed in order so a test can say
+ * "this call fails, the next one succeeds". */
+type Stub = Reply | readonly Reply[];
+
+const ok = (body: unknown): Reply => ({ status: 200, body });
+
+function defaultStubs(): Record {
+ return {
+ [`GET ${BASE}`]: ok({
+ skill: SKILL,
+ pinnedBy: [{ definitionId: "def_1", name: "Research Buddy" }],
+ }),
+ [`GET ${BASE}/versions`]: ok({ versions: VERSIONS }),
+ [`GET ${BASE}/versions/${OLDER_SHA}`]: ok({
+ skill: { ...SKILL, body: "Read the report." },
+ }),
+ [`PUT ${BASE}`]: ok({ skill: SKILL }),
+ [`POST ${BASE}/restore`]: ok({ skill: SKILL }),
+ [`PUT ${BASE}/scope`]: ok({ skill: SKILL }),
+ };
+}
+let stubs: Record = {};
let container: HTMLDivElement | null = null;
let root: Root | null = null;
let requested: { method: string; path: string; body: unknown }[] = [];
@@ -71,19 +86,25 @@ function stubRegistry(): void {
body:
init?.body === undefined ? undefined : JSON.parse(String(init.body)),
});
- const key = `${method} ${path}`;
- if (!(key in ROUTES)) {
- return new Response(
- JSON.stringify({ error: { message: `no stub for ${key}` } }),
- { status: 404 },
- );
+ const stub = stubs[`${method} ${path}`];
+ if (stub === undefined) {
+ return new Response(JSON.stringify({ error: { message: "no stub" } }), {
+ status: 404,
+ });
}
- return new Response(JSON.stringify(ROUTES[key]), { status: 200 });
+ const reply = Array.isArray(stub)
+ ? stub.length > 1
+ ? ((stubs[`${method} ${path}`] = stub.slice(1)), stub[0])
+ : stub[0]
+ : (stub as Reply);
+ if (reply === undefined) throw new Error("empty stub queue");
+ return new Response(JSON.stringify(reply.body), { status: reply.status });
}) as unknown as typeof fetch;
}
beforeEach(() => {
requested = [];
+ stubs = defaultStubs();
stubRegistry();
});
@@ -140,6 +161,12 @@ function buttonNamed(scope: ParentNode, label: string): HTMLButtonElement {
return found;
}
+function enabledIn(scope: ParentNode, label: string): HTMLButtonElement[] {
+ return buttonsIn(scope).filter(
+ (button) => button.textContent?.includes(label) && !button.disabled,
+ );
+}
+
async function click(button: HTMLButtonElement) {
await act(async () => {
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
@@ -147,9 +174,14 @@ async function click(button: HTMLButtonElement) {
await settle();
}
-function typeInto(id: string, value: string) {
+function field(id: string): HTMLTextAreaElement {
const el = document.getElementById(id) as HTMLTextAreaElement | null;
if (el === null) throw new Error(`no field #${id}`);
+ return el;
+}
+
+function typeInto(id: string, value: string) {
+ const el = field(id);
const setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value",
@@ -165,31 +197,34 @@ function saves(): { method: string; path: string; body: unknown }[] {
);
}
+function versionTable(el: ParentNode): HTMLTableElement {
+ const table = el.querySelector(
+ 'table[aria-label="Versions"]',
+ );
+ if (table === null) throw new Error("no version table");
+ return table;
+}
+
describe("SkillDetailPage", () => {
test("renders the editor seeded from the published version, with its pins", async () => {
const el = await mount();
- const body = document.getElementById(
- "skill-body",
- ) as HTMLTextAreaElement | null;
- expect(body?.value).toBe(HEAD_BODY);
+ expect(field("skill-body").value).toBe(HEAD_BODY);
expect(el.textContent).toContain("Research Buddy");
});
test("the version list renders each commit's note, author, and when", async () => {
const el = await mount();
- const table = el.querySelector('table[aria-label="Versions"]');
- expect(table).not.toBeNull();
- expect(table?.textContent).toContain("Create triage");
- expect(table?.textContent).toContain("Grace");
- expect(table?.textContent).toContain("Version 1");
- expect(table?.textContent).toContain("current");
+ const table = versionTable(el);
+ expect(table.textContent).toContain("Create triage");
+ expect(table.textContent).toContain("Grace");
+ expect(table.textContent).toContain("Version 1");
+ expect(table.textContent).toContain("current");
});
test("Save… is offered only once the editor differs from the published version", async () => {
const el = await mount();
const bar = el.querySelector('[data-testid="stage-top-bar-actions"]');
- expect(bar).not.toBeNull();
- if (bar === null) return;
+ if (bar === null) throw new Error("no action slot");
expect(buttonNamed(bar, "Save…").disabled).toBe(true);
await act(async () => {
@@ -207,7 +242,6 @@ describe("SkillDetailPage", () => {
expect(document.body.textContent).toContain("Review this save");
const diff = document.body.querySelector('[data-testid="diff-view"]');
- expect(diff).not.toBeNull();
expect(diff?.textContent).toContain("Pick one label.");
expect(diff?.textContent).toContain("Pick two labels.");
expect(saves()).toHaveLength(0);
@@ -223,13 +257,12 @@ describe("SkillDetailPage", () => {
expect(document.body.textContent).not.toContain("Review this save");
expect(saves()).toHaveLength(0);
- const body = document.getElementById(
- "skill-body",
- ) as HTMLTextAreaElement | null;
- expect(body?.value).toBe("Read the report.\nPick two labels.");
+ expect(field("skill-body").value).toBe(
+ "Read the report.\nPick two labels.",
+ );
});
- test("Confirm & save is what publishes the new version", async () => {
+ test("Confirm & save publishes the new version against the version it reviewed", async () => {
const el = await mount();
await act(async () => {
typeInto("skill-body", "Read the report.\nPick two labels.");
@@ -241,21 +274,115 @@ describe("SkillDetailPage", () => {
expect(saves()[0]?.body).toEqual({
description: "Sorts inbound issues.",
body: "Read the report.\nPick two labels.",
+ expectedHeadSha: HEAD_SHA,
});
});
- test("Compare reads the chosen version and diffs it against the current one", async () => {
+ test("a description edit is saved exactly as reviewed, untrimmed", async () => {
+ const el = await mount();
+ await act(async () => {
+ typeInto("skill-description", " Sorts inbound issues by severity. ");
+ });
+ await click(buttonNamed(el, "Save…"));
+ const diff = document.body.querySelector('[data-testid="diff-view"]');
+ expect(diff?.textContent).toContain("Sorts inbound issues by severity.");
+ await click(buttonNamed(document.body, "Confirm & save"));
+
+ expect(saves()[0]?.body).toEqual({
+ description: " Sorts inbound issues by severity. ",
+ body: HEAD_BODY,
+ expectedHeadSha: HEAD_SHA,
+ });
+ });
+
+ test("CRLF in the buffer neither reads as a change nor changes the bytes saved", async () => {
const el = await mount();
- const table = el.querySelector('table[aria-label="Versions"]');
- if (table === null) throw new Error("no version table");
- const compares = buttonsIn(table).filter(
- (button) => button.textContent?.includes("Compare") && !button.disabled,
+ await act(async () => {
+ typeInto("skill-body", "Read the report.\r\nPick one label.");
+ });
+ // Same text, different newline convention: nothing to save.
+ const bar = el.querySelector('[data-testid="stage-top-bar-actions"]');
+ if (bar === null) throw new Error("no action slot");
+ expect(buttonNamed(bar, "Save…").disabled).toBe(true);
+
+ await act(async () => {
+ typeInto("skill-body", "Read the report.\r\nPick two labels.\r");
+ });
+ await click(buttonNamed(el, "Save…"));
+ await click(buttonNamed(document.body, "Confirm & save"));
+ expect(saves()[0]?.body).toEqual({
+ description: "Sorts inbound issues.",
+ body: "Read the report.\nPick two labels.\n",
+ expectedHeadSha: HEAD_SHA,
+ });
+ });
+
+ test("a save that lost the race is refused, re-diffed against the version that won, and never buries it", async () => {
+ const theirBody = "Read the report.\nPick one label.\nTheir new line.";
+ const theirSha = "9999999999";
+ stubs[`PUT ${BASE}`] = [
+ { status: 409, body: { error: { message: "conflict" } } },
+ ok({ skill: SKILL }),
+ ];
+ stubs[`GET ${BASE}`] = [
+ ok({ skill: SKILL, pinnedBy: [] }),
+ ok({ skill: { ...SKILL, body: theirBody }, pinnedBy: [] }),
+ ];
+ stubs[`GET ${BASE}/versions`] = [
+ ok({ versions: VERSIONS }),
+ ok({
+ versions: [
+ {
+ commitSha: theirSha,
+ message: "Update triage",
+ author: "Grace",
+ committedAtIso: "2026-08-05T11:30:00.000Z",
+ current: true,
+ },
+ { ...VERSIONS[0], current: false },
+ ],
+ }),
+ ];
+
+ const el = await mount();
+ await act(async () => {
+ typeInto("skill-body", "Read the report.\nMy new line.");
+ });
+ await click(buttonNamed(el, "Save…"));
+ await click(buttonNamed(document.body, "Confirm & save"));
+
+ // The review is still open, saying what happened, now diffing against
+ // the version that actually won the race.
+ expect(document.body.textContent).toContain("Review this save");
+ const notice = document.body.querySelector(
+ '[data-testid="save-stale-notice"]',
);
+ expect(notice?.textContent).toContain("Someone else saved this skill");
+ const diff = document.body.querySelector('[data-testid="diff-view"]');
+ expect(diff?.textContent).toContain("Their new line.");
+ expect(diff?.textContent).toContain("My new line.");
+ // The edit is untouched and nothing was written.
+ expect(field("skill-body").value).toBe("Read the report.\nMy new line.");
+ expect(saves()).toHaveLength(1);
+
+ // Confirming again saves on top of their version, naming it.
+ await click(buttonNamed(document.body, "Confirm & save"));
+ expect(saves()).toHaveLength(2);
+ expect(saves()[1]?.body).toEqual({
+ description: "Sorts inbound issues.",
+ body: "Read the report.\nMy new line.",
+ expectedHeadSha: theirSha,
+ });
+ });
+
+ test("Compare reads the chosen version and diffs it against the current one", async () => {
+ const el = await mount();
+ const compares = enabledIn(versionTable(el), "Compare");
expect(compares).toHaveLength(1);
await click(compares[0] as HTMLButtonElement);
expect(
- requested.some((entry) => entry.path === `${BASE}/versions/0123456789`),
+ requested.some((entry) => entry.path === `${BASE}/versions/${OLDER_SHA}`),
).toBe(true);
expect(el.textContent).toContain("compared with the current version");
expect(el.textContent).toContain("Pick one label.");
@@ -263,26 +390,97 @@ describe("SkillDetailPage", () => {
test("Restore posts the chosen commit to the registry", async () => {
const el = await mount();
- const table = el.querySelector('table[aria-label="Versions"]');
- if (table === null) throw new Error("no version table");
- const restores = buttonsIn(table).filter(
- (button) => button.textContent === "Restore" && !button.disabled,
- );
+ const restores = enabledIn(versionTable(el), "Restore");
expect(restores).toHaveLength(1);
await click(restores[0] as HTMLButtonElement);
const call = requested.find((entry) => entry.path === `${BASE}/restore`);
expect(call?.method).toBe("POST");
- expect(call?.body).toEqual({ commitSha: "0123456789" });
+ expect(call?.body).toEqual({ commitSha: OLDER_SHA });
+ });
+
+ test("a restore keeps unsaved edits rather than silently discarding them", async () => {
+ const el = await mount();
+ await act(async () => {
+ typeInto("skill-body", "Read the report.\nMy unsaved edit.");
+ });
+ await click(enabledIn(versionTable(el), "Restore")[0] as HTMLButtonElement);
+ expect(field("skill-body").value).toBe(
+ "Read the report.\nMy unsaved edit.",
+ );
+ });
+
+ test("a failed side action stays local: the editor and the edit survive", async () => {
+ stubs[`PUT ${BASE}/scope`] = {
+ status: 500,
+ body: { error: { message: "hub is down" } },
+ };
+ const el = await mount();
+ await act(async () => {
+ typeInto("skill-body", "Read the report.\nMy unsaved edit.");
+ });
+ await click(buttonNamed(el, "Share with workbench"));
+
+ expect(el.textContent).not.toContain("Couldn't load this skill");
+ expect(el.textContent).not.toContain("hub is down");
+ expect(el.querySelector('[role="alert"]')?.textContent).toContain(
+ "Something went wrong",
+ );
+ expect(field("skill-body").value).toBe(
+ "Read the report.\nMy unsaved edit.",
+ );
+ });
+
+ test("a compare that fails reports itself without replacing the page", async () => {
+ stubs[`GET ${BASE}/versions/${OLDER_SHA}`] = {
+ status: 500,
+ body: { error: { message: "hub is down" } },
+ };
+ const el = await mount();
+ await click(enabledIn(versionTable(el), "Compare")[0] as HTMLButtonElement);
+
+ expect(el.textContent).not.toContain("Couldn't load this skill");
+ expect(el.querySelector('[role="alert"]')?.textContent).toContain(
+ "Something went wrong",
+ );
+ expect(document.getElementById("skill-body")).not.toBeNull();
});
- test("a failed read says so rather than showing an empty editor", async () => {
- globalThis.fetch = (async () =>
- new Response(JSON.stringify({ error: { message: "registry is down" } }), {
- status: 503,
- })) as unknown as typeof fetch;
+ test("a skill that isn't there says so, rather than reporting a failure", async () => {
+ stubs = {};
+ const el = await mount();
+ expect(el.textContent).toContain(`No skill named “${NAME}”`);
+ expect(el.textContent).not.toContain("Couldn't load this skill");
+ });
+
+ test("a failed read says so in plain language, never the server's own words", async () => {
+ stubs[`GET ${BASE}`] = {
+ status: 503,
+ body: { error: { message: "registry is down" } },
+ };
const el = await mount();
expect(el.textContent).toContain("Couldn't load this skill");
expect(el.textContent).not.toContain("registry is down");
});
+
+ test("a change too large to show line by line falls back to a summary", async () => {
+ const el = await mount();
+ const huge = Array.from(
+ { length: 4_000 },
+ (_, index) => `rewritten line ${String(index)}`,
+ ).join("\n");
+ await act(async () => {
+ typeInto("skill-body", huge);
+ });
+ await click(buttonNamed(el, "Save…"));
+
+ const summary = document.body.querySelector(
+ '[data-testid="diff-too-large"]',
+ );
+ expect(summary?.textContent).toContain("too large to show line by line");
+ expect(document.body.querySelector('[data-testid="diff-view"]')).toBeNull();
+ // Still a real save: the review refuses to draw the diff, not the save.
+ await click(buttonNamed(document.body, "Confirm & save"));
+ expect(saves()).toHaveLength(1);
+ });
});
diff --git a/packages/skills/test/routes.test.ts b/packages/skills/test/routes.test.ts
index a65ba5674..59e382f57 100644
--- a/packages/skills/test/routes.test.ts
+++ b/packages/skills/test/routes.test.ts
@@ -141,6 +141,90 @@ test("GET /:name/versions/:commitSha for an unknown commit is a 404", async () =
expect(response.status).toBe(404);
});
+async function headSha(app: Hono): Promise {
+ const versions = (await (await app.request("/triage/versions")).json()) as {
+ versions: { commitSha: string }[];
+ };
+ return versions.versions[0]?.commitSha ?? "";
+}
+
+test("PUT /:name with the current head as expectedHeadSha saves", async () => {
+ const app = buildApp();
+ await createSkill(app);
+ const response = await app.request("/triage", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ description: "Sorts inbound issues.",
+ body: "Read the report. Pick a severity label.",
+ expectedHeadSha: await headSha(app),
+ }),
+ });
+ expect(response.status).toBe(200);
+});
+
+test("PUT /:name is a 409 when the skill moved on since the edit started", async () => {
+ const app = buildApp();
+ await createSkill(app);
+ const staleSha = await headSha(app);
+
+ // Somebody else saves first.
+ await app.request("/triage", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ description: "Sorts inbound issues.",
+ body: "Their version.",
+ }),
+ });
+
+ const response = await app.request("/triage", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ description: "Sorts inbound issues.",
+ body: "My version, written against the old head.",
+ expectedHeadSha: staleSha,
+ }),
+ });
+ expect(response.status).toBe(409);
+
+ // The other person's version is still the current one — nothing buried.
+ const current = (await (await app.request("/triage")).json()) as {
+ skill: { body: string };
+ };
+ expect(current.skill.body).toBe("Their version.");
+});
+
+test("PUT /:name with a malformed expectedHeadSha is a 400", async () => {
+ const app = buildApp();
+ await createSkill(app);
+ const response = await app.request("/triage", {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ description: "Sorts inbound issues.",
+ body: "x",
+ expectedHeadSha: "not a sha",
+ }),
+ });
+ expect(response.status).toBe(400);
+});
+
+test("GET /:name/versions/:commitSha refuses a version id that isn't one", async () => {
+ const app = buildApp();
+ await createSkill(app);
+ // Path-shaped and over-long ids are refused at the boundary; a
+ // well-shaped id that simply isn't in this skill's history is a 404.
+ expect(
+ (await app.request("/triage/versions/..%2F..%2Fetc%2Fpasswd")).status,
+ ).toBe(400);
+ expect((await app.request(`/triage/versions/${"a".repeat(65)}`)).status).toBe(
+ 400,
+ );
+ expect((await app.request("/triage/versions/commit9999")).status).toBe(404);
+});
+
test("PUT /:name for an unknown skill is a 404", async () => {
const app = buildApp();
const response = await app.request("/does-not-exist", {
diff --git a/packages/text-diff/src/line-diff.test.ts b/packages/text-diff/src/line-diff.test.ts
index fea03733b..a1963acd2 100644
--- a/packages/text-diff/src/line-diff.test.ts
+++ b/packages/text-diff/src/line-diff.test.ts
@@ -1,21 +1,40 @@
import { describe, expect, test } from "bun:test";
-import { diffLines, diffTotals, hasChanges } from "./line-diff";
+import {
+ DEFAULT_DIFF_LIMITS,
+ diffText,
+ normalizeNewlines,
+ type Diff,
+ type DiffLine,
+} from "./line-diff";
+
+function diffed(before: string, after: string, limits = DEFAULT_DIFF_LIMITS) {
+ const diff = diffText(before, after, limits);
+ if (diff.status !== "diffed") {
+ throw new Error(`expected a diff, got ${diff.status}`);
+ }
+ return diff;
+}
function render(before: string, after: string): string[] {
- return diffLines(before, after).map((line) => {
+ return diffed(before, after).lines.map((line: DiffLine) => {
+ if (line.kind === "skipped") return `~${line.text}`;
const marker =
line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " ";
return `${marker}${line.text}`;
});
}
-describe("diffLines", () => {
- test("identical text is all context", () => {
- const lines = diffLines("one\ntwo", "one\ntwo");
- expect(lines.map((line) => line.kind)).toEqual(["context", "context"]);
- expect(hasChanges(lines)).toBe(false);
- expect(diffTotals(lines)).toEqual({ added: 0, removed: 0 });
+function lines(count: number, prefix = "line"): string {
+ return Array.from({ length: count }, (_, i) => `${prefix} ${String(i)}`).join(
+ "\n",
+ );
+}
+
+describe("diffText", () => {
+ test("identical text is reported as identical, with no script to render", () => {
+ const diff: Diff = diffText("one\ntwo", "one\ntwo");
+ expect(diff.status).toBe("identical");
});
test("an edit in the middle leaves the surrounding lines as context", () => {
@@ -33,7 +52,7 @@ describe("diffLines", () => {
"+two",
" three",
]);
- expect(diffTotals(diffLines("one\nthree", "one\ntwo\nthree"))).toEqual({
+ expect(diffed("one\nthree", "one\ntwo\nthree").totals).toEqual({
added: 1,
removed: 0,
});
@@ -48,9 +67,8 @@ describe("diffLines", () => {
});
test("line numbers point at each side's own revision", () => {
- const lines = diffLines("a\nb", "a\nc\nb");
expect(
- lines.map((line) => [
+ diffed("a\nb", "a\nc\nb").lines.map((line) => [
line.kind,
line.beforeLineNumber,
line.afterLineNumber,
@@ -65,11 +83,7 @@ describe("diffLines", () => {
test("empty before is all additions and empty after is all removals", () => {
expect(render("", "one\ntwo")).toEqual(["+one", "+two"]);
expect(render("one\ntwo", "")).toEqual(["-one", "-two"]);
- expect(diffLines("", "")).toEqual([]);
- });
-
- test("carriage returns do not read as changed lines", () => {
- expect(hasChanges(diffLines("one\r\ntwo", "one\ntwo"))).toBe(false);
+ expect(diffText("", "").status).toBe("identical");
});
test("a trailing blank line is a visible addition", () => {
@@ -88,11 +102,75 @@ describe("diffLines", () => {
});
test("a moved line is not reported as unchanged in both places", () => {
- const lines = diffLines("header\nbody", "body\nheader");
- expect(diffTotals(lines).added).toBeGreaterThan(0);
- expect(diffTotals(lines).removed).toBeGreaterThan(0);
- expect(
- lines.filter((line) => line.kind === "context").map((line) => line.text),
- ).toHaveLength(1);
+ const diff = diffed("header\nbody", "body\nheader");
+ expect(diff.totals.added).toBeGreaterThan(0);
+ expect(diff.totals.removed).toBeGreaterThan(0);
+ expect(diff.lines.filter((line) => line.kind === "context")).toHaveLength(
+ 1,
+ );
+ });
+});
+
+describe("newline conventions", () => {
+ test("normalizeNewlines collapses CRLF and lone carriage returns", () => {
+ expect(normalizeNewlines("a\r\nb\rc\nd")).toBe("a\nb\nc\nd");
+ });
+
+ test("the same text in two newline conventions is identical, not rewritten", () => {
+ expect(diffText("one\r\ntwo", "one\ntwo").status).toBe("identical");
+ expect(diffText("one\rtwo", "one\ntwo").status).toBe("identical");
+ });
+});
+
+describe("long documents", () => {
+ test("unchanged runs between edits collapse to a skipped row", () => {
+ const before = `head\n${lines(40)}\ntail`;
+ const after = `HEAD\n${lines(40)}\nTAIL`;
+ const diff = diffed(before, after);
+ const skipped = diff.lines.filter((line) => line.kind === "skipped");
+ expect(skipped).toHaveLength(1);
+ expect(skipped[0]?.skippedLines).toBe(34);
+ expect(skipped[0]?.text).toBe("34 unchanged lines");
+ // Both edits (two rows each), 3 context lines either side of the
+ // collapsed run, the skipped row itself, and nothing else.
+ expect(diff.lines).toHaveLength(2 + 3 + 1 + 3 + 2);
+ expect(diff.totals).toEqual({ added: 2, removed: 2 });
+ });
+
+ test("a one-line edit in a 20k-line document diffs on trimmed input, fast", () => {
+ const body = lines(20_000);
+ const edited = body.replace("line 10000", "line 10000 — revised");
+ const started = performance.now();
+ const diff = diffed(body, edited);
+ const elapsedMs = performance.now() - started;
+ expect(diff.totals).toEqual({ added: 1, removed: 1 });
+ // The quadratic walk sees one line per side; anything near the full
+ // document would blow far past this.
+ expect(elapsedMs).toBeLessThan(250);
+ });
+
+ test("a wholly rewritten large document is refused instead of allocating for it", () => {
+ const before = lines(6_000, "before");
+ const after = lines(6_000, "after");
+ const diff = diffText(before, after);
+ expect(diff).toEqual({
+ status: "too-large",
+ beforeLines: 6_000,
+ afterLines: 6_000,
+ changedBeforeLines: 6_000,
+ changedAfterLines: 6_000,
+ });
+ });
+
+ test("the character cap refuses long lines even when the line count is small", () => {
+ const before = `${"a".repeat(300_000)}\nx`;
+ const after = `${"b".repeat(300_000)}\ny`;
+ expect(diffText(before, after).status).toBe("too-large");
+ });
+
+ test("the caps are the caller's to set", () => {
+ const tiny = { maxLines: 2, maxCharacters: 1_000, contextLines: 1 };
+ expect(diffText("a\nb\nc", "x\ny\nz", tiny).status).toBe("too-large");
+ expect(diffText("a\nb", "x\ny", tiny).status).toBe("diffed");
});
});
From 8d1c0569ec75da563379e568c02ea69b52a963be Mon Sep 17 00:00:00 2001
From: Sawyer
Date: Thu, 20 Aug 2026 16:26:04 -0700
Subject: [PATCH 4/4] Skills: bound the diff and refuse a save that lost the
race
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
apps/web/src/pages/diff-view.tsx | 60 +++--
apps/web/src/pages/skill-detail-page.tsx | 221 +++++++++++++-----
apps/web/src/skills-api.ts | 28 ++-
packages/skills/src/registry.ts | 26 ++-
packages/skills/src/routes.ts | 22 +-
packages/text-diff/src/index.ts | 7 +-
packages/text-diff/src/line-diff.ts | 278 +++++++++++++++++++----
7 files changed, 497 insertions(+), 145 deletions(-)
diff --git a/apps/web/src/pages/diff-view.tsx b/apps/web/src/pages/diff-view.tsx
index 3974cdb85..408df1b9d 100644
--- a/apps/web/src/pages/diff-view.tsx
+++ b/apps/web/src/pages/diff-view.tsx
@@ -1,43 +1,33 @@
// 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 itself
-// comes from `@corbits/text-diff`; this file is only its presentation.
+// 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 { diffLines, diffTotals, hasChanges } from "@corbits/text-diff";
+import { diffText } from "@corbits/text-diff";
import type { DiffLine } from "@corbits/text-diff";
+import { useMemo } from "react";
const MARKER: Record = {
context: " ",
added: "+",
removed: "-",
+ skipped: "⋯",
};
const ROW_CLASS: Record = {
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 DiffSummary({
- before,
- after,
-}: {
- readonly before: string;
- readonly after: string;
-}) {
- const totals = diffTotals(diffLines(before, after));
- return (
-