mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user