From caa08a6dc046257c3e7f80fda27be7f27d96f275 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 31 May 2026 18:12:00 +0100 Subject: [PATCH] feat: show real Skill Workshop proposals --- ui/src/styles/skill-workshop.css | 13 + ui/src/ui/app-render.ts | 101 ++---- ui/src/ui/app-settings.ts | 8 + ui/src/ui/app-view-state.ts | 7 +- ui/src/ui/app.ts | 7 +- ui/src/ui/controllers/skill-workshop.ts | 418 ++++++++++++++++++++++++ ui/src/ui/views/skill-workshop.ts | 276 +--------------- 7 files changed, 482 insertions(+), 348 deletions(-) create mode 100644 ui/src/ui/controllers/skill-workshop.ts diff --git a/ui/src/styles/skill-workshop.css b/ui/src/styles/skill-workshop.css index 8a23c8d8adb..4cd127d5ac7 100644 --- a/ui/src/styles/skill-workshop.css +++ b/ui/src/styles/skill-workshop.css @@ -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; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index e53f6498ef6..ebeaec71b87 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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( - proposals: T[], - overrides: AppViewState["skillWorkshopStatusOverrides"], -): T[] { - return proposals.map((proposal) => { - const status = overrides[proposal.key]; - return status ? { ...proposal, status } : proposal; - }); -} - function rememberSkillWorkshopProposalReviewed( reviewedKeys: string[], proposal: T, @@ -709,59 +704,6 @@ function rememberSkillWorkshopProposalReviewed 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; diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index f7de6b6cd17..e322f44e706 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -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; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index dc7bd12293b..2066833fc37 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; skillWorkshopRevisionKey: string | null; skillWorkshopRevisionDraft: string; skillWorkshopActionNoticeTimer?: ReturnType | number | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 86f85b1763e..e1337cfd72c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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 = {}; @state() skillWorkshopRevisionKey: string | null = null; @state() skillWorkshopRevisionDraft = ""; skillWorkshopActionNoticeTimer: ReturnType | number | null = null; diff --git a/ui/src/ui/controllers/skill-workshop.ts b/ui/src/ui/controllers/skill-workshop.ts new file mode 100644 index 00000000000..a360db4dff9 --- /dev/null +++ b/ui/src/ui/controllers/skill-workshop.ts @@ -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 | number | null; + skillWorkshopRevisionKey: string | null; + skillWorkshopRevisionDraft: string; + handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise; +}; + +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 { + 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("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 { + 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( + "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 { + state.skillWorkshopLoaded = false; + await loadSkillWorkshopProposals(state, { force: true }); + await loadSkillWorkshopProposalDetail(state, proposalId, { force: true }); +} + +export async function runSkillWorkshopLifecycleAction( + state: SkillWorkshopState, + action: Extract, + proposalId: string, +): Promise { + 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 { + 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; + } + } +} diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/ui/views/skill-workshop.ts index 20968c6b472..0c438548cdb 100644 --- a/ui/src/ui/views/skill-workshop.ts +++ b/ui/src/ui/views/skill-workshop.ts @@ -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`
+ ${props.error ? html`
${props.error}
` : nothing}
${keyed(props.mode, html`
${body}
`)}
@@ -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`
@@ -396,7 +400,9 @@ function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal

${proposal.slug}

- ${renderProposalBody(proposal.body)} + ${detailLoading + ? html`

Loading proposal…

` + : renderProposalBody(proposal.body)}
${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 { - const counts: Record = { - all: proposals.length, - pending: 0, - applied: 0, - rejected: 0, - quarantined: 0, - stale: 0, - }; - for (const p of proposals) { - counts[p.status] += 1; - } - return counts; -}