diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index c1003efd42fa..f75a817b0862 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -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 diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 76dc12e2f44e..56fec457a871 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/lib/skill-workshop/index.test.ts b/ui/src/lib/skill-workshop/index.test.ts new file mode 100644 index 000000000000..b9b40a0d1cd3 --- /dev/null +++ b/ui/src/lib/skill-workshop/index.test.ts @@ -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); + }); +}); diff --git a/ui/src/lib/skill-workshop/index.ts b/ui/src/lib/skill-workshop/index.ts index f44e6430e90c..dbbc427e6819 100644 --- a/ui/src/lib/skill-workshop/index.ts +++ b/ui/src/lib/skill-workshop/index.ts @@ -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(); + 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; }); diff --git a/ui/src/pages/skill-workshop/applied-history.runtime.ts b/ui/src/pages/skill-workshop/applied-history.runtime.ts new file mode 100644 index 000000000000..754d01969fe2 --- /dev/null +++ b/ui/src/pages/skill-workshop/applied-history.runtime.ts @@ -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` +
+ +
+ ${skill.revisions.map(({ proposal, operation, version }) => { + const selected = proposal.key === props.selectedKey; + return html` + + `; + })} +
+
+ `; +} diff --git a/ui/src/pages/skill-workshop/applied-history.ts b/ui/src/pages/skill-workshop/applied-history.ts new file mode 100644 index 000000000000..5b8b97ea6670 --- /dev/null +++ b/ui/src/pages/skill-workshop/applied-history.ts @@ -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 | undefined; + +function loadAppliedHistoryRenderer(): Promise { + 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`

${t("common.loading")}

`, + ); +} diff --git a/ui/src/pages/skill-workshop/evaluation.browser.test.ts b/ui/src/pages/skill-workshop/evaluation.browser.test.ts index 0b94701f8dd6..8c199e9cbc26 100644 --- a/ui/src/pages/skill-workshop/evaluation.browser.test.ts +++ b/ui/src/pages/skill-workshop/evaluation.browser.test.ts @@ -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", diff --git a/ui/src/pages/skill-workshop/proposal-list.ts b/ui/src/pages/skill-workshop/proposal-list.ts new file mode 100644 index 000000000000..db527dd7f943 --- /dev/null +++ b/ui/src/pages/skill-workshop/proposal-list.ts @@ -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` + + `; +} + +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` + + `; +} diff --git a/ui/src/pages/skill-workshop/proposals.test.ts b/ui/src/pages/skill-workshop/proposals.test.ts index 7bbb6c29bb35..861a39b3bf3d 100644 --- a/ui/src/pages/skill-workshop/proposals.test.ts +++ b/ui/src/pages/skill-workshop/proposals.test.ts @@ -116,6 +116,7 @@ function inspectResult(status: SkillWorkshopProposal["status"] = "pending") { function proposal(overrides: Partial = {}): 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 () => { diff --git a/ui/src/pages/skill-workshop/proposals.ts b/ui/src/pages/skill-workshop/proposals.ts index fafc2e12d79a..59fe23211f63 100644 --- a/ui/src/pages/skill-workshop/proposals.ts +++ b/ui/src/pages/skill-workshop/proposals.ts @@ -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(); + 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( diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.applied.test.ts b/ui/src/pages/skill-workshop/skill-workshop-page.applied.test.ts new file mode 100644 index 000000000000..be952f6870f0 --- /dev/null +++ b/ui/src/pages/skill-workshop/skill-workshop-page.applied.test.ts @@ -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; + requestUpdate: () => void; +}; + +function createContext(request: ReturnType): 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(".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 }, + ); + }); +}); diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts index 4b92b5bc15d2..3a8c4e3f0adc 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.test.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.test.ts @@ -112,27 +112,39 @@ function createContext( } as unknown as ApplicationContext; } +function createProposal(overrides: Partial): 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( diff --git a/ui/src/pages/skill-workshop/skill-workshop-page.ts b/ui/src/pages/skill-workshop/skill-workshop-page.ts index 5b3a60397cf2..e870c788fae4 100644 --- a/ui/src/pages/skill-workshop/skill-workshop-page.ts +++ b/ui/src/pages/skill-workshop/skill-workshop-page.ts @@ -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]; diff --git a/ui/src/pages/skill-workshop/view.ts b/ui/src/pages/skill-workshop/view.ts index 655d9d29faae..92624caabc36 100644 --- a/ui/src/pages/skill-workshop/view.ts +++ b/ui/src/pages/skill-workshop/view.ts @@ -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 = { }; 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`
@@ -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)}
- ${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)}
`; @@ -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` - - `; -} - -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` - - `; -} - -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)} + ${appliedSkill ? renderLazyAppliedHistory(props, appliedSkill) : nothing} ${proposal.supportFiles.length > 0 ? html`
diff --git a/ui/src/styles/skill-workshop.css b/ui/src/styles/skill-workshop.css index 9250ce284cf0..9155e7cec0b4 100644 --- a/ui/src/styles/skill-workshop.css +++ b/ui/src/styles/skill-workshop.css @@ -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);