feat: show real Skill Workshop proposals

This commit is contained in:
Shakker
2026-05-31 18:12:00 +01:00
committed by Shakker
parent 4339d7c1d8
commit caa08a6dc0
7 changed files with 482 additions and 348 deletions

View File

@@ -40,6 +40,19 @@
min-height: auto;
}
.sw-error {
margin: 0 0 12px;
border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
border-radius: 10px;
padding: 10px 12px;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.sw-muted {
color: var(--muted);
}
/* ── Lifecycle tabs (underline style) ───────────────────────────────── */
.sw-lifecycle-tabs {
display: flex;

View File

@@ -126,6 +126,13 @@ import {
restoreSessionFromCheckpoint,
toggleSessionCompactionCheckpoints,
} from "./controllers/sessions.ts";
import {
countSkillWorkshopProposals,
loadSkillWorkshopProposalDetail,
requestSkillWorkshopRevision,
runSkillWorkshopLifecycleAction,
selectSkillWorkshopProposal,
} from "./controllers/skill-workshop.ts";
import {
closeClawHubDetail,
installFromClawHub,
@@ -493,8 +500,6 @@ const MAX_SKILL_WORKSHOP_REVIEWED_KEYS = 500;
const DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH = 360;
const MIN_SKILL_WORKSHOP_QUEUE_WIDTH = 280;
const MAX_SKILL_WORKSHOP_QUEUE_WIDTH = 560;
const SKILL_WORKSHOP_ACTION_DELAY_MS = 450;
const SKILL_WORKSHOP_NOTICE_MS = 2800;
const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"];
const CRON_TIMEZONE_SUGGESTIONS = [
"UTC",
@@ -686,16 +691,6 @@ function applySkillWorkshopReviewState<T extends SkillWorkshopReviewableProposal
}));
}
function applySkillWorkshopStatusOverrides<T extends SkillWorkshopReviewableProposal>(
proposals: T[],
overrides: AppViewState["skillWorkshopStatusOverrides"],
): T[] {
return proposals.map((proposal) => {
const status = overrides[proposal.key];
return status ? { ...proposal, status } : proposal;
});
}
function rememberSkillWorkshopProposalReviewed<T extends SkillWorkshopReviewableProposal>(
reviewedKeys: string[],
proposal: T,
@@ -709,59 +704,6 @@ function rememberSkillWorkshopProposalReviewed<T extends SkillWorkshopReviewable
return next;
}
function runSkillWorkshopDemoAction(
state: AppViewState,
action: "apply" | "revise" | "reject",
key: string,
proposals: SkillWorkshopReviewableProposal[],
options?: { onComplete?: () => void },
): void {
if (state.skillWorkshopActionBusy) {
return;
}
const proposal = proposals.find((item) => item.key === key);
if (!proposal) {
return;
}
if (state.skillWorkshopActionNoticeTimer) {
globalThis.clearTimeout(state.skillWorkshopActionNoticeTimer);
state.skillWorkshopActionNoticeTimer = null;
}
state.skillWorkshopActionBusy = { key, action };
state.skillWorkshopActionNotice = null;
globalThis.setTimeout(() => {
if (
state.skillWorkshopActionBusy?.key !== key ||
state.skillWorkshopActionBusy.action !== action
) {
return;
}
state.skillWorkshopActionBusy = null;
const nextStatus = action === "apply" ? "applied" : action === "reject" ? "rejected" : null;
if (nextStatus) {
state.skillWorkshopStatusOverrides = {
...state.skillWorkshopStatusOverrides,
[key]: nextStatus,
};
}
state.skillWorkshopActionNotice = {
key,
label:
action === "apply" ? "Applied" : action === "reject" ? "Rejected" : "Revision requested",
slug: "slug" in proposal && typeof proposal.slug === "string" ? proposal.slug : proposal.key,
};
options?.onComplete?.();
state.skillWorkshopActionNoticeTimer = globalThis.setTimeout(() => {
if (state.skillWorkshopActionNotice?.key === key) {
state.skillWorkshopActionNotice = null;
}
state.skillWorkshopActionNoticeTimer = null;
}, SKILL_WORKSHOP_NOTICE_MS);
}, SKILL_WORKSHOP_ACTION_DELAY_MS);
}
function loadDismissedUpdateBanner(): DismissedUpdateBanner | null {
try {
const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY);
@@ -3096,13 +3038,10 @@ export function renderApp(state: AppViewState) {
${state.tab === "skillWorkshop"
? renderLazyView(lazySkillWorkshop, (m) => {
const proposals = applySkillWorkshopReviewState(
applySkillWorkshopStatusOverrides(
m.getDemoSkillWorkshopProposals(),
state.skillWorkshopStatusOverrides,
),
state.skillWorkshopProposals,
state.skillWorkshopReviewedKeys,
);
const counts = m.countProposals(proposals);
const counts = countSkillWorkshopProposals(proposals);
const selectedKey = state.skillWorkshopSelectedKey ?? proposals[0]?.key ?? null;
const currentIndex = proposals.findIndex((p) => p.key === selectedKey);
const goto = (offset: number) => {
@@ -3117,7 +3056,9 @@ export function renderApp(state: AppViewState) {
);
};
return m.renderSkillWorkshop({
loading: false,
loading: state.skillWorkshopLoading,
error: state.skillWorkshopError,
inspectingKey: state.skillWorkshopInspectingKey,
proposals,
selectedKey,
statusFilter: state.skillWorkshopStatusFilter,
@@ -3140,7 +3081,7 @@ export function renderApp(state: AppViewState) {
onModeChange: (mode) => setSkillWorkshopMode(state, mode),
onSelect: (key) => {
const proposal = proposals.find((p) => p.key === key);
state.skillWorkshopSelectedKey = key;
selectSkillWorkshopProposal(state, key);
if (proposal) {
state.skillWorkshopReviewedKeys = rememberSkillWorkshopProposalReviewed(
state.skillWorkshopReviewedKeys,
@@ -3152,13 +3093,17 @@ export function renderApp(state: AppViewState) {
},
onPrev: () => goto(-1),
onNext: () => goto(1),
onApply: (key) => runSkillWorkshopDemoAction(state, "apply", key, proposals),
onApply: (key) => {
void runSkillWorkshopLifecycleAction(state, "apply", key);
},
onRevise: (key) => {
state.skillWorkshopRevisionKey = key;
state.skillWorkshopRevisionDraft = "";
state.skillWorkshopActionNotice = null;
},
onReject: (key) => runSkillWorkshopDemoAction(state, "reject", key, proposals),
onReject: (key) => {
void runSkillWorkshopLifecycleAction(state, "reject", key);
},
onRevisionDraftChange: (draft) => {
state.skillWorkshopRevisionDraft = draft;
},
@@ -3173,15 +3118,11 @@ export function renderApp(state: AppViewState) {
if (!state.skillWorkshopRevisionDraft.trim()) {
return;
}
runSkillWorkshopDemoAction(state, "revise", key, proposals, {
onComplete: () => {
state.skillWorkshopRevisionKey = null;
state.skillWorkshopRevisionDraft = "";
},
});
void requestSkillWorkshopRevision(state, key);
},
onPreviewFile: (_key, path) => {
state.skillWorkshopFilePreviewKey = path;
void loadSkillWorkshopProposalDetail(state, _key);
},
onClosePreview: () => {
state.skillWorkshopFilePreviewKey = null;

View File

@@ -52,6 +52,10 @@ import {
import { loadNodes, type NodesState } from "./controllers/nodes.ts";
import { loadPresence, type PresenceState } from "./controllers/presence.ts";
import { loadSessions, type SessionsState } from "./controllers/sessions.ts";
import {
loadSkillWorkshopProposals,
type SkillWorkshopState,
} from "./controllers/skill-workshop.ts";
import { loadSkills, type SkillsState } from "./controllers/skills.ts";
import { loadUsage, type UsageState } from "./controllers/usage.ts";
import { loadWorkboard } from "./controllers/workboard.ts";
@@ -152,6 +156,7 @@ type SettingsAppHost = SettingsHost &
PresenceState &
SessionsState &
SkillsState &
SkillWorkshopState &
ModelAuthStatusState &
UsageState & {
overviewLogCursor: number | null;
@@ -465,6 +470,9 @@ export async function refreshActiveTab(host: SettingsHost) {
case "skills":
await loadSkills(app);
break;
case "skillWorkshop":
await loadSkillWorkshopProposals(app, { force: true });
break;
case "agents":
await refreshAgentsTab(host, app);
break;

View File

@@ -51,6 +51,7 @@ import type {
} from "./types.ts";
import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SkillWorkshopProposal } from "./views/skill-workshop.ts";
import type { SessionLogEntry } from "./views/usage.ts";
export type AppViewState = {
@@ -431,11 +432,15 @@ export type AppViewState = {
skillWorkshopQuery: string;
skillWorkshopFilePreviewKey: string | null;
skillWorkshopFilePreviewQuery: string;
skillWorkshopLoading: boolean;
skillWorkshopLoaded: boolean;
skillWorkshopError: string | null;
skillWorkshopInspectingKey: string | null;
skillWorkshopProposals: SkillWorkshopProposal[];
skillWorkshopReviewedKeys: string[];
skillWorkshopQueueWidth: number;
skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null;
skillWorkshopActionNotice: { key: string; label: string; slug: string } | null;
skillWorkshopStatusOverrides: Record<string, "applied" | "rejected">;
skillWorkshopRevisionKey: string | null;
skillWorkshopRevisionDraft: string;
skillWorkshopActionNoticeTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;

View File

@@ -149,6 +149,7 @@ import type {
import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types.ts";
import { generateUUID } from "./uuid.ts";
import type { NostrProfileFormState } from "./views/channels.nostr-profile-form.ts";
import type { SkillWorkshopProposal } from "./views/skill-workshop.ts";
declare global {
interface Window {
@@ -638,13 +639,17 @@ export class OpenClawApp extends LitElement {
@state() skillWorkshopQuery = "";
@state() skillWorkshopFilePreviewKey: string | null = null;
@state() skillWorkshopFilePreviewQuery = "";
@state() skillWorkshopLoading = false;
@state() skillWorkshopLoaded = false;
@state() skillWorkshopError: string | null = null;
@state() skillWorkshopInspectingKey: string | null = null;
@state() skillWorkshopProposals: SkillWorkshopProposal[] = [];
@state() skillWorkshopReviewedKeys = loadSkillWorkshopReviewedKeys();
@state() skillWorkshopQueueWidth = loadSkillWorkshopQueueWidth();
@state() skillWorkshopMode: "board" | "today" = loadSkillWorkshopMode();
@state() skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null =
null;
@state() skillWorkshopActionNotice: { key: string; label: string; slug: string } | null = null;
@state() skillWorkshopStatusOverrides: Record<string, "applied" | "rejected"> = {};
@state() skillWorkshopRevisionKey: string | null = null;
@state() skillWorkshopRevisionDraft = "";
skillWorkshopActionNoticeTimer: ReturnType<typeof globalThis.setTimeout> | number | null = null;

View File

@@ -0,0 +1,418 @@
import type { ChatSendOptions } from "../app-chat.ts";
import type { GatewayBrowserClient } from "../gateway.ts";
import type {
SkillWorkshopAction,
SkillWorkshopActionNotice,
SkillWorkshopProposal,
} from "../views/skill-workshop.ts";
const SKILL_WORKSHOP_NOTICE_MS = 2800;
type SkillProposalStatus = "pending" | "applied" | "rejected" | "quarantined" | "stale";
type SkillProposalKind = "create" | "update";
type SkillProposalScanState = "pending" | "clean" | "failed" | "quarantined";
type SkillProposalManifestEntry = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
skillName: string;
skillKey: string;
createdAt: string;
updatedAt: string;
scanState: SkillProposalScanState;
};
type SkillProposalManifest = {
schema: "openclaw.skill-workshop.proposals-manifest.v1";
updatedAt: string;
proposals: SkillProposalManifestEntry[];
};
type SkillProposalSupportFileRecord = {
path: string;
sizeBytes: number;
};
type SkillProposalRecord = {
id: string;
kind: SkillProposalKind;
status: SkillProposalStatus;
title: string;
description: string;
createdAt: string;
updatedAt: string;
proposedVersion: string;
supportFiles?: SkillProposalSupportFileRecord[];
target: {
skillName: string;
skillKey: string;
};
};
type SkillProposalSupportFile = {
path: string;
content: string;
};
type SkillProposalInspectResult = {
record: SkillProposalRecord;
content: string;
supportFiles?: SkillProposalSupportFile[];
};
export type SkillWorkshopState = {
client: GatewayBrowserClient | null;
connected: boolean;
skillWorkshopLoading: boolean;
skillWorkshopLoaded: boolean;
skillWorkshopError: string | null;
skillWorkshopInspectingKey: string | null;
skillWorkshopProposals: SkillWorkshopProposal[];
skillWorkshopSelectedKey: string | null;
skillWorkshopActionBusy: { key: string; action: SkillWorkshopAction } | null;
skillWorkshopActionNotice: SkillWorkshopActionNotice | null;
skillWorkshopActionNoticeTimer?: ReturnType<typeof globalThis.setTimeout> | number | null;
skillWorkshopRevisionKey: string | null;
skillWorkshopRevisionDraft: string;
handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise<void>;
};
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function parseDateMs(value: string | undefined): number {
if (!value) {
return Date.now();
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : Date.now();
}
function startOfLocalDay(ms: number): number {
const date = new Date(ms);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
}
function recencyGroup(ms: number): SkillWorkshopProposal["recencyGroup"] {
const today = startOfLocalDay(Date.now());
const day = startOfLocalDay(ms);
if (day === today) {
return "today";
}
if (day === today - 24 * 60 * 60 * 1000) {
return "yesterday";
}
return "earlier";
}
function compactAgeLabel(ms: number): string {
const diff = Math.max(0, Date.now() - ms);
const min = Math.floor(diff / 60_000);
if (min < 1) {
return "now";
}
if (min < 60) {
return `${min}m`;
}
const hr = Math.floor(min / 60);
if (hr < 24) {
return `${hr}h`;
}
const day = Math.floor(hr / 24);
return `${day}d`;
}
function proposedVersionNumber(value: string | undefined): number {
const parsed = Number.parseInt((value ?? "").replace(/^v/i, ""), 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B";
}
if (bytes < 1024) {
return `${bytes} B`;
}
return `${(bytes / 1024).toFixed(1)} KB`;
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
function stripProposalFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function supportFilesFromInspect(
result: SkillProposalInspectResult,
): SkillWorkshopProposal["supportFiles"] {
const sizes = new Map(
(result.record.supportFiles ?? []).map((file) => [file.path, file.sizeBytes]),
);
return (result.supportFiles ?? []).map((file) => ({
path: file.path,
size: formatBytes(sizes.get(file.path) ?? byteLength(file.content)),
contents: file.content,
}));
}
function proposalFromManifest(
entry: SkillProposalManifestEntry,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const updatedAt = parseDateMs(entry.updatedAt);
const createdAt = parseDateMs(entry.createdAt);
const previousIsCurrent = previous?.updatedAt === updatedAt;
return {
key: entry.id,
slug: entry.skillKey,
name: entry.title || entry.skillName,
oneLine: entry.description,
body: previousIsCurrent ? previous.body : "",
status: entry.status,
version: previousIsCurrent ? previous.version : 1,
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: previousIsCurrent ? previous.supportFiles : [],
isNew: previous?.isNew ?? false,
};
}
function proposalFromInspect(
result: SkillProposalInspectResult,
previous: SkillWorkshopProposal | undefined,
): SkillWorkshopProposal {
const record = result.record;
const updatedAt = parseDateMs(record.updatedAt);
const createdAt = parseDateMs(record.createdAt);
return {
key: record.id,
slug: record.target.skillKey,
name: record.title || record.target.skillName,
oneLine: record.description,
body: stripProposalFrontmatter(result.content),
status: record.status,
version: proposedVersionNumber(record.proposedVersion),
createdAt,
updatedAt,
recencyGroup: recencyGroup(updatedAt || createdAt),
ageLabel: compactAgeLabel(updatedAt || createdAt),
supportFiles: supportFilesFromInspect(result),
isNew: previous?.isNew ?? false,
};
}
function mergeProposal(state: SkillWorkshopState, proposal: SkillWorkshopProposal): void {
const proposals = state.skillWorkshopProposals;
const index = proposals.findIndex((item) => item.key === proposal.key);
if (index < 0) {
state.skillWorkshopProposals = [proposal, ...proposals];
return;
}
state.skillWorkshopProposals = [
...proposals.slice(0, index),
proposal,
...proposals.slice(index + 1),
];
}
function clearActionNoticeTimer(state: SkillWorkshopState): void {
if (state.skillWorkshopActionNoticeTimer) {
globalThis.clearTimeout(state.skillWorkshopActionNoticeTimer);
state.skillWorkshopActionNoticeTimer = null;
}
}
function showActionNotice(
state: SkillWorkshopState,
proposal: SkillWorkshopProposal | undefined,
label: string,
): void {
if (!proposal) {
return;
}
clearActionNoticeTimer(state);
state.skillWorkshopActionNotice = {
key: proposal.key,
label,
slug: proposal.slug || proposal.name,
};
state.skillWorkshopActionNoticeTimer = globalThis.setTimeout(() => {
if (state.skillWorkshopActionNotice?.key === proposal.key) {
state.skillWorkshopActionNotice = null;
}
state.skillWorkshopActionNoticeTimer = null;
}, SKILL_WORKSHOP_NOTICE_MS);
}
export function countSkillWorkshopProposals(
proposals: SkillWorkshopProposal[],
): Record<"all" | SkillProposalStatus, number> {
return proposals.reduce(
(counts, proposal) => {
counts.all += 1;
counts[proposal.status] += 1;
return counts;
},
{ all: 0, pending: 0, applied: 0, rejected: 0, quarantined: 0, stale: 0 },
);
}
export async function loadSkillWorkshopProposals(
state: SkillWorkshopState,
options?: { force?: boolean },
): Promise<void> {
if (!state.client || !state.connected || state.skillWorkshopLoading) {
return;
}
if (state.skillWorkshopLoaded && !options?.force) {
return;
}
state.skillWorkshopLoading = true;
state.skillWorkshopError = null;
try {
const result = await state.client.request<SkillProposalManifest>("skills.proposals.list", {});
const previousByKey = new Map(
state.skillWorkshopProposals.map((proposal) => [proposal.key, proposal]),
);
const proposals = (result.proposals ?? [])
.toSorted((a, b) => parseDateMs(b.updatedAt) - parseDateMs(a.updatedAt))
.map((entry) => proposalFromManifest(entry, previousByKey.get(entry.id)));
state.skillWorkshopProposals = proposals;
state.skillWorkshopLoaded = true;
if (!proposals.some((proposal) => proposal.key === state.skillWorkshopSelectedKey)) {
state.skillWorkshopSelectedKey = proposals[0]?.key ?? null;
}
if (state.skillWorkshopSelectedKey) {
await loadSkillWorkshopProposalDetail(state, state.skillWorkshopSelectedKey);
}
} catch (err) {
state.skillWorkshopError = getErrorMessage(err);
} finally {
state.skillWorkshopLoading = false;
}
}
export async function loadSkillWorkshopProposalDetail(
state: SkillWorkshopState,
proposalId: string,
options?: { force?: boolean },
): Promise<void> {
if (!state.client || !state.connected || state.skillWorkshopInspectingKey === proposalId) {
return;
}
const existing = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
if (existing?.body && !options?.force) {
return;
}
state.skillWorkshopInspectingKey = proposalId;
state.skillWorkshopError = null;
try {
const result = await state.client.request<SkillProposalInspectResult>(
"skills.proposals.inspect",
{
proposalId,
},
);
mergeProposal(state, proposalFromInspect(result, existing));
} catch (err) {
state.skillWorkshopError = getErrorMessage(err);
} finally {
if (state.skillWorkshopInspectingKey === proposalId) {
state.skillWorkshopInspectingKey = null;
}
}
}
export function selectSkillWorkshopProposal(state: SkillWorkshopState, proposalId: string): void {
state.skillWorkshopSelectedKey = proposalId;
void loadSkillWorkshopProposalDetail(state, proposalId);
}
async function refreshAfterMutation(state: SkillWorkshopState, proposalId: string): Promise<void> {
state.skillWorkshopLoaded = false;
await loadSkillWorkshopProposals(state, { force: true });
await loadSkillWorkshopProposalDetail(state, proposalId, { force: true });
}
export async function runSkillWorkshopLifecycleAction(
state: SkillWorkshopState,
action: Extract<SkillWorkshopAction, "apply" | "reject">,
proposalId: string,
): Promise<void> {
if (!state.client || !state.connected || state.skillWorkshopActionBusy) {
return;
}
const previous = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
state.skillWorkshopActionBusy = { key: proposalId, action };
state.skillWorkshopActionNotice = null;
state.skillWorkshopError = null;
try {
const method = action === "apply" ? "skills.proposals.apply" : "skills.proposals.reject";
await state.client.request(method, { proposalId });
await refreshAfterMutation(state, proposalId);
const updated = state.skillWorkshopProposals.find((proposal) => proposal.key === proposalId);
showActionNotice(state, updated ?? previous, action === "apply" ? "Applied" : "Rejected");
} catch (err) {
state.skillWorkshopError = getErrorMessage(err);
} finally {
if (
state.skillWorkshopActionBusy?.key === proposalId &&
state.skillWorkshopActionBusy.action === action
) {
state.skillWorkshopActionBusy = null;
}
}
}
function buildRevisionRequest(proposal: SkillWorkshopProposal, instructions: string): string {
return [
`Revise Skill Workshop proposal \`${proposal.key}\` (${proposal.slug}).`,
"",
"Use `skill_workshop` with `action=inspect` first, then `action=revise` for that pending proposal.",
"Do not apply, approve, reject, or install the proposal.",
"",
"Requested changes:",
instructions.trim(),
].join("\n");
}
export async function requestSkillWorkshopRevision(
state: SkillWorkshopState,
proposalId: string,
): Promise<void> {
if (state.skillWorkshopActionBusy) {
return;
}
const proposal = state.skillWorkshopProposals.find((item) => item.key === proposalId);
const instructions = state.skillWorkshopRevisionDraft.trim();
if (!proposal || !instructions) {
return;
}
state.skillWorkshopActionBusy = { key: proposalId, action: "revise" };
state.skillWorkshopActionNotice = null;
state.skillWorkshopError = null;
try {
await state.handleSendChat(buildRevisionRequest(proposal, instructions));
state.skillWorkshopRevisionKey = null;
state.skillWorkshopRevisionDraft = "";
showActionNotice(state, proposal, "Revision requested");
} catch (err) {
state.skillWorkshopError = getErrorMessage(err);
} finally {
if (
state.skillWorkshopActionBusy?.key === proposalId &&
state.skillWorkshopActionBusy.action === "revise"
) {
state.skillWorkshopActionBusy = null;
}
}
}

View File

@@ -49,6 +49,8 @@ export type SkillWorkshopActionNotice = {
export type SkillWorkshopProps = {
loading: boolean;
error: string | null;
inspectingKey: string | null;
proposals: SkillWorkshopProposal[];
selectedKey: string | null;
statusFilter: SkillWorkshopStatusFilter;
@@ -126,6 +128,7 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) {
return html`
<section class="skill-workshop sw-mode-${props.mode}">
${props.error ? html`<div class="sw-error" role="status">${props.error}</div>` : nothing}
<div class="sw-view" data-mode=${props.mode}>
${keyed(props.mode, html`<div class="sw-view__pane">${body}</div>`)}
</div>
@@ -365,6 +368,7 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
const createdLabel = proposal.updatedAt
? `Edited ${formatRelative(proposal.updatedAt)}`
: `Created ${formatRelative(proposal.createdAt)}`;
const detailLoading = props.inspectingKey === proposal.key && !proposal.body;
return html`
<div class="sw-detail">
@@ -396,7 +400,9 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal
<div class="sw-detail__body">
<div class="sw-body-card">
<h1>${proposal.slug}</h1>
${renderProposalBody(proposal.body)}
${detailLoading
? html`<p class="sw-muted">Loading proposal…</p>`
: renderProposalBody(proposal.body)}
</div>
${proposal.supportFiles.length > 0
@@ -836,6 +842,9 @@ function groupByRecency(
}
function queueEmptyText(props: SkillWorkshopProps): string {
if (props.error) {
return "Could not load proposals.";
}
if (props.loading) {
return "Loading proposals…";
}
@@ -865,268 +874,3 @@ function formatRelative(ms: number): string {
}
return new Date(ms).toLocaleDateString();
}
let cachedDemoProposals: SkillWorkshopProposal[] | null = null;
export function getDemoSkillWorkshopProposals(): SkillWorkshopProposal[] {
if (!cachedDemoProposals) {
cachedDemoProposals = buildDemoSkillWorkshopProposals();
}
return cachedDemoProposals;
}
// Demo data so the page actually renders the design before the gateway wires up.
// Drop this once `skills.proposals.list` is wired.
export function buildDemoSkillWorkshopProposals(): SkillWorkshopProposal[] {
const now = Date.now();
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const morningBody = `## When to use
First thing in the morning when the user wants to start the day with a cleared inbox and a concrete plan. Trigger phrases: \`morning catch up\`, \`clear my inbox\`, \`what should I do today\`.
## Steps
1. **Triage.** Read unread messages across mail, Slack, and Discord. Skip threads where the user is just CC'd unless flagged.
2. **Archive.** Sort newsletters, receipts, and automated alerts into their normal folders.
3. **Surface.** List anything that needs the user's reply today, with a one-line "why" each.
4. **Draft.** For the top three replies, write a short draft in the user's voice. Do not send.
5. **Plan.** Propose a 3-item focus list for the day. Match against calendar gaps.
## Output
\`\`\`
## Needs reply
- Jen (vendor renewal) — wants pricing by Wed
- Marcus (interview confirm) — needs slot
## Today's three
1. Finish Q3 deck draft
2. Approve onboarding copy
3. 30-min focus block on the API doc
\`\`\``;
return [
{
key: "morning-catchup",
slug: "morning-catchup",
name: "Morning catch-up",
oneLine:
"Summarise overnight emails, Slack DMs, and PR reviews into one digest you can read in two minutes.",
body: morningBody,
status: "pending",
version: 1,
createdAt: now - 2 * minute,
recencyGroup: "today",
ageLabel: "2m",
isNew: true,
supportFiles: [
{
path: "templates/digest.md",
size: "2.1 KB",
contents: `# Morning digest template
Used by morning-catchup when posting the daily summary back to the user. Sections render in this order. Skip any section that has no items.
## Needs reply
Bulleted list. One line each. Format: - {sender} ({why}) — {ask}
Example:
- Jen (vendor renewal) — wants pricing by Wed
- Marcus (interview confirm) — needs slot
## Today's three
A numbered list of three focus items, in priority order. Match against calendar gaps when possible.
1. {top priority — what + why now}
2. {second priority}
3. {third priority — short focus block ok}
## Archived
Optional. One line summary count: Archived 14 items (newsletters, receipts, automated alerts).
## Footer
Always end with the timestamp and how long the catch-up took:
_Catch-up complete · {duration}s · {timestamp}_
`,
},
{
path: "filters/auto-senders.txt",
size: "418 B",
contents: `noreply@*
notifications@github.com
no-reply@*
calendar-notifications@*
reply+*@reply.github.com
account-update@*
billing@*
*receipts@*
mailer-daemon@*
postmaster@*
`,
},
{
path: "prompts/group-by-importance.md",
size: "1.4 KB",
contents: `# Group by importance
Given a set of unread messages, return three buckets:
1. **Needs reply today** — direct asks, time-sensitive threads, anything the user is the
sole owner of.
2. **FYI** — useful context, but not actionable today. Mention briefly without surfacing.
3. **Archive** — newsletters, automated alerts, marketing.
For each item in bucket 1, include:
- sender
- one-line "why now"
- suggested next action
`,
},
],
},
{
key: "birthday-reminders",
slug: "birthday-reminders",
name: "Birthday reminders",
oneLine: "Surface contacts with birthdays in the next 7 days from Google Contacts.",
body: `## When to use
Daily at the start of the day, surface upcoming birthdays so the user can send a quick note.
## Steps
1. Read Google Contacts birthdays for the next 7 days.
2. Group by day and skip duplicates.
3. For each contact, suggest a one-line greeting in the user's voice.
`,
status: "pending",
version: 1,
createdAt: now - 14 * minute,
recencyGroup: "today",
ageLabel: "14m",
isNew: true,
supportFiles: [],
},
{
key: "invoice-followup",
slug: "invoice-followup",
name: "Invoice follow-up",
oneLine: "Draft a polite nudge for invoices unpaid > 14 days.",
body: `## When to use
When AR shows invoices past their net-14 due date and no reply has been received.
## Steps
1. Pull invoices older than 14 days from Stripe / QuickBooks.
2. Cross-reference any payment received since the last sync.
3. Draft a polite reminder per overdue invoice. Do not send.
`,
status: "pending",
version: 2,
createdAt: now - 80 * minute,
updatedAt: now - 60 * minute,
recencyGroup: "today",
ageLabel: "1h",
isNew: true,
supportFiles: [],
},
{
key: "trip-planning",
slug: "trip-planning",
name: "Trip planning",
oneLine: "Take a city + dates, return flights, hotels, and a day-by-day plan.",
body: `## When to use
When the user names a destination and travel window.
## Steps
1. Search flights for the given window.
2. Suggest two hotel tiers near the main activity area.
3. Draft a day-by-day plan with one anchor activity per day.
`,
status: "pending",
version: 1,
createdAt: now - 2 * hour,
recencyGroup: "today",
ageLabel: "2h",
isNew: true,
supportFiles: [],
},
{
key: "screenshot-cleanup",
slug: "screenshot-cleanup",
name: "Screenshot cleanup",
oneLine: "Move screenshots older than 30 days from Desktop to ~/Archive.",
body: `## When to use
Weekly or on demand when the Desktop is cluttered with screenshots.
## Steps
1. List screenshots on Desktop older than 30 days.
2. Move them into ~/Archive/screenshots/{yyyy-mm}/.
3. Report counts moved and any conflicts skipped.
`,
status: "applied",
version: 1,
createdAt: now - 1 * day,
recencyGroup: "yesterday",
ageLabel: "1d",
isNew: false,
supportFiles: [],
},
{
key: "standup-notes",
slug: "standup-notes",
name: "Standup notes",
oneLine: "Generate daily standup from yesterday's git commits + calendar.",
body: `## When to use
Every weekday morning before standup, the user wants a one-screen summary.
## Steps
1. Read yesterday's git commits across pinned repos.
2. Read yesterday's accepted calendar events.
3. Combine into three bullets: yesterday / today / blockers.
`,
status: "pending",
version: 1,
createdAt: now - 1 * day,
recencyGroup: "yesterday",
ageLabel: "1d",
isNew: false,
supportFiles: [],
},
{
key: "repo-cleanup",
slug: "repo-cleanup",
name: "Repo cleanup",
oneLine: "Identify branches merged > 30 days ago, suggest deletion.",
body: `## When to use
Monthly hygiene. The user wants a short list of stale branches to delete.
## Steps
1. List branches across pinned repos.
2. Filter to those merged > 30 days ago.
3. Suggest deletion grouped by repo. Do not delete.
`,
status: "pending",
version: 1,
createdAt: now - 4 * day,
recencyGroup: "earlier",
ageLabel: "4d",
isNew: false,
supportFiles: [],
},
];
}
export function countProposals(
proposals: SkillWorkshopProposal[],
): Record<SkillWorkshopStatusFilter, number> {
const counts: Record<SkillWorkshopStatusFilter, number> = {
all: proposals.length,
pending: 0,
applied: 0,
rejected: 0,
quarantined: 0,
stale: 0,
};
for (const p of proposals) {
counts[p.status] += 1;
}
return counts;
}