fix(ui): group Skill Workshop applied revisions per skill with inline history (#125794)

The Applied view rendered every applied proposal as an independent recency-sorted row, so one skill's revision history looked like duplicate skills. Applied now shows one row per live skill with a revision-count badge and grouped tab count; the detail pane gains a lazily loaded History section with operations from the ledger's recorded kind. Pending/rejected/quarantined/stale keep one row per proposal.

Fixes #125632
This commit is contained in:
Ayaan Zaidi
2026-08-18 18:00:05 +05:30
committed by GitHub
parent 029f6c14cc
commit e8044fdffe
15 changed files with 687 additions and 148 deletions
+1 -1
View File
@@ -4297,7 +4297,7 @@ ui/src/pages/skill-workshop/header-controls.ts 1
ui/src/pages/skill-workshop/route.ts 1
ui/src/pages/skill-workshop/self-learning.ts 1
ui/src/pages/skill-workshop/skill-workshop-page.ts 1
ui/src/pages/skill-workshop/view.ts 2
ui/src/pages/skill-workshop/view.ts 1
ui/src/pages/skills/view.ts 3
ui/src/pages/usage/heatmap.ts 1
ui/src/pages/usage/usage-page.ts 1
+8
View File
@@ -3494,6 +3494,14 @@ export const en: TranslationMap = {
yesterday: "Yesterday",
earlier: "Earlier this week",
},
applied: {
history: "History",
revision: "{count} revision",
revisions: "{count} revisions",
create: "Create",
update: "Update",
version: "v{version}",
},
previewContext: "in {slug}",
actions: {
close: "Close",
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
filterSkillWorkshopAppliedSkills,
filterSkillWorkshopProposals,
type SkillWorkshopProposal,
type SkillWorkshopProposalStatus,
} from "./index.ts";
function proposal(options: {
key: string;
kind?: SkillWorkshopProposal["kind"];
status?: SkillWorkshopProposalStatus;
slug?: string;
description?: string;
updatedAt?: number;
}): SkillWorkshopProposal {
const kind = options.kind ?? "update";
return {
key: options.key,
kind,
slug: options.slug ?? "release-sanity",
name: `${kind === "create" ? "Create" : "Update"} ${options.slug ?? "release-sanity"}`,
oneLine: options.description ?? `Description for ${options.key}`,
body: "## Workflow\n- Verify the release.",
status: options.status ?? "applied",
version: 1,
revisionHash: null,
createdAt: options.updatedAt ?? 1,
updatedAt: options.updatedAt,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
};
}
describe("Skill Workshop proposal filtering", () => {
it("keeps every revision in an update-only lineage labeled Update", () => {
const [skill] = filterSkillWorkshopAppliedSkills(
[proposal({ key: "new", updatedAt: 2 }), proposal({ key: "old", updatedAt: 1 })],
"",
);
expect(skill?.revisions.map(({ operation }) => operation)).toEqual(["update", "update"]);
});
it("groups applied revisions with deterministic order and recorded operations", () => {
const proposals = [
proposal({ key: "revision-a", updatedAt: 1 }),
proposal({ key: "revision-c", updatedAt: 3 }),
proposal({ key: "revision-b", kind: "create", updatedAt: 2 }),
proposal({ key: "revision-d", updatedAt: 3 }),
];
expect(filterSkillWorkshopProposals(proposals, "applied", "").map((item) => item.key)).toEqual([
"revision-d",
]);
const [skill] = filterSkillWorkshopAppliedSkills(proposals, "");
expect(
skill?.revisions.map(({ proposal: revisionProposal, operation, version }) => ({
key: revisionProposal.key,
operation,
version,
})),
).toEqual([
{ key: "revision-d", operation: "update", version: 4 },
{ key: "revision-c", operation: "update", version: 3 },
{ key: "revision-b", operation: "create", version: 2 },
{ key: "revision-a", operation: "update", version: 1 },
]);
});
it("searches every revision while returning the grouped skill row", () => {
const proposals = [
proposal({ key: "new", description: "Current release checks", updatedAt: 2 }),
proposal({ key: "old", description: "Legacy rollback phrase", updatedAt: 1 }),
];
expect(
filterSkillWorkshopProposals(proposals, "applied", "legacy rollback").map((item) => item.key),
).toEqual(["new"]);
});
it("keeps non-applied filters and the all view proposal-based", () => {
const proposals = [
proposal({ key: "pending", status: "pending" }),
proposal({ key: "rejected", status: "rejected" }),
proposal({ key: "quarantined", status: "quarantined" }),
proposal({ key: "stale", status: "stale" }),
proposal({ key: "applied-a" }),
proposal({ key: "applied-b" }),
];
for (const status of ["pending", "rejected", "quarantined", "stale"] as const) {
expect(filterSkillWorkshopProposals(proposals, status, "").map((item) => item.key)).toEqual([
status,
]);
}
expect(filterSkillWorkshopProposals(proposals, "all", "")).toEqual(proposals);
});
});
+71 -5
View File
@@ -52,6 +52,7 @@ export type SkillWorkshopEvaluation = {
export type SkillWorkshopProposal = {
key: string;
kind: "create" | "update";
slug: string;
name: string;
oneLine: string;
@@ -89,21 +90,86 @@ export type SkillWorkshopActionNotice = {
slug: string;
};
type SkillWorkshopAppliedRevision = {
proposal: SkillWorkshopProposal;
version: number;
operation: SkillWorkshopProposal["kind"];
};
export type SkillWorkshopAppliedSkill = {
slug: string;
latest: SkillWorkshopProposal;
revisions: SkillWorkshopAppliedRevision[];
};
function compareWorkshopProposals(
left: SkillWorkshopProposal,
right: SkillWorkshopProposal,
): number {
const timeDifference = (right.updatedAt ?? right.createdAt) - (left.updatedAt ?? left.createdAt);
if (timeDifference !== 0) {
return timeDifference;
}
if (left.key === right.key) {
return 0;
}
return left.key < right.key ? 1 : -1;
}
function matchesWorkshopQuery(proposal: SkillWorkshopProposal, query: string): boolean {
return `${proposal.name} ${proposal.oneLine} ${proposal.slug}`.toLowerCase().includes(query);
}
function groupSkillWorkshopAppliedSkills(
proposals: SkillWorkshopProposal[],
): SkillWorkshopAppliedSkill[] {
const revisionsBySlug = new Map<string, [SkillWorkshopProposal, ...SkillWorkshopProposal[]]>();
const applied = proposals
.filter((proposal) => proposal.status === "applied")
.toSorted(compareWorkshopProposals);
for (const proposal of applied) {
const revisions = revisionsBySlug.get(proposal.slug);
if (revisions) {
revisions.push(proposal);
} else {
revisionsBySlug.set(proposal.slug, [proposal]);
}
}
return Array.from(revisionsBySlug, ([slug, proposalsForSkill]) => ({
slug,
latest: proposalsForSkill[0],
revisions: proposalsForSkill.map((proposal, index) => {
const version = proposalsForSkill.length - index;
return { proposal, version, operation: proposal.kind };
}),
}));
}
export function filterSkillWorkshopAppliedSkills(
proposals: SkillWorkshopProposal[],
query: string,
): SkillWorkshopAppliedSkill[] {
const q = query.trim().toLowerCase();
return groupSkillWorkshopAppliedSkills(proposals).filter(
(skill) => !q || skill.revisions.some(({ proposal }) => matchesWorkshopQuery(proposal, q)),
);
}
export function filterSkillWorkshopProposals(
proposals: SkillWorkshopProposal[],
statusFilter: SkillWorkshopStatusFilter,
query: string,
): SkillWorkshopProposal[] {
const q = query.trim().toLowerCase();
if (statusFilter === "applied") {
return filterSkillWorkshopAppliedSkills(proposals, query).map((skill) => skill.latest);
}
return proposals.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) {
return false;
}
if (q) {
const hay = `${p.name} ${p.oneLine} ${p.slug}`.toLowerCase();
if (!hay.includes(q)) {
return false;
}
if (q && !matchesWorkshopQuery(p, q)) {
return false;
}
return true;
});
@@ -0,0 +1,32 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import type { SkillWorkshopAppliedSkill } from "../../lib/skill-workshop/index.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
export function renderAppliedHistory(props: SkillWorkshopProps, skill: SkillWorkshopAppliedSkill) {
return html`
<section class="sw-section sw-applied-history">
<h3 class="sw-section__label">${t("skillWorkshop.applied.history")}</h3>
<div class="sw-applied-history__list">
${skill.revisions.map(({ proposal, operation, version }) => {
const selected = proposal.key === props.selectedKey;
return html`
<button
class="sw-applied-history__item ${selected ? "is-selected" : ""}"
aria-current=${selected ? "true" : "false"}
@click=${() => props.onSelect(proposal.key)}
>
<span class="sw-applied-history__operation">
${t(`skillWorkshop.applied.${operation}`)}
</span>
<span class="sw-applied-history__age">${proposal.ageLabel}</span>
<span class="sw-applied-history__version">
${t("skillWorkshop.applied.version", { version: String(version) })}
</span>
</button>
`;
})}
</div>
</section>
`;
}
@@ -0,0 +1,49 @@
import { html } from "lit";
import { until } from "lit/directives/until.js";
import { t } from "../../i18n/index.ts";
import {
filterSkillWorkshopAppliedSkills,
type SkillWorkshopAppliedSkill,
type SkillWorkshopProposal,
} from "../../lib/skill-workshop/index.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
type AppliedHistoryRenderer = typeof import("./applied-history.runtime.ts").renderAppliedHistory;
let appliedHistoryRenderer: AppliedHistoryRenderer | undefined;
let appliedHistoryRuntime: Promise<AppliedHistoryRenderer> | undefined;
function loadAppliedHistoryRenderer(): Promise<AppliedHistoryRenderer> {
return (appliedHistoryRuntime ??= import("./applied-history.runtime.ts").then((runtime) => {
appliedHistoryRenderer = runtime.renderAppliedHistory;
return appliedHistoryRenderer;
}));
}
export function resolveAppliedHistory(
proposals: SkillWorkshopProposal[],
query: string,
selectedKey: string | null,
) {
const skills = filterSkillWorkshopAppliedSkills(proposals, query);
const selectedSkill =
skills.find((skill) => skill.revisions.some(({ proposal }) => proposal.key === selectedKey)) ??
skills[0];
const selectedProposal =
selectedSkill?.revisions.find(({ proposal }) => proposal.key === selectedKey)?.proposal ??
selectedSkill?.latest;
return { skills, selectedSkill, selectedProposal };
}
export function renderLazyAppliedHistory(
props: SkillWorkshopProps,
skill: SkillWorkshopAppliedSkill,
) {
if (appliedHistoryRenderer) {
return appliedHistoryRenderer(props, skill);
}
return until(
loadAppliedHistoryRenderer().then((renderer) => renderer(props, skill)),
html`<p class="sw-muted" aria-busy="true">${t("common.loading")}</p>`,
);
}
@@ -70,6 +70,7 @@ const evaluation: SkillWorkshopEvaluation = {
const proposal: SkillWorkshopProposal = {
key: "proposal-1",
kind: "update",
slug: "inbox-cleaner",
name: "Inbox Cleaner",
oneLine: "Clean inbox triage",
@@ -0,0 +1,91 @@
import { html } from "lit";
import { t } from "../../i18n/index.ts";
import type {
SkillWorkshopAppliedSkill,
SkillWorkshopProposal,
} from "../../lib/skill-workshop/index.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
export function renderSkillWorkshopProposalList(
props: SkillWorkshopProps,
groups: Array<{ label: string; items: SkillWorkshopProposal[] }>,
selected: SkillWorkshopProposal | undefined,
appliedSkills: SkillWorkshopAppliedSkill[],
emptyText: string,
) {
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
const appliedSkillsBySlug = new Map(appliedSkills.map((skill) => [skill.slug, skill]));
return html`
<aside class="sw-queue">
<div class="sw-queue__search">
<input
placeholder=${t("skillWorkshop.queue.search")}
.value=${props.query}
@input=${(event: Event) =>
// SAFETY: handler is bound on the <input> itself, so currentTarget is that element.
props.onQueryChange((event.currentTarget as HTMLInputElement).value ?? "")}
/>
</div>
<div class="sw-queue__body">
${total === 0
? html`<div class="sw-queue__empty">${emptyText}</div>`
: groups.map(
(group) => html`
<div class="sw-queue__group">
${t(group.label)}
<span class="settings-count">${group.items.length}</span>
</div>
${group.items.map((proposal) =>
renderProposalRow(
props,
proposal,
selected,
appliedSkillsBySlug.get(proposal.slug),
),
)}
`,
)}
</div>
</aside>
`;
}
function renderProposalRow(
props: SkillWorkshopProps,
proposal: SkillWorkshopProposal,
selected: SkillWorkshopProposal | undefined,
appliedSkill: SkillWorkshopAppliedSkill | undefined,
) {
const latest = appliedSkill?.latest ?? proposal;
const isSelected = appliedSkill
? appliedSkill.revisions.some(
({ proposal: revisionProposal }) => revisionProposal.key === props.selectedKey,
)
: selected?.key === proposal.key;
const revisionCountKey =
appliedSkill?.revisions.length === 1
? "skillWorkshop.applied.revision"
: "skillWorkshop.applied.revisions";
return html`
<button
class="sw-row ${latest.isNew ? "is-new" : "is-seen"} ${isSelected ? "is-selected" : ""}"
@click=${() => props.onSelect(latest.key)}
>
<span class="sw-row__dot"></span>
<span>
<span class="sw-row__title">${appliedSkill?.slug ?? proposal.name}</span>
<span class="sw-row__desc">${latest.oneLine}</span>
</span>
${appliedSkill
? html`
<span class="sw-row__meta sw-row__meta--applied">
<span class="sw-row__revision-count">
${t(revisionCountKey, { count: String(appliedSkill.revisions.length) })}
</span>
<span>${latest.ageLabel}</span>
</span>
`
: html`<span class="sw-row__meta">${proposal.ageLabel}</span>`}
</button>
`;
}
@@ -116,6 +116,7 @@ function inspectResult(status: SkillWorkshopProposal["status"] = "pending") {
function proposal(overrides: Partial<SkillWorkshopProposal> = {}): SkillWorkshopProposal {
return {
key: "proposal-1",
kind: "update",
slug: "inbox-cleaner",
name: "Inbox Cleaner",
oneLine: "Clean inbox triage",
@@ -185,6 +186,7 @@ describe("Skill Workshop proposal RPCs", () => {
agentId: "research",
proposalId: "proposal-1",
});
expect(state.skillWorkshopProposals[0]?.kind).toBe("create");
});
it("preserves capped support-file size formatting through the shared helper", async () => {
+18 -6
View File
@@ -28,7 +28,7 @@ export {
const SKILL_WORKSHOP_NOTICE_MS = 2800;
type SkillProposalStatus = SkillWorkshopProposalStatus;
type SkillProposalKind = "create" | "update";
type SkillProposalKind = SkillWorkshopProposal["kind"];
type SkillProposalScanState = "pending" | "clean" | "failed" | "quarantined";
type SkillProposalManifestEntry = {
@@ -219,6 +219,7 @@ function proposalFromManifest(
const previousIsCurrent = previous?.updatedAt === updatedAt;
return {
key: entry.id,
kind: entry.kind,
slug: entry.skillKey,
name: entry.title || entry.skillName,
oneLine: entry.description,
@@ -253,6 +254,7 @@ function proposalFromInspect(
: undefined;
return {
key: record.id,
kind: record.kind,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
@@ -280,6 +282,7 @@ function proposalFromEvaluation(
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,
@@ -348,14 +351,23 @@ function showActionNotice(
export function countSkillWorkshopProposals(
proposals: SkillWorkshopProposal[],
): Record<"all" | SkillProposalStatus, number> {
return proposals.reduce(
(counts, proposal) => {
counts.all += 1;
counts[proposal.status] += 1;
return counts;
// 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>();
const counts = proposals.reduce(
(accumulated, proposal) => {
accumulated.all += 1;
if (proposal.status === "applied") {
appliedSkills.add(proposal.slug);
} else {
accumulated[proposal.status] += 1;
}
return accumulated;
},
{ all: 0, pending: 0, applied: 0, rejected: 0, quarantined: 0, stale: 0 },
);
counts.applied = appliedSkills.size;
return counts;
}
export async function loadSkillWorkshopProposals(
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type { ApplicationContext, ApplicationGatewaySnapshot } from "../../app/context.ts";
import type { SkillWorkshopProposal } from "../../lib/skill-workshop/index.ts";
import { gatewayHelloForMethods } from "../../test-helpers/gateway-methods.ts";
import { createSkillWorkshopState, skillWorkshopRouteData } from "./proposals.ts";
import type { SkillWorkshopRouteData, SkillWorkshopState } from "./proposals.ts";
import "./skill-workshop-page.ts";
type SkillWorkshopPageTestElement = HTMLElement & {
context: ApplicationContext;
data?: SkillWorkshopRouteData;
state?: SkillWorkshopState;
updateComplete: Promise<boolean>;
requestUpdate: () => void;
};
function createContext(request: ReturnType<typeof vi.fn>): ApplicationContext {
// SAFETY: this test client implements the only Gateway method exercised by the page.
const client = { request } as unknown as GatewayBrowserClient;
const snapshot: ApplicationGatewaySnapshot = {
client,
phase: "connected",
offlineStable: false,
canvasPluginSurfaceUrl: null,
hello: gatewayHelloForMethods([]),
assistantAgentId: "research",
sessionKey: "global",
lastError: null,
lastErrorCode: null,
};
const subscribe = () => () => undefined;
// SAFETY: the page reads only the ApplicationContext fields supplied by this fixture.
return {
basePath: "",
gateway: { snapshot, subscribe },
config: {
current: { assistantIdentity: { name: "OpenClaw" } },
subscribe,
},
agents: { state: { agentsList: null } },
agentSelection: {
state: { selectedId: "research" },
subscribe,
},
agentIdentity: {
get: () => ({ agentId: "research", name: "Research" }),
subscribe,
},
sessions: { state: { result: null, loading: false } },
skillWorkshopRevision: { prepare: vi.fn() },
runtimeConfig: {
state: { configSnapshot: null, configLoading: false, lastError: null },
ensureLoaded: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
patch: vi.fn(async () => true),
subscribe,
},
navigate: vi.fn(),
} as unknown as ApplicationContext;
}
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;
expect(page.querySelectorAll(".sw-row")).toHaveLength(1);
expect(page.querySelector(".sw-row")?.textContent).toContain("4 revisions");
// The Applied tab counts grouped skills, matching the one-row-per-skill list.
const appliedFilter = [...page.querySelectorAll("button")].find((button) =>
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");
expect(history[0]?.textContent).toContain("Update");
expect(history[0]?.textContent).toContain("v4");
expect(history[2]?.textContent).toContain("Create");
expect(history[2]?.textContent).toContain("v2");
expect(history[3]?.textContent).toContain("Update");
expect(history[3]?.textContent).toContain("v1");
history[3]?.click();
await vi.waitFor(
() => {
const inspectCalls = request.mock.calls.filter(
([calledMethod]) => calledMethod === "skills.proposals.inspect",
);
expect(inspectCalls).toHaveLength(1);
expect(page.querySelector(".sw-detail__body")?.textContent).toContain(
"Revision 1 inspected",
);
},
{ interval: 1 },
);
});
});
@@ -112,27 +112,39 @@ function createContext(
} as unknown as ApplicationContext;
}
function createProposal(overrides: Partial<SkillWorkshopProposal>): SkillWorkshopProposal {
return {
key: "proposal",
kind: "update",
slug: "proposal",
name: "Proposal",
oneLine: "",
body: "",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
...overrides,
};
}
afterEach(() => {
document.body.replaceChildren();
});
describe("SkillWorkshopPage lifecycle", () => {
it("renders revisions in the shared modal and handles modal cancellation", async () => {
const proposal = {
const proposal = createProposal({
key: "proposal-modal",
slug: "proposal-modal",
name: "Modal proposal",
oneLine: "Shared modal coverage",
body: "## Workflow\n- test",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
} satisfies SkillWorkshopProposal;
});
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopLoaded = true;
loadedState.skillWorkshopProposals = [proposal];
@@ -159,26 +171,18 @@ describe("SkillWorkshopPage lifecycle", () => {
it("renders truncated Today previews without dangling surrogates", async () => {
const previewText = `${"a".repeat(118)}😀trailing`;
const proposal = {
const proposal = createProposal({
key: "proposal-utf16-preview",
slug: "proposal-utf16-preview",
name: "UTF-16 preview",
oneLine: "Preview boundary coverage",
body: `## Workflow\n- ${previewText}`,
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
updatedAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
origin: {
agentId: "research",
sessionKey: "agent:research:proposal-utf16-preview",
},
} satisfies SkillWorkshopProposal;
});
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
@@ -421,26 +425,15 @@ describe("SkillWorkshopPage lifecycle", () => {
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
const proposal = {
const proposal = createProposal({
key: "proposal-1",
slug: "proposal-1",
name: "Proposal",
oneLine: "",
body: "",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
updatedAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
origin: {
agentId: "research",
sessionKey: "agent:research:revision",
},
} satisfies SkillWorkshopProposal;
});
loadedState.skillWorkshopProposals = [proposal];
loadedState.skillWorkshopSelectedKey = proposal.key;
const page = document.createElement(
@@ -507,26 +500,15 @@ describe("SkillWorkshopPage lifecycle", () => {
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
const proposal = {
const proposal = createProposal({
key: "proposal-owner",
slug: "proposal-owner",
name: "Proposal",
oneLine: "",
body: "",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
updatedAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
origin: {
agentId: "research",
sessionKey: "agent:research:revision",
},
} satisfies SkillWorkshopProposal;
});
loadedState.skillWorkshopProposals = [proposal];
const page = document.createElement(
"openclaw-skill-workshop-page",
@@ -571,22 +553,11 @@ describe("SkillWorkshopPage lifecycle", () => {
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
const proposal = {
const proposal = createProposal({
key: "proposal-reconnect",
slug: "proposal-reconnect",
name: "Proposal",
oneLine: "",
body: "",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
updatedAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
} satisfies SkillWorkshopProposal;
});
loadedState.skillWorkshopProposals = [proposal];
loadedState.skillWorkshopSelectedKey = proposal.key;
const page = document.createElement(
@@ -643,22 +614,11 @@ describe("SkillWorkshopPage lifecycle", () => {
const loadedState = createSkillWorkshopState();
loadedState.skillWorkshopAgentId = "research";
loadedState.skillWorkshopLoaded = true;
const proposal = {
const proposal = createProposal({
key: "proposal-read-only",
slug: "proposal-read-only",
name: "Proposal",
oneLine: "",
body: "",
status: "pending",
version: 1,
revisionHash: null,
createdAt: 0,
updatedAt: 0,
recencyGroup: "today",
ageLabel: "now",
supportFiles: [],
isNew: false,
} satisfies SkillWorkshopProposal;
});
loadedState.skillWorkshopProposals = [proposal];
loadedState.skillWorkshopSelectedKey = proposal.key;
const page = document.createElement(
@@ -96,9 +96,15 @@ function renderSkillWorkshopPage(
state.skillWorkshopStatusFilter,
state.skillWorkshopQuery,
);
const selectedIndex = visibleProposals.findIndex(
const selectedProposal = state.skillWorkshopProposals.find(
(proposal) => proposal.key === state.skillWorkshopSelectedKey,
);
const isSelectedProposal = (proposal: (typeof visibleProposals)[number]) =>
proposal.key === state.skillWorkshopSelectedKey ||
(state.skillWorkshopStatusFilter === "applied" &&
selectedProposal?.status === "applied" &&
proposal.slug === selectedProposal?.slug);
const selectedIndex = visibleProposals.findIndex(isSelectedProposal);
const selectProposal = (key: string) => {
state.skillWorkshopFilePreviewKey = null;
void selectSkillWorkshopProposal(state, context, key).finally(requestUpdate);
@@ -118,10 +124,7 @@ function renderSkillWorkshopPage(
}
};
const selectVisibleFallback = (proposals: typeof visibleProposals) => {
if (
proposals.length === 0 ||
proposals.some((proposal) => proposal.key === state.skillWorkshopSelectedKey)
) {
if (proposals.length === 0 || proposals.some(isSelectedProposal)) {
return;
}
const firstProposal = proposals[0];
+35 -59
View File
@@ -14,14 +14,17 @@ import "../../styles/skill-workshop.css";
import {
filterSkillWorkshopProposals,
type SkillWorkshopActionNotice,
type SkillWorkshopAppliedSkill,
type SkillWorkshopEvaluation,
type SkillWorkshopEvaluationFinding,
type SkillWorkshopEvaluationOutcome,
type SkillWorkshopProposal,
type SkillWorkshopStatusFilter,
} from "../../lib/skill-workshop/index.ts";
import { renderLazyAppliedHistory, resolveAppliedHistory } from "./applied-history.ts";
import { renderBoardEmptyDetail, renderWorkshopEmptyState } from "./empty-states.ts";
import { renderSkillWorkshopHistoryScan } from "./history-scan.ts";
import { renderSkillWorkshopProposalList } from "./proposal-list.ts";
import { renderSelfLearningError } from "./self-learning.ts";
import type { SkillWorkshopProps } from "./view-types.ts";
@@ -53,8 +56,17 @@ const GROUP_LABEL: Record<SkillWorkshopProposal["recencyGroup"], string> = {
};
export function renderSkillWorkshop(props: SkillWorkshopProps) {
const filtered = filterSkillWorkshopProposals(props.proposals, props.statusFilter, props.query);
const selected = filtered.find((p) => p.key === props.selectedKey) ?? filtered[0];
const appliedHistory =
props.statusFilter === "applied"
? resolveAppliedHistory(props.proposals, props.query, props.selectedKey)
: undefined;
const filtered = appliedHistory
? appliedHistory.skills.map((skill) => skill.latest)
: filterSkillWorkshopProposals(props.proposals, props.statusFilter, props.query);
const selected =
appliedHistory?.selectedProposal ??
filtered.find((proposal) => proposal.key === props.selectedKey) ??
filtered[0];
const groups = groupByRecency(filtered);
const preview =
selected && props.filePreviewKey
@@ -75,7 +87,13 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) {
})
: props.mode === "today"
? renderToday(props, todayHero, allPending)
: renderBoard(props, groups, selected);
: renderBoard(
props,
groups,
selected,
appliedHistory?.skills ?? [],
appliedHistory?.selectedSkill,
);
return html`
<section class="skill-workshop sw-mode-${props.mode}">
@@ -195,13 +213,22 @@ function renderBoard(
props: SkillWorkshopProps,
groups: Array<{ label: string; items: SkillWorkshopProposal[] }>,
selected: SkillWorkshopProposal | undefined,
appliedSkills: SkillWorkshopAppliedSkill[],
selectedAppliedSkill: SkillWorkshopAppliedSkill | undefined,
) {
return html`
${renderLifecycleTabs(props)}
<div class="sw-triage" style=${styleMap({ "--sw-queue-width": `${props.queueWidth}px` })}>
${renderQueue(props, groups, selected)} ${renderQueueResizer(props)}
${renderSkillWorkshopProposalList(
props,
groups,
selected,
appliedSkills,
queueEmptyText(props),
)}
${renderQueueResizer(props)}
${selected
? renderDetail(props, selected)
? renderDetail(props, selected, selectedAppliedSkill)
: renderBoardEmptyDetail(props.query, props.statusFilter)}
</div>
`;
@@ -282,63 +309,11 @@ function renderLifecycleTabs(props: SkillWorkshopProps) {
`;
}
function renderQueue(
props: SkillWorkshopProps,
groups: Array<{ label: string; items: SkillWorkshopProposal[] }>,
selected: SkillWorkshopProposal | undefined,
) {
const total = groups.reduce((sum, g) => sum + g.items.length, 0);
return html`
<aside class="sw-queue">
<div class="sw-queue__search">
<input
placeholder=${t("skillWorkshop.queue.search")}
.value=${props.query}
@input=${(event: Event) =>
props.onQueryChange((event.target as HTMLInputElement).value ?? "")}
/>
</div>
<div class="sw-queue__body">
${total === 0
? html`<div class="sw-queue__empty">${queueEmptyText(props)}</div>`
: groups.map(
(group) => html`
<div class="sw-queue__group">
${t(group.label)}
<span class="settings-count">${group.items.length}</span>
</div>
${group.items.map((proposal) => renderRow(props, proposal, selected))}
`,
)}
</div>
</aside>
`;
}
function renderRow(
function renderDetail(
props: SkillWorkshopProps,
proposal: SkillWorkshopProposal,
selected: SkillWorkshopProposal | undefined,
appliedSkill: SkillWorkshopAppliedSkill | undefined,
) {
const isSelected = selected?.key === proposal.key;
const noveltyClass = proposal.isNew ? "is-new" : "is-seen";
return html`
<button
class="sw-row ${noveltyClass} ${isSelected ? "is-selected" : ""}"
@click=${() => props.onSelect(proposal.key)}
>
<span class="sw-row__dot"></span>
<span>
<span class="sw-row__title">${proposal.name}</span>
<span class="sw-row__desc">${proposal.oneLine}</span>
</span>
<span class="sw-row__meta">${proposal.ageLabel}</span>
</button>
`;
}
function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal) {
const editedAt =
proposal.updatedAt && proposal.updatedAt > proposal.createdAt ? proposal.updatedAt : null;
const createdLabel = editedAt
@@ -390,6 +365,7 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
: renderProposalBody(proposal.body)}
</div>
${appliedSkill ? renderLazyAppliedHistory(props, appliedSkill) : nothing}
${proposal.supportFiles.length > 0
? html`
<div class="sw-section" style="margin-top: 18px;">
+72
View File
@@ -277,6 +277,23 @@
margin-top: 1px;
}
.sw-row__meta--applied {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.sw-row__revision-count {
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg);
color: var(--text);
font-size: 11px;
line-height: 1.4;
}
.sw-queue__empty {
padding: 28px 16px;
text-align: center;
@@ -520,6 +537,61 @@
font-weight: 600;
}
.sw-applied-history {
margin-top: 18px;
}
.sw-applied-history__list {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg);
}
.sw-applied-history__item {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border: 0;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
}
.sw-applied-history__item:last-child {
border-bottom: 0;
}
.sw-applied-history__item:hover {
background: var(--bg-hover);
}
.sw-applied-history__item.is-selected {
background: color-mix(in srgb, var(--accent) 8%, var(--bg) 92%);
}
.sw-applied-history__operation {
color: var(--text-strong);
font-size: 13px;
font-weight: 600;
}
.sw-applied-history__age,
.sw-applied-history__version {
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.sw-applied-history__version {
font-family: var(--mono);
}
/* ── Body card (proposal markdown) ─────────────────────────────────── */
.sw-body-card {
background: var(--bg);