feat(ui): show applied revision changes (#125854)

Show bounded predecessor diffs by default so operators can understand applied Skill Workshop revisions without manual comparison. Keep the full revision body one click away and preserve oldest-revision fallback behavior.
This commit is contained in:
Ayaan Zaidi
2026-08-18 20:40:07 +05:30
committed by GitHub
parent c77feb00f3
commit 0c0649037c
19 changed files with 1154 additions and 362 deletions
@@ -0,0 +1,148 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
canRunPlaywrightChromium,
installMockGateway,
resolvePlaywrightChromiumExecutablePath,
startControlUiE2eServer,
type ControlUiE2eServer,
type MockGatewayRequest,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath);
const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1";
const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip;
const artifactDir = path.resolve(process.cwd(), ".artifacts/control-ui-e2e/applied-revision-diff");
let browser: Browser;
let server: ControlUiE2eServer;
function appliedProposal(id: string, updatedAt: string) {
return {
createdAt: updatedAt,
description: "Keep the release procedure accurate.",
id,
kind: "update",
scanState: "clean",
skillKey: "deploy-review",
skillName: "Deploy Review",
status: "applied",
title: "Deploy review",
updatedAt,
};
}
function inspectResponse(
proposal: ReturnType<typeof appliedProposal>,
content: string,
version: string,
) {
return {
content,
record: {
...proposal,
proposedVersion: version,
target: { skillKey: proposal.skillKey, skillName: proposal.skillName },
},
supportFiles: [],
};
}
function inspectedProposalId(request: MockGatewayRequest): unknown {
const params = request.params;
if (!params || typeof params !== "object" || !("proposalId" in params)) {
throw new Error("Expected skills.proposals.inspect params");
}
return params.proposalId;
}
describeControlUiE2e("Skill Workshop applied revision diff mocked Gateway E2E", () => {
beforeAll(async () => {
if (!chromiumAvailable) {
throw new Error(`Playwright Chromium is unavailable at ${chromiumExecutablePath}`);
}
await rm(artifactDir, { force: true, recursive: true });
await mkdir(artifactDir, { recursive: true });
server = await startControlUiE2eServer();
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
});
afterAll(async () => {
await browser?.close();
await server?.close();
});
it("shows a compact revision diff and switches to the full body", async () => {
const previous = appliedProposal("proposal-v1", "2026-08-16T10:00:00.000Z");
const latest = appliedProposal("proposal-v2", "2026-08-17T10:00:00.000Z");
const previousBody = "# Deploy review\n\n## Steps\n1. Verify package.\n2. Publish release.";
const latestBody = "# Deploy review\n\n## Steps\n1. Verify package.\n2. Publish package.";
const context = await browser.newContext({
locale: "en-US",
recordVideo: { dir: artifactDir, size: { height: 900, width: 1280 } },
serviceWorkers: "block",
viewport: { height: 900, width: 1280 },
});
const page = await context.newPage();
const gateway = await installMockGateway(page, {
methodResponses: {
"skills.proposals.inspect": {
cases: [
{
match: { proposalId: latest.id },
response: inspectResponse(latest, latestBody, "v2"),
},
{
match: { proposalId: previous.id },
response: inspectResponse(previous, previousBody, "v1"),
},
],
},
"skills.proposals.list": {
proposals: [latest, previous],
schema: "openclaw.skill-workshop.proposals-manifest.v1",
updatedAt: latest.updatedAt,
},
},
});
try {
const response = await page.goto(`${server.baseUrl}skills/workshop`);
expect(response?.status()).toBe(200);
await gateway.waitForRequest("skills.proposals.list");
await page.locator("#skill-workshop-mode-tab-board").click();
await page.locator(".sw-lifecycle-tab", { hasText: "Applied" }).click();
const changes = page.getByRole("button", { name: "Changes", exact: true });
await expect.poll(() => changes.getAttribute("aria-pressed")).toBe("true");
await page.locator(".sw-diff__row--add", { hasText: "Publish package." }).waitFor();
await page.locator(".sw-diff__row--del", { hasText: "Publish release." }).waitFor();
const inspectRequests = await gateway.getRequests("skills.proposals.inspect");
expect(new Set(inspectRequests.map(inspectedProposalId))).toEqual(
new Set([latest.id, previous.id]),
);
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "01-changes.png"),
});
await page.getByRole("button", { name: "Full body", exact: true }).click();
await page.getByText("Publish package.", { exact: true }).waitFor();
expect(await page.locator(".sw-diff").count()).toBe(0);
expect(await gateway.getRequests("skills.proposals.inspect")).toHaveLength(
inspectRequests.length,
);
await page.screenshot({
animations: "disabled",
fullPage: true,
path: path.join(artifactDir, "02-full-body.png"),
});
} finally {
await context.close();
}
});
});
+9
View File
@@ -3479,6 +3479,15 @@ export const en: TranslationMap = {
yesterday: "Yesterday",
earlier: "Earlier this week",
},
diff: {
changes: "Changes",
fullBody: "Full body",
viewLabel: "Revision view",
unchanged: "This revision left the skill body unchanged.",
loadingPrevious: "Loading the previous revision\u2026",
previousUnavailable: "The previous revision is unavailable, so this is the full body.",
tooLarge: "This comparison is too large to show here. Switch to Full body to read it.",
},
applied: {
history: "History",
revision: "{count} revision",
+29
View File
@@ -106,6 +106,35 @@ describe("computeLineDiff", () => {
{ kind: "skip", text: "" },
]);
});
it("collapses unchanged runs to three context lines when asked", () => {
const oldLines = Array.from({ length: 40 }, (_, index) => `line ${index}`);
const newLines = [...oldLines];
newLines[20] = "changed";
const full = computeLineDiff(oldLines.join("\n"), newLines.join("\n"));
const compact = computeLineDiff(oldLines.join("\n"), newLines.join("\n"), {
compactUnchanged: true,
});
expect(full).toHaveLength(41);
expect(compact.filter((line) => line.kind === "ctx").map((line) => line.text)).toEqual([
"line 17",
"line 18",
"line 19",
"line 21",
"line 22",
"line 23",
]);
expect(compact.filter((line) => line.kind === "skip")).toHaveLength(2);
});
it("compacts an identical pair to nothing so callers can say unchanged", () => {
const text = "alpha\nbeta\ngamma";
expect(computeLineDiff(text, text, { compactUnchanged: true })).toEqual([]);
expect(computeLineDiff(text, text)).toHaveLength(3);
});
});
describe("buildWriteDiffLines", () => {
+17 -4
View File
@@ -100,12 +100,19 @@ function splitDiffLines(text: string): string[] {
return lines;
}
function compactLineDiff(lines: DiffLine[], inputTruncated: boolean): DiffLine[] {
if (lines.length <= MAX_DIFF_RENDER_LINES && !inputTruncated) {
function compactLineDiff(
lines: DiffLine[],
inputTruncated: boolean,
compactUnchanged: boolean,
): DiffLine[] {
if (!compactUnchanged && lines.length <= MAX_DIFF_RENDER_LINES && !inputTruncated) {
return lines;
}
const hasChange = lines.some((line) => line.kind === "add" || line.kind === "del");
if (!hasChange) {
if (compactUnchanged && !inputTruncated) {
return [];
}
return inputTruncated
? [{ kind: "skip", text: "" }]
: [...lines.slice(0, MAX_DIFF_RENDER_LINES), { kind: "skip", text: "" }];
@@ -151,8 +158,14 @@ function compactLineDiff(lines: DiffLine[], inputTruncated: boolean): DiffLine[]
/**
* Compute a line diff between two snippets (no file line numbers available).
* Standard LCS table; inputs are bounded so the quadratic cost stays small.
*
* `compactUnchanged` collapses unchanged runs to three lines of context.
*/
export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
export function computeLineDiff(
oldText: string,
newText: string,
options?: { compactUnchanged?: boolean },
): DiffLine[] {
const allOldLines = splitDiffLines(oldText);
const allNewLines = splitDiffLines(newText);
const inputTruncated =
@@ -213,7 +226,7 @@ export function computeLineDiff(oldText: string, newText: string): DiffLine[] {
}
j++;
}
return compactLineDiff(lines, inputTruncated);
return compactLineDiff(lines, inputTruncated, options?.compactUnchanged === true);
}
/** All-added preview for freshly written files, numbered from line 1. */
+24
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
filterSkillWorkshopAppliedSkills,
filterSkillWorkshopProposals,
findSkillWorkshopAppliedPredecessor,
type SkillWorkshopProposal,
type SkillWorkshopProposalStatus,
} from "./index.ts";
@@ -30,6 +31,7 @@ function proposal(options: {
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
bodyLoaded: true,
isNew: false,
};
}
@@ -98,4 +100,26 @@ describe("Skill Workshop proposal filtering", () => {
}
expect(filterSkillWorkshopProposals(proposals, "all", "")).toEqual(proposals);
});
it("points every applied revision at the one it replaced", () => {
const proposals = [
proposal({ key: "v3", updatedAt: 3 }),
proposal({ key: "v1", updatedAt: 1 }),
proposal({ key: "v2", updatedAt: 2 }),
proposal({ key: "other", slug: "other-skill", updatedAt: 9 }),
proposal({ key: "pending", status: "pending", updatedAt: 4 }),
];
const [skill] = filterSkillWorkshopAppliedSkills(proposals, "release-sanity");
expect(
skill?.revisions.map(({ proposal: item, previous }) => [item.key, previous?.key]),
).toEqual([
["v3", "v2"],
["v2", "v1"],
["v1", undefined],
]);
expect(findSkillWorkshopAppliedPredecessor(proposals, "v3")?.key).toBe("v2");
expect(findSkillWorkshopAppliedPredecessor(proposals, "v1")).toBeNull();
expect(findSkillWorkshopAppliedPredecessor(proposals, "pending")).toBeNull();
});
});
+27 -1
View File
@@ -57,6 +57,12 @@ export type SkillWorkshopProposal = {
name: string;
oneLine: string;
body: string;
/**
* A proposal inspected through the gateway may legitimately have an empty
* body, so emptiness alone cannot mean "not fetched yet". Cold entries from
* the manifest carry `false`.
*/
bodyLoaded: boolean;
status: SkillWorkshopProposalStatus;
origin?: {
agentId?: string;
@@ -78,6 +84,7 @@ export type SkillWorkshopProposal = {
export type SkillWorkshopStatusFilter = "all" | SkillWorkshopProposalStatus;
export type SkillWorkshopAction = "apply" | "evaluate" | "revise" | "reject";
export type SkillWorkshopMode = "board" | "today";
export type SkillWorkshopAppliedDiffMode = "changes" | "full";
export type SkillWorkshopActionBusy = {
key: string;
@@ -94,6 +101,7 @@ type SkillWorkshopAppliedRevision = {
proposal: SkillWorkshopProposal;
version: number;
operation: SkillWorkshopProposal["kind"];
previous: SkillWorkshopProposal | null;
};
export type SkillWorkshopAppliedSkill = {
@@ -140,11 +148,29 @@ function groupSkillWorkshopAppliedSkills(
latest: proposalsForSkill[0],
revisions: proposalsForSkill.map((proposal, index) => {
const version = proposalsForSkill.length - index;
return { proposal, version, operation: proposal.kind };
return {
proposal,
version,
operation: proposal.kind,
previous: proposalsForSkill[index + 1] ?? null,
};
}),
}));
}
export function findSkillWorkshopAppliedPredecessor(
proposals: SkillWorkshopProposal[],
key: string,
): SkillWorkshopProposal | null {
for (const skill of groupSkillWorkshopAppliedSkills(proposals)) {
const revision = skill.revisions.find(({ proposal }) => proposal.key === key);
if (revision) {
return revision.previous;
}
}
return null;
}
export function filterSkillWorkshopAppliedSkills(
proposals: SkillWorkshopProposal[],
query: string,
@@ -1,8 +1,43 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import { computeLineDiff, type DiffLine, diffStat } from "../../lib/chat/tool-call-diff.ts";
import type { SkillWorkshopAppliedSkill } from "../../lib/skill-workshop/index.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
const DIFF_SIGN: Record<DiffLine["kind"], string> = {
add: "+",
del: "-",
ctx: " ",
file: " ",
skip: "\u22ef",
};
function renderDiffRow(line: DiffLine) {
return html`
<div class="sw-diff__row sw-diff__row--${line.kind}">
<span class="sw-diff__sign" aria-hidden="true">${DIFF_SIGN[line.kind]}</span>
<span class="sw-diff__text">${line.kind === "skip" ? "" : line.text}</span>
</div>
`;
}
export function renderAppliedRevisionDiff(previousBody: string, body: string) {
const lines = computeLineDiff(previousBody, body, { compactUnchanged: true });
if (lines.length === 0) {
return html`<p class="sw-muted">${t("skillWorkshop.diff.unchanged")}</p>`;
}
const { added, removed } = diffStat(lines);
return html`
<div class="sw-diff">
<p class="sw-diff__stat">
<span class="sw-diff__stat-add">+${added}</span>
<span class="sw-diff__stat-del">-${removed}</span>
</p>
<div class="sw-diff__rows">${lines.map(renderDiffRow)}</div>
</div>
`;
}
export function renderAppliedHistory(props: SkillWorkshopProps, skill: SkillWorkshopAppliedSkill) {
return html`
<section class="sw-section sw-applied-history">
+62 -11
View File
@@ -8,18 +8,26 @@ import {
} from "../../lib/skill-workshop/index.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
type AppliedHistoryRenderer = typeof import("./applied-history.runtime.ts").renderAppliedHistory;
type AppliedHistoryRuntime = typeof import("./applied-history.runtime.ts");
let appliedHistoryRenderer: AppliedHistoryRenderer | undefined;
let appliedHistoryRuntime: Promise<AppliedHistoryRenderer> | undefined;
const MAX_APPLIED_DIFF_INPUT_CHARS = 120_000;
function loadAppliedHistoryRenderer(): Promise<AppliedHistoryRenderer> {
return (appliedHistoryRuntime ??= import("./applied-history.runtime.ts").then((runtime) => {
appliedHistoryRenderer = runtime.renderAppliedHistory;
return appliedHistoryRenderer;
let appliedHistoryRuntime: AppliedHistoryRuntime | undefined;
let appliedHistoryLoad: Promise<AppliedHistoryRuntime> | undefined;
// Diff computation and its markup stay behind this one dynamic import so the
// startup bundle never carries them; both lazy entries share the same load.
function loadAppliedHistoryRuntime(): Promise<AppliedHistoryRuntime> {
return (appliedHistoryLoad ??= import("./applied-history.runtime.ts").then((runtime) => {
appliedHistoryRuntime = runtime;
return runtime;
}));
}
function pendingRuntime() {
return html`<p class="sw-muted" aria-busy="true">${t("common.loading")}</p>`;
}
export function resolveAppliedHistory(
proposals: SkillWorkshopProposal[],
query: string,
@@ -35,15 +43,58 @@ export function resolveAppliedHistory(
return { skills, selectedSkill, selectedProposal };
}
/**
* What the body card should show for a revision. `previousUnavailable` keeps
* the full body but says so, because a predecessor inspect can fail and a
* silent full body would read as "nothing changed".
*/
export type SkillWorkshopBodyView =
| { kind: "full" }
| { kind: "loadingPrevious" }
| { kind: "previousUnavailable" }
| { kind: "tooLarge" }
| { kind: "diff"; previous: SkillWorkshopProposal };
export function resolveAppliedBodyView(
props: SkillWorkshopProps,
proposal: SkillWorkshopProposal,
previous: SkillWorkshopProposal | null,
): SkillWorkshopBodyView {
if (!previous || props.appliedDiffMode === "full") {
return { kind: "full" };
}
if (previous.bodyLoaded) {
if (previous.body.length + proposal.body.length > MAX_APPLIED_DIFF_INPUT_CHARS) {
return { kind: "tooLarge" };
}
return { kind: "diff", previous };
}
return props.inspectingKey === previous.key
? { kind: "loadingPrevious" }
: { kind: "previousUnavailable" };
}
export function renderLazyAppliedHistory(
props: SkillWorkshopProps,
skill: SkillWorkshopAppliedSkill,
) {
if (appliedHistoryRenderer) {
return appliedHistoryRenderer(props, skill);
if (appliedHistoryRuntime) {
return appliedHistoryRuntime.renderAppliedHistory(props, skill);
}
return until(
loadAppliedHistoryRenderer().then((renderer) => renderer(props, skill)),
html`<p class="sw-muted" aria-busy="true">${t("common.loading")}</p>`,
loadAppliedHistoryRuntime().then((runtime) => runtime.renderAppliedHistory(props, skill)),
pendingRuntime(),
);
}
export function renderLazyAppliedRevisionDiff(previousBody: string, body: string) {
if (appliedHistoryRuntime) {
return appliedHistoryRuntime.renderAppliedRevisionDiff(previousBody, body);
}
return until(
loadAppliedHistoryRuntime().then((runtime) =>
runtime.renderAppliedRevisionDiff(previousBody, body),
),
pendingRuntime(),
);
}
@@ -84,6 +84,7 @@ const proposal: SkillWorkshopProposal = {
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
bodyLoaded: true,
isNew: false,
};
@@ -101,6 +102,7 @@ function propsFor(mode: SkillWorkshopMode): SkillWorkshopProps {
inspectingKey: null,
proposals: [proposal],
selectedKey: proposal.key,
appliedDiffMode: "changes",
statusFilter: "pending",
query: "",
filePreviewKey: null,
@@ -123,6 +125,7 @@ function propsFor(mode: SkillWorkshopMode): SkillWorkshopProps {
onQueueWidthChange: vi.fn(),
onModeChange: vi.fn(),
onSelect: vi.fn(),
onAppliedDiffModeChange: vi.fn(),
onPrev: vi.fn(),
onNext: vi.fn(),
onApply: vi.fn(),
@@ -0,0 +1,244 @@
import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion";
import { formatBytes } from "../../lib/agents/display.ts";
import type {
SkillWorkshopEvaluation,
SkillWorkshopProposal,
SkillWorkshopProposalStatus,
} from "../../lib/skill-workshop/index.ts";
type SkillProposalStatus = SkillWorkshopProposalStatus;
type SkillProposalKind = SkillWorkshopProposal["kind"];
type SkillProposalScanState = "pending" | "clean" | "failed" | "quarantined";
type SkillProposalManifestEntry = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
skillName: string;
skillKey: string;
createdAt: string;
updatedAt: string;
scanState: SkillProposalScanState;
};
export type SkillProposalManifest = {
schema: "openclaw.skill-workshop.proposals-manifest.v1";
updatedAt: string;
proposals: SkillProposalManifestEntry[];
};
type SkillProposalSupportFileRecord = {
path: string;
sizeBytes: number;
};
type SkillProposalOrigin = {
agentId?: string;
sessionKey?: string;
runId?: string;
messageId?: string;
};
type SkillProposalRecord = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
createdAt: string;
updatedAt: string;
proposedVersion: string;
draftHash: string;
evaluation?: SkillWorkshopEvaluation;
origin?: SkillProposalOrigin;
supportFiles?: SkillProposalSupportFileRecord[];
target: {
skillName: string;
skillKey: string;
};
};
type SkillProposalSupportFile = {
path: string;
content: string;
};
export type SkillProposalInspectResult = {
record: SkillProposalRecord;
revisionHash?: string;
content: string;
supportFiles?: SkillProposalSupportFile[];
};
export type SkillProposalEvaluateResult = {
record: SkillProposalRecord;
evaluation: SkillWorkshopEvaluation;
};
export function parseDateMs(value: string | undefined): number {
return parseDateStringTimestampMs(value) ?? Date.now();
}
function startOfLocalDay(ms: number): number {
const date = new Date(ms);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function recencyGroup(ms: number): SkillWorkshopProposal["recencyGroup"] {
const today = startOfLocalDay(Date.now());
const day = startOfLocalDay(ms);
if (day === today) {
return "today";
}
if (day === today - 24 * 60 * 60 * 1000) {
return "yesterday";
}
return "earlier";
}
function compactAgeLabel(ms: number): string {
const diff = Math.max(0, Date.now() - ms);
const min = Math.floor(diff / 60_000);
if (min < 1) {
return "now";
}
if (min < 60) {
return `${min}m`;
}
const hr = Math.floor(min / 60);
if (hr < 24) {
return `${hr}h`;
}
const day = Math.floor(hr / 24);
return `${day}d`;
}
function proposedVersionNumber(value: string | undefined): number {
const parsed = Number.parseInt((value ?? "").replace(/^v/i, ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
function stripProposalFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function supportFilesFromInspect(
result: SkillProposalInspectResult,
): SkillWorkshopProposal["supportFiles"] {
const sizes = new Map(
(result.record.supportFiles ?? []).map((file) => [file.path, file.sizeBytes]),
);
return (result.supportFiles ?? []).map((file) => ({
path: file.path,
size: formatBytes(Math.max(0, sizes.get(file.path) ?? byteLength(file.content)), {
fallback: "0 B",
maxUnit: "kilo",
fractionDigits: (_value, unit) => (unit === "byte" ? null : 1),
}),
contents: file.content,
}));
}
export function proposalFromManifest(
entry: SkillProposalManifestEntry,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const updatedAt = parseDateMs(entry.updatedAt);
const createdAt = parseDateMs(entry.createdAt);
const previousIsCurrent = previous?.updatedAt === updatedAt;
return {
key: entry.id,
kind: entry.kind,
slug: entry.skillKey,
name: entry.title || entry.skillName,
oneLine: entry.description,
body: previousIsCurrent ? previous.body : "",
bodyLoaded: previousIsCurrent ? previous.bodyLoaded : false,
status: entry.status,
...(previousIsCurrent && previous.origin ? { origin: previous.origin } : {}),
version: previousIsCurrent ? previous.version : 1,
revisionHash: previousIsCurrent ? previous.revisionHash : null,
...(previousIsCurrent && previous.evaluation ? { evaluation: previous.evaluation } : {}),
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: previousIsCurrent ? previous.supportFiles : [],
isNew: previous?.isNew ?? false,
};
}
export function proposalFromInspect(
result: SkillProposalInspectResult,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const record = result.record;
const updatedAt = parseDateMs(record.updatedAt);
const createdAt = parseDateMs(record.createdAt);
const revisionHash = result.revisionHash?.trim() || null;
const evaluation =
record.evaluation?.revisionHash === revisionHash
? record.evaluation
: previous?.evaluation?.revisionHash === revisionHash
? previous.evaluation
: undefined;
return {
key: record.id,
kind: record.kind,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
body: stripProposalFrontmatter(result.content),
bodyLoaded: true,
status: record.status,
...(record.origin ? { origin: record.origin } : {}),
version: proposedVersionNumber(record.proposedVersion),
revisionHash,
...(evaluation ? { evaluation } : {}),
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: supportFilesFromInspect(result),
isNew: previous?.isNew ?? false,
};
}
export function proposalFromEvaluation(
result: SkillProposalEvaluateResult,
previous: SkillWorkshopProposal,
): SkillWorkshopProposal {
const record = result.record;
const updatedAt = parseDateMs(record.updatedAt);
const createdAt = parseDateMs(record.createdAt);
return {
key: record.id,
kind: record.kind,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
body: previous.body,
bodyLoaded: previous.bodyLoaded,
status: record.status,
...(record.origin
? { origin: record.origin }
: previous.origin
? { origin: previous.origin }
: {}),
version: proposedVersionNumber(record.proposedVersion),
revisionHash: result.evaluation.revisionHash,
evaluation: result.evaluation,
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: previous.supportFiles,
isNew: previous.isNew,
};
}
+96 -6
View File
@@ -129,6 +129,7 @@ function proposal(overrides: Partial<SkillWorkshopProposal> = {}): SkillWorkshop
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
bodyLoaded: true,
isNew: false,
...overrides,
};
@@ -189,6 +190,37 @@ describe("Skill Workshop proposal RPCs", () => {
expect(state.skillWorkshopProposals[0]?.kind).toBe("create");
});
it("reports a failed inspect for a selection retained across refresh", async () => {
const appliedManifest = manifest("applied");
const latest = appliedManifest.proposals[0];
if (!latest) {
throw new Error("Expected proposal fixture");
}
const previous = {
...latest,
id: "proposal-0",
updatedAt: "2026-06-15T12:00:00.000Z",
};
const { state, context, request } = createFixture({
skillWorkshopAgentId: "research",
skillWorkshopSelectedKey: "proposal-1",
});
request.mockImplementation(async (method: string) => {
if (method === "skills.proposals.list") {
return { ...appliedManifest, proposals: [latest, previous] };
}
throw new Error("inspect failed");
});
await loadSkillWorkshopProposals(state, context, { force: true });
expect(state.skillWorkshopSelectedKey).toBe("proposal-1");
expect(state.skillWorkshopError).toContain("inspect failed");
expect(request.mock.calls.filter(([method]) => method === "skills.proposals.inspect")).toEqual([
["skills.proposals.inspect", { agentId: "research", proposalId: "proposal-1" }],
]);
});
it("preserves capped support-file size formatting through the shared helper", async () => {
const { state, context, request } = createFixture();
const baseInspect = inspectResult();
@@ -217,7 +249,7 @@ describe("Skill Workshop proposal RPCs", () => {
it("inspects a selected proposal with the agent from the current session", async () => {
const { state, context, request } = createFixture(
{ skillWorkshopProposals: [proposal({ body: "" })] },
{ skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })] },
{ sessionKey: "agent:ops-team:main" },
["skills.proposals.inspect"],
);
@@ -363,7 +395,7 @@ describe("Skill Workshop proposal RPCs", () => {
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [proposal({ body: "" })],
skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })],
},
{},
["skills.proposals.inspect", "skills.proposals.evaluate"],
@@ -385,7 +417,7 @@ describe("Skill Workshop proposal RPCs", () => {
it("drops an inspected evaluation that belongs to a different revision", async () => {
const baseInspect = inspectResult();
const { state, context, request } = createFixture(
{ skillWorkshopProposals: [proposal({ body: "" })] },
{ skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })] },
{},
["skills.proposals.inspect"],
);
@@ -535,7 +567,7 @@ describe("Skill Workshop proposal RPCs", () => {
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [proposal({ body: "" })],
skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })],
},
{},
["skills.proposals.inspect"],
@@ -584,7 +616,7 @@ describe("Skill Workshop proposal RPCs", () => {
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [proposal({ body: "" })],
skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })],
skillWorkshopRevisionDraft: "Tighten the trigger.",
},
{},
@@ -611,7 +643,7 @@ describe("Skill Workshop proposal RPCs", () => {
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [proposal({ body: "" })],
skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })],
skillWorkshopRevisionDraft: "Tighten the trigger.",
},
{},
@@ -634,4 +666,62 @@ describe("Skill Workshop proposal RPCs", () => {
await expect(revision).resolves.toBe(false);
expect(sendRevisionRequest).not.toHaveBeenCalled();
});
it("ignores a superseded selection and keeps its error out of the pane", async () => {
const first = createDeferred<ReturnType<typeof inspectResult>>();
const second = createDeferred<ReturnType<typeof inspectResult>>();
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [
proposal({ key: "proposal-1", body: "", bodyLoaded: false }),
proposal({ key: "proposal-2", body: "", bodyLoaded: false }),
],
},
{},
["skills.proposals.inspect"],
);
request.mockImplementation(async (_method, payload) =>
(payload as { proposalId: string }).proposalId === "proposal-1"
? first.promise
: second.promise,
);
const stale = selectSkillWorkshopProposal(state, context, "proposal-1");
const latest = selectSkillWorkshopProposal(state, context, "proposal-2");
const base = inspectResult();
second.resolve({ ...base, record: { ...base.record, id: "proposal-2" } });
await latest;
first.reject(new Error("inspect failed"));
await stale;
expect(state.skillWorkshopSelectedKey).toBe("proposal-2");
expect(state.skillWorkshopError).toBeNull();
});
it("inspects a revision once even when its body is legitimately empty", async () => {
const { state, context, request } = createFixture(
{
skillWorkshopAgentId: "research",
skillWorkshopProposals: [proposal({ body: "", bodyLoaded: false })],
},
{},
["skills.proposals.inspect"],
);
const base = inspectResult();
request.mockResolvedValue({ ...base, content: "" });
await Promise.all([
selectSkillWorkshopProposal(state, context, "proposal-1"),
selectSkillWorkshopProposal(state, context, "proposal-1"),
]);
await selectSkillWorkshopProposal(state, context, "proposal-1");
expect(state.skillWorkshopProposals[0]?.body).toBe("");
expect(state.skillWorkshopProposals[0]?.bodyLoaded).toBe(true);
expect(
request.mock.calls.filter(([method]) => method === "skills.proposals.inspect"),
).toHaveLength(1);
expect(state.skillWorkshopSelectedKey).toBe("proposal-1");
});
});
+123 -266
View File
@@ -1,9 +1,7 @@
// Control UI controller manages skill workshop gateway state.
import { parseDateStringTimestampMs } from "@openclaw/normalization-core/number-coercion";
import type { AgentSelectionCapability } from "../../app/agent-selection.ts";
import type { ApplicationGateway } from "../../app/context.ts";
import { t } from "../../i18n/index.ts";
import { formatBytes } from "../../lib/agents/display.ts";
import { formatUiError } from "../../lib/format-error.ts";
import { canCallGatewayMethod } from "../../lib/gateway-methods.ts";
import {
@@ -11,12 +9,21 @@ import {
parseAgentSessionKey,
resolveUiSelectedGlobalAgentId,
} from "../../lib/sessions/session-key.ts";
import type {
SkillWorkshopAction,
SkillWorkshopEvaluation,
SkillWorkshopProposal,
SkillWorkshopProposalStatus,
import {
findSkillWorkshopAppliedPredecessor,
type SkillWorkshopAction,
type SkillWorkshopProposal,
type SkillWorkshopProposalStatus,
} from "../../lib/skill-workshop/index.ts";
import {
parseDateMs,
proposalFromEvaluation,
proposalFromInspect,
proposalFromManifest,
type SkillProposalEvaluateResult,
type SkillProposalInspectResult,
type SkillProposalManifest,
} from "./proposal-records.ts";
import { createSkillWorkshopHistoryScanState, type SkillWorkshopState } from "./state.ts";
export {
createSkillWorkshopState,
@@ -27,77 +34,6 @@ export {
const SKILL_WORKSHOP_NOTICE_MS = 2800;
type SkillProposalStatus = SkillWorkshopProposalStatus;
type SkillProposalKind = SkillWorkshopProposal["kind"];
type SkillProposalScanState = "pending" | "clean" | "failed" | "quarantined";
type SkillProposalManifestEntry = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
skillName: string;
skillKey: string;
createdAt: string;
updatedAt: string;
scanState: SkillProposalScanState;
};
type SkillProposalManifest = {
schema: "openclaw.skill-workshop.proposals-manifest.v1";
updatedAt: string;
proposals: SkillProposalManifestEntry[];
};
type SkillProposalSupportFileRecord = {
path: string;
sizeBytes: number;
};
type SkillProposalOrigin = {
agentId?: string;
sessionKey?: string;
runId?: string;
messageId?: string;
};
type SkillProposalRecord = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
createdAt: string;
updatedAt: string;
proposedVersion: string;
draftHash: string;
evaluation?: SkillWorkshopEvaluation;
origin?: SkillProposalOrigin;
supportFiles?: SkillProposalSupportFileRecord[];
target: {
skillName: string;
skillKey: string;
};
};
type SkillProposalSupportFile = {
path: string;
content: string;
};
type SkillProposalInspectResult = {
record: SkillProposalRecord;
revisionHash?: string;
content: string;
supportFiles?: SkillProposalSupportFile[];
};
type SkillProposalEvaluateResult = {
record: SkillProposalRecord;
evaluation: SkillWorkshopEvaluation;
};
export type SkillWorkshopContext = {
gateway: ApplicationGateway;
agentSelection: Pick<AgentSelectionCapability, "state">;
@@ -139,170 +75,10 @@ function resetSkillWorkshopAgentScope(state: SkillWorkshopState, agentId: string
state.skillWorkshopRevisionDraft = "";
state.skillWorkshopFilePreviewKey = null;
state.skillWorkshopFilePreviewQuery = "";
state.skillWorkshopAppliedDiffMode = "changes";
state.skillWorkshopHistoryScan = createSkillWorkshopHistoryScanState();
}
function parseDateMs(value: string | undefined): number {
return parseDateStringTimestampMs(value) ?? Date.now();
}
function startOfLocalDay(ms: number): number {
const date = new Date(ms);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function recencyGroup(ms: number): SkillWorkshopProposal["recencyGroup"] {
const today = startOfLocalDay(Date.now());
const day = startOfLocalDay(ms);
if (day === today) {
return "today";
}
if (day === today - 24 * 60 * 60 * 1000) {
return "yesterday";
}
return "earlier";
}
function compactAgeLabel(ms: number): string {
const diff = Math.max(0, Date.now() - ms);
const min = Math.floor(diff / 60_000);
if (min < 1) {
return "now";
}
if (min < 60) {
return `${min}m`;
}
const hr = Math.floor(min / 60);
if (hr < 24) {
return `${hr}h`;
}
const day = Math.floor(hr / 24);
return `${day}d`;
}
function proposedVersionNumber(value: string | undefined): number {
const parsed = Number.parseInt((value ?? "").replace(/^v/i, ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
function stripProposalFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function supportFilesFromInspect(
result: SkillProposalInspectResult,
): SkillWorkshopProposal["supportFiles"] {
const sizes = new Map(
(result.record.supportFiles ?? []).map((file) => [file.path, file.sizeBytes]),
);
return (result.supportFiles ?? []).map((file) => ({
path: file.path,
size: formatBytes(Math.max(0, sizes.get(file.path) ?? byteLength(file.content)), {
fallback: "0 B",
maxUnit: "kilo",
fractionDigits: (_value, unit) => (unit === "byte" ? null : 1),
}),
contents: file.content,
}));
}
function proposalFromManifest(
entry: SkillProposalManifestEntry,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const updatedAt = parseDateMs(entry.updatedAt);
const createdAt = parseDateMs(entry.createdAt);
const previousIsCurrent = previous?.updatedAt === updatedAt;
return {
key: entry.id,
kind: entry.kind,
slug: entry.skillKey,
name: entry.title || entry.skillName,
oneLine: entry.description,
body: previousIsCurrent ? previous.body : "",
status: entry.status,
...(previousIsCurrent && previous.origin ? { origin: previous.origin } : {}),
version: previousIsCurrent ? previous.version : 1,
revisionHash: previousIsCurrent ? previous.revisionHash : null,
...(previousIsCurrent && previous.evaluation ? { evaluation: previous.evaluation } : {}),
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: previousIsCurrent ? previous.supportFiles : [],
isNew: previous?.isNew ?? false,
};
}
function proposalFromInspect(
result: SkillProposalInspectResult,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const record = result.record;
const updatedAt = parseDateMs(record.updatedAt);
const createdAt = parseDateMs(record.createdAt);
const revisionHash = result.revisionHash?.trim() || null;
const evaluation =
record.evaluation?.revisionHash === revisionHash
? record.evaluation
: previous?.evaluation?.revisionHash === revisionHash
? previous.evaluation
: undefined;
return {
key: record.id,
kind: record.kind,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
body: stripProposalFrontmatter(result.content),
status: record.status,
...(record.origin ? { origin: record.origin } : {}),
version: proposedVersionNumber(record.proposedVersion),
revisionHash,
...(evaluation ? { evaluation } : {}),
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: supportFilesFromInspect(result),
isNew: previous?.isNew ?? false,
};
}
function proposalFromEvaluation(
result: SkillProposalEvaluateResult,
previous: SkillWorkshopProposal,
): SkillWorkshopProposal {
const record = result.record;
const updatedAt = parseDateMs(record.updatedAt);
const createdAt = parseDateMs(record.createdAt);
return {
key: record.id,
kind: record.kind,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
body: previous.body,
status: record.status,
...(record.origin
? { origin: record.origin }
: previous.origin
? { origin: previous.origin }
: {}),
version: proposedVersionNumber(record.proposedVersion),
revisionHash: result.evaluation.revisionHash,
evaluation: result.evaluation,
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: previous.supportFiles,
isNew: previous.isNew,
};
inspectRequestsByState.delete(state);
selectionRequestByState.delete(state);
}
function mergeProposal(state: SkillWorkshopState, proposal: SkillWorkshopProposal): void {
@@ -350,7 +126,7 @@ function showActionNotice(
export function countSkillWorkshopProposals(
proposals: SkillWorkshopProposal[],
): Record<"all" | SkillProposalStatus, number> {
): Record<"all" | SkillWorkshopProposalStatus, number> {
// Applied renders one row per skill, so its tab count is grouped skills;
// every other status stays a per-proposal count.
const appliedSkills = new Set<string>();
@@ -409,9 +185,24 @@ export async function loadSkillWorkshopProposals(
state.skillWorkshopLoaded = true;
if (!proposals.some((proposal) => proposal.key === state.skillWorkshopSelectedKey)) {
state.skillWorkshopSelectedKey = proposals[0]?.key ?? null;
// Only a refresh that actually reassigns the pane owns the selection
// fence; otherwise a background reload would silence an in-flight click.
if (state.skillWorkshopSelectedKey) {
markSkillWorkshopSelectionRequest(state, state.skillWorkshopSelectedKey);
}
}
if (state.skillWorkshopSelectedKey) {
await loadSkillWorkshopProposalDetail(state, context, state.skillWorkshopSelectedKey);
const selectedKey = state.skillWorkshopSelectedKey;
if (selectedKey) {
// Route data retains the selection but not its ephemeral request fence.
if (!selectionRequestByState.has(state)) {
markSkillWorkshopSelectionRequest(state, selectedKey);
}
const selectedLoaded = await loadSkillWorkshopProposalDetail(state, context, selectedKey);
if (selectedLoaded) {
// The Applied tab can be opened without a fresh click, so the predecessor
// has to be warmed here too or the diff never has a baseline.
await loadSkillWorkshopPredecessorBody(state, context, selectedKey);
}
}
} catch (err) {
state.skillWorkshopError = formatUiError(err);
@@ -423,25 +214,40 @@ export async function loadSkillWorkshopProposals(
}
}
async function loadSkillWorkshopProposalDetail(
type SkillWorkshopGatewayClient = NonNullable<ApplicationGateway["snapshot"]["client"]>;
// Rapid history clicks overlap: each inspect awaits the Gateway, so a slower
// earlier request must neither re-issue the same call nor publish its selection
// or error after a newer click won the pane. Both fences are keyed on the live
// state object so nothing reaches the persisted route data.
const inspectRequestsByState = new WeakMap<SkillWorkshopState, Map<string, Promise<boolean>>>();
const selectionRequestByState = new WeakMap<SkillWorkshopState, string>();
function inspectRequests(state: SkillWorkshopState): Map<string, Promise<boolean>> {
const existing = inspectRequestsByState.get(state);
if (existing) {
return existing;
}
const requests = new Map<string, Promise<boolean>>();
inspectRequestsByState.set(state, requests);
return requests;
}
function markSkillWorkshopSelectionRequest(state: SkillWorkshopState, proposalId: string): void {
selectionRequestByState.set(state, proposalId);
}
function isLatestSkillWorkshopSelection(state: SkillWorkshopState, proposalId: string): boolean {
return selectionRequestByState.get(state) === proposalId;
}
async function inspectSkillWorkshopProposal(
state: SkillWorkshopState,
context: SkillWorkshopContext,
client: SkillWorkshopGatewayClient,
proposalId: string,
options?: { force?: boolean },
existing: SkillWorkshopProposal | undefined,
): Promise<boolean> {
const snapshot = context.gateway.snapshot;
const client = snapshot.client;
if (
!client ||
snapshot.phase !== "connected" ||
state.skillWorkshopInspectingKey === proposalId
) {
return false;
}
const existing = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
if (existing?.body && !options?.force) {
return true;
}
const requestAgentId = loadedSkillWorkshopAgentParams(state, context).agentId;
if (state.skillWorkshopAgentId === null) {
state.skillWorkshopAgentId = requestAgentId;
@@ -454,16 +260,18 @@ async function loadSkillWorkshopProposalDetail(
"skills.proposals.inspect",
requestParams,
);
if (
state.skillWorkshopAgentId !== requestAgentId ||
state.skillWorkshopInspectingKey !== proposalId
) {
if (state.skillWorkshopAgentId !== requestAgentId) {
return false;
}
mergeProposal(state, proposalFromInspect(result, existing));
return true;
} catch (err) {
if (state.skillWorkshopAgentId === requestAgentId) {
// Only the revision the operator is waiting on may publish an error; a
// superseded click or a background predecessor fetch stays quiet.
if (
state.skillWorkshopAgentId === requestAgentId &&
isLatestSkillWorkshopSelection(state, proposalId)
) {
state.skillWorkshopError = formatUiError(err);
}
return false;
@@ -477,19 +285,68 @@ async function loadSkillWorkshopProposalDetail(
}
}
function loadSkillWorkshopProposalDetail(
state: SkillWorkshopState,
context: SkillWorkshopContext,
proposalId: string,
options?: { force?: boolean },
): Promise<boolean> {
const snapshot = context.gateway.snapshot;
const client = snapshot.client;
if (!client || snapshot.phase !== "connected") {
return Promise.resolve(false);
}
const existing = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
if (existing?.bodyLoaded && !options?.force) {
return Promise.resolve(true);
}
const requests = inspectRequests(state);
const inFlight = requests.get(proposalId);
if (inFlight) {
return inFlight;
}
const request = inspectSkillWorkshopProposal(
state,
context,
client,
proposalId,
existing,
).finally(() => {
if (requests.get(proposalId) === request) {
requests.delete(proposalId);
}
});
requests.set(proposalId, request);
return request;
}
function loadSkillWorkshopPredecessorBody(
state: SkillWorkshopState,
context: SkillWorkshopContext,
proposalId: string,
): Promise<boolean> {
const previous = findSkillWorkshopAppliedPredecessor(state.skillWorkshopProposals, proposalId);
return previous && !previous.bodyLoaded
? loadSkillWorkshopProposalDetail(state, context, previous.key)
: Promise.resolve(false);
}
export async function selectSkillWorkshopProposal(
state: SkillWorkshopState,
context: SkillWorkshopContext,
proposalId: string,
): Promise<void> {
markSkillWorkshopSelectionRequest(state, proposalId);
const current = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
if (!current?.body) {
if (!current?.bodyLoaded) {
const loaded = await loadSkillWorkshopProposalDetail(state, context, proposalId);
if (!loaded) {
if (!loaded || !isLatestSkillWorkshopSelection(state, proposalId)) {
return;
}
}
state.skillWorkshopSelectedKey = proposalId;
state.skillWorkshopAppliedDiffMode = "changes";
await loadSkillWorkshopPredecessorBody(state, context, proposalId);
}
async function refreshAfterMutation(
@@ -60,75 +60,98 @@ function createContext(request: ReturnType<typeof vi.fn>): ApplicationContext {
} as unknown as ApplicationContext;
}
function appliedProposals(): SkillWorkshopProposal[] {
return [1, 2, 3, 4].map((updatedAt) => ({
key: `proposal-${updatedAt}`,
kind: updatedAt === 2 ? "create" : "update",
slug: "release-sanity",
name: `${updatedAt === 1 ? "Create" : "Update"} release-sanity`,
oneLine: `Revision ${updatedAt} description`,
body: updatedAt === 1 ? "" : `## Workflow\n- Revision ${updatedAt}`,
status: "applied",
version: 1,
revisionHash: null,
createdAt: updatedAt,
updatedAt,
recencyGroup: "today",
ageLabel: `${updatedAt}h`,
supportFiles: [],
bodyLoaded: updatedAt !== 1,
isNew: false,
}));
}
function inspectRequest() {
return vi.fn(async (method: string, params?: unknown) => {
if (method !== "skills.proposals.inspect") {
return {};
}
expect(params).toEqual({ agentId: "research", proposalId: "proposal-1" });
return {
record: {
id: "proposal-1",
kind: "update",
status: "applied",
title: "Create release-sanity",
description: "Revision 1 description",
createdAt: new Date(1).toISOString(),
updatedAt: new Date(1).toISOString(),
proposedVersion: "v1",
draftHash: "a".repeat(64),
target: { skillName: "Release sanity", skillKey: "release-sanity" },
},
revisionHash: "b".repeat(64),
content: "## Workflow\n- Revision 1 inspected",
supportFiles: [],
};
});
}
async function mountAppliedPage(
request: ReturnType<typeof inspectRequest>,
proposals: SkillWorkshopProposal[],
): Promise<SkillWorkshopPageTestElement> {
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
loadedState.skillWorkshopProposals = proposals;
loadedState.skillWorkshopSelectedKey = "proposal-4";
// SAFETY: the registered custom element exposes the tested reactive page fields.
const page = document.createElement(
"openclaw-skill-workshop-page",
) as SkillWorkshopPageTestElement;
page.data = skillWorkshopRouteData(loadedState);
page.context = createContext(request);
document.body.append(page);
await page.updateComplete;
if (!page.state) {
throw new Error("Expected Skill Workshop state");
}
page.state.skillWorkshopMode = "board";
page.state.skillWorkshopStatusFilter = "applied";
page.requestUpdate();
await page.updateComplete;
return page;
}
function historyItems(page: SkillWorkshopPageTestElement) {
return page.querySelectorAll<HTMLButtonElement>(".sw-applied-history__item");
}
function modeButton(page: SkillWorkshopPageTestElement, label: string) {
return [...page.querySelectorAll<HTMLButtonElement>(".sw-body-mode__button")].find(
(button) => button.textContent?.trim() === label,
);
}
afterEach(() => {
document.body.replaceChildren();
});
describe("Skill Workshop applied history", () => {
it("renders one skill row and inspects a selected revision", async () => {
const timestamps = [1, 2, 3, 4];
const proposals = timestamps.map(
(updatedAt): SkillWorkshopProposal => ({
key: `proposal-${updatedAt}`,
kind: updatedAt === 2 ? "create" : "update",
slug: "release-sanity",
name: `${updatedAt === 1 ? "Create" : "Update"} release-sanity`,
oneLine: `Revision ${updatedAt} description`,
body: updatedAt === 1 ? "" : `## Workflow\n- Revision ${updatedAt}`,
status: "applied",
version: 1,
revisionHash: null,
createdAt: updatedAt,
updatedAt,
recencyGroup: "today",
ageLabel: `${updatedAt}h`,
supportFiles: [],
isNew: false,
}),
);
const request = vi.fn(async (method: string, params?: unknown) => {
if (method !== "skills.proposals.inspect") {
return {};
}
expect(params).toEqual({ agentId: "research", proposalId: "proposal-1" });
return {
record: {
id: "proposal-1",
kind: "update",
status: "applied",
title: "Create release-sanity",
description: "Revision 1 description",
createdAt: new Date(1).toISOString(),
updatedAt: new Date(1).toISOString(),
proposedVersion: "v1",
draftHash: "a".repeat(64),
target: { skillName: "Release sanity", skillKey: "release-sanity" },
},
revisionHash: "b".repeat(64),
content: "## Workflow\n- Revision 1 inspected",
supportFiles: [],
};
});
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
loadedState.skillWorkshopProposals = proposals;
loadedState.skillWorkshopSelectedKey = "proposal-4";
// SAFETY: the registered custom element exposes the tested reactive page fields.
const page = document.createElement(
"openclaw-skill-workshop-page",
) as SkillWorkshopPageTestElement;
page.data = skillWorkshopRouteData(loadedState);
page.context = createContext(request);
document.body.append(page);
await page.updateComplete;
if (!page.state) {
throw new Error("Expected Skill Workshop state");
}
page.state.skillWorkshopMode = "board";
page.state.skillWorkshopStatusFilter = "applied";
page.requestUpdate();
await page.updateComplete;
const request = inspectRequest();
const page = await mountAppliedPage(request, appliedProposals());
expect(page.querySelectorAll(".sw-row")).toHaveLength(1);
expect(page.querySelector(".sw-row")?.textContent).toContain("4 revisions");
@@ -137,11 +160,8 @@ describe("Skill Workshop applied history", () => {
button.textContent?.includes("Applied"),
);
expect(appliedFilter?.querySelector(".settings-count")?.textContent).toBe("1");
await vi.waitFor(
() => expect(page.querySelectorAll(".sw-applied-history__item")).toHaveLength(4),
{ interval: 1 },
);
const history = page.querySelectorAll<HTMLButtonElement>(".sw-applied-history__item");
await vi.waitFor(() => expect(historyItems(page)).toHaveLength(4), { interval: 1 });
const history = historyItems(page);
expect(history[0]?.textContent).toContain("Update");
expect(history[0]?.textContent).toContain("v4");
expect(history[2]?.textContent).toContain("Create");
@@ -163,4 +183,69 @@ describe("Skill Workshop applied history", () => {
{ interval: 1 },
);
});
it("diffs a revision against its predecessor and toggles back to the full body", async () => {
const page = await mountAppliedPage(inspectRequest(), appliedProposals());
await vi.waitFor(() => expect(historyItems(page)).toHaveLength(4), { interval: 1 });
await vi.waitFor(() => expect(page.querySelector(".sw-diff")).not.toBeNull(), { interval: 1 });
const added = [...page.querySelectorAll(".sw-diff__row--add")].map((row) => row.textContent);
const removed = [...page.querySelectorAll(".sw-diff__row--del")].map((row) => row.textContent);
expect(added.join("")).toContain("Revision 4");
expect(removed.join("")).toContain("Revision 3");
expect(page.querySelector(".sw-diff__stat")?.textContent?.replace(/\s+/gu, "")).toBe("+1-1");
modeButton(page, "Full body")?.click();
await vi.waitFor(
() => {
expect(page.querySelector(".sw-diff")).toBeNull();
expect(page.querySelector(".sw-body-card")?.textContent).toContain("Revision 4");
},
{ interval: 1 },
);
expect(modeButton(page, "Full body")?.getAttribute("aria-pressed")).toBe("true");
expect(modeButton(page, "Changes")?.getAttribute("aria-pressed")).toBe("false");
});
it("shows the oldest revision as a full body with no diff toggle", async () => {
const page = await mountAppliedPage(inspectRequest(), appliedProposals());
await vi.waitFor(() => expect(historyItems(page)).toHaveLength(4), { interval: 1 });
historyItems(page)[3]?.click();
await vi.waitFor(
() => {
expect(page.querySelector(".sw-detail__body")?.textContent).toContain(
"Revision 1 inspected",
);
expect(page.querySelector(".sw-body-mode")).toBeNull();
expect(page.querySelector(".sw-diff")).toBeNull();
},
{ interval: 1 },
);
});
it("bounds large comparisons and keeps the full body available", async () => {
const largeBody = "x".repeat(60_001);
const proposals = appliedProposals();
for (const proposal of proposals.slice(2)) {
proposal.body = largeBody;
}
const page = await mountAppliedPage(inspectRequest(), proposals);
await vi.waitFor(
() => {
expect(page.querySelector(".sw-diff")).toBeNull();
expect(page.querySelector(".sw-body-card")?.textContent).toContain(
"This comparison is too large to show here.",
);
},
{ interval: 1 },
);
modeButton(page, "Full body")?.click();
await vi.waitFor(
() => expect(page.querySelector(".sw-body-card")?.textContent).toContain(largeBody),
{ interval: 1 },
);
});
});
@@ -127,6 +127,7 @@ function createProposal(overrides: Partial<SkillWorkshopProposal>): SkillWorksho
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
bodyLoaded: true,
isNew: false,
...overrides,
};
@@ -8,7 +8,10 @@ import "../../components/tooltip.ts";
import { t } from "../../i18n/index.ts";
import { sessionNavigationTarget } from "../../lib/sessions/route-navigation.ts";
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
import { filterSkillWorkshopProposals } from "../../lib/skill-workshop/index.ts";
import {
filterSkillWorkshopProposals,
type SkillWorkshopAppliedDiffMode,
} from "../../lib/skill-workshop/index.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { SubscriptionsController } from "../../lit/subscriptions-controller.ts";
import { PLUGINS_HUB_PANEL_ID, pluginsHubTabs } from "../plugins/plugins-hub.ts";
@@ -145,6 +148,7 @@ function renderSkillWorkshopPage(
inspectingKey: state.skillWorkshopInspectingKey,
proposals: state.skillWorkshopProposals,
selectedKey: state.skillWorkshopSelectedKey,
appliedDiffMode: state.skillWorkshopAppliedDiffMode,
statusFilter: state.skillWorkshopStatusFilter,
query: state.skillWorkshopQuery,
filePreviewKey: state.skillWorkshopFilePreviewKey,
@@ -195,6 +199,10 @@ function renderSkillWorkshopPage(
},
onModeChange: (mode) => setSkillWorkshopMode(state, mode, requestUpdate),
onSelect: selectProposal,
onAppliedDiffModeChange: (mode: SkillWorkshopAppliedDiffMode) => {
state.skillWorkshopAppliedDiffMode = mode;
requestUpdate();
},
onPrev: () => selectRelativeProposal(-1),
onNext: () => selectRelativeProposal(1),
onApply: (key) => {
+3
View File
@@ -1,6 +1,7 @@
import type {
SkillWorkshopAction,
SkillWorkshopActionNotice,
SkillWorkshopAppliedDiffMode,
SkillWorkshopMode,
SkillWorkshopProposal,
SkillWorkshopStatusFilter,
@@ -45,6 +46,7 @@ export type SkillWorkshopState = {
skillWorkshopInspectingKey: string | null;
skillWorkshopProposals: SkillWorkshopProposal[];
skillWorkshopSelectedKey: string | null;
skillWorkshopAppliedDiffMode: SkillWorkshopAppliedDiffMode;
skillWorkshopActionBusy: { key: string; action: SkillWorkshopAction } | null;
skillWorkshopActionNotice: SkillWorkshopActionNotice | null;
skillWorkshopActionNoticeTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
@@ -85,6 +87,7 @@ export function createSkillWorkshopState(data?: SkillWorkshopRouteData): SkillWo
skillWorkshopInspectingKey: data?.skillWorkshopInspectingKey ?? null,
skillWorkshopProposals: data?.skillWorkshopProposals ?? [],
skillWorkshopSelectedKey: data?.skillWorkshopSelectedKey ?? null,
skillWorkshopAppliedDiffMode: "changes",
skillWorkshopActionBusy: data?.skillWorkshopActionBusy ?? null,
skillWorkshopActionNotice: data?.skillWorkshopActionNotice ?? null,
skillWorkshopActionNoticeTimer: null,
@@ -1,6 +1,7 @@
import type {
SkillWorkshopActionBusy,
SkillWorkshopActionNotice,
SkillWorkshopAppliedDiffMode,
SkillWorkshopMode,
SkillWorkshopProposal,
SkillWorkshopStatusFilter,
@@ -16,6 +17,7 @@ export type SkillWorkshopProps = {
inspectingKey: string | null;
proposals: SkillWorkshopProposal[];
selectedKey: string | null;
appliedDiffMode: SkillWorkshopAppliedDiffMode;
statusFilter: SkillWorkshopStatusFilter;
query: string;
filePreviewKey: string | null;
@@ -38,6 +40,7 @@ export type SkillWorkshopProps = {
onQueueWidthChange: (width: number) => void;
onModeChange: (mode: SkillWorkshopMode) => void;
onSelect: (key: string) => void;
onAppliedDiffModeChange: (mode: SkillWorkshopAppliedDiffMode) => void;
onPrev: () => void;
onNext: () => void;
onApply: (key: string) => void;
+65 -4
View File
@@ -14,6 +14,7 @@ import "../../styles/skill-workshop.css";
import {
filterSkillWorkshopProposals,
type SkillWorkshopActionNotice,
type SkillWorkshopAppliedDiffMode,
type SkillWorkshopAppliedSkill,
type SkillWorkshopEvaluation,
type SkillWorkshopEvaluationFinding,
@@ -21,7 +22,13 @@ import {
type SkillWorkshopProposal,
type SkillWorkshopStatusFilter,
} from "../../lib/skill-workshop/index.ts";
import { renderLazyAppliedHistory, resolveAppliedHistory } from "./applied-history.ts";
import {
renderLazyAppliedHistory,
renderLazyAppliedRevisionDiff,
resolveAppliedBodyView,
resolveAppliedHistory,
type SkillWorkshopBodyView,
} from "./applied-history.ts";
import { renderBoardEmptyDetail, renderWorkshopEmptyState } from "./empty-states.ts";
import { renderSkillWorkshopHistoryScan } from "./history-scan.ts";
import { renderSkillWorkshopProposalList } from "./proposal-list.ts";
@@ -309,6 +316,53 @@ function renderLifecycleTabs(props: SkillWorkshopProps) {
`;
}
function renderBodyModeButton(
props: SkillWorkshopProps,
mode: SkillWorkshopAppliedDiffMode,
label: string,
) {
const active = props.appliedDiffMode === mode;
return html`
<button
class="sw-body-mode__button ${active ? "is-active" : ""}"
aria-pressed=${active ? "true" : "false"}
@click=${() => props.onAppliedDiffModeChange(mode)}
>
${label}
</button>
`;
}
function renderBodyModeToggle(props: SkillWorkshopProps) {
return html`
<div class="sw-body-mode" role="group" aria-label=${t("skillWorkshop.diff.viewLabel")}>
${renderBodyModeButton(props, "changes", t("skillWorkshop.diff.changes"))}
${renderBodyModeButton(props, "full", t("skillWorkshop.diff.fullBody"))}
</div>
`;
}
function renderRevisionBody(view: SkillWorkshopBodyView, proposal: SkillWorkshopProposal) {
if (view.kind === "diff") {
return renderLazyAppliedRevisionDiff(view.previous.body, proposal.body);
}
if (view.kind === "loadingPrevious") {
return html`<p class="sw-muted" aria-busy="true">
${t("skillWorkshop.diff.loadingPrevious")}
</p>`;
}
if (view.kind === "previousUnavailable") {
return html`
<p class="sw-muted">${t("skillWorkshop.diff.previousUnavailable")}</p>
${renderProposalBody(proposal.body)}
`;
}
if (view.kind === "tooLarge") {
return html`<p class="sw-muted">${t("skillWorkshop.diff.tooLarge")}</p>`;
}
return renderProposalBody(proposal.body);
}
function renderDetail(
props: SkillWorkshopProps,
proposal: SkillWorkshopProposal,
@@ -319,8 +373,12 @@ function renderDetail(
const createdLabel = editedAt
? t("skillWorkshop.detail.edited", { time: formatRelative(editedAt) })
: t("skillWorkshop.detail.created", { time: formatRelative(proposal.createdAt) });
const detailLoading = props.inspectingKey === proposal.key && !proposal.body;
const detailLoading = props.inspectingKey === proposal.key && !proposal.bodyLoaded;
const firstSupportFile = proposal.supportFiles[0];
const previousRevision =
appliedSkill?.revisions.find(({ proposal: revision }) => revision.key === proposal.key)
?.previous ?? null;
const bodyView = resolveAppliedBodyView(props, proposal, previousRevision);
return html`
<div class="sw-detail">
@@ -359,10 +417,13 @@ function renderDetail(
<div class="sw-detail__body">
<div class="sw-body-card">
<h1>${proposal.slug}</h1>
<div class="sw-body-card__head">
<h1>${proposal.slug}</h1>
${previousRevision ? renderBodyModeToggle(props) : nothing}
</div>
${detailLoading
? html`<p class="sw-muted">${t("skillWorkshop.detail.loading")}</p>`
: renderProposalBody(proposal.body)}
: renderRevisionBody(bodyView, proposal)}
</div>
${appliedSkill ? renderLazyAppliedHistory(props, appliedSkill) : nothing}
+103 -1
View File
@@ -603,8 +603,110 @@
line-height: 1.65;
}
.sw-body-card__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.sw-body-mode {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.sw-body-mode__button {
border: 0;
background: transparent;
color: var(--muted);
font-size: 11.5px;
font-weight: 600;
padding: 4px 10px;
cursor: var(--cursor-action);
}
.sw-body-mode__button:hover {
background: var(--bg-hover);
color: var(--text);
}
.sw-body-mode__button.is-active {
background: color-mix(in srgb, var(--accent) 12%, var(--bg) 88%);
color: var(--text-strong);
}
.sw-diff__stat {
display: flex;
gap: 10px;
margin: 0 0 8px;
font-family: var(--mono);
font-size: 11.5px;
font-weight: 600;
}
.sw-diff__stat-add {
color: var(--ok);
}
.sw-diff__stat-del {
color: var(--danger);
}
.sw-diff__rows {
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: auto;
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.55;
}
.sw-diff__row {
display: flex;
gap: 8px;
padding: 0 10px;
white-space: pre-wrap;
word-break: break-word;
}
.sw-diff__row--add {
background: var(--ok-subtle);
color: var(--text);
}
.sw-diff__row--del {
background: var(--danger-subtle);
color: var(--text);
}
.sw-diff__row--ctx {
color: var(--muted);
}
.sw-diff__row--skip {
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--muted);
padding: 2px 10px;
}
.sw-diff__sign {
flex: 0 0 auto;
width: 8px;
color: var(--muted);
}
.sw-diff__text {
flex: 1 1 auto;
}
.sw-body-card h1 {
margin: 0 0 14px;
margin: 0;
font-size: 20px;
color: var(--text-strong);
font-weight: 700;