From b12724b79b5e159f017fdf21006aa60d5d70ebcc Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 31 May 2026 12:39:42 +0100 Subject: [PATCH] feat: add Skill Workshop demo view --- ui/src/ui/app-render.helpers.ts | 9 +- ui/src/ui/app-render.ts | 54 ++- ui/src/ui/app-view-state.ts | 4 + ui/src/ui/app.ts | 11 + ui/src/ui/views/skill-workshop.ts | 733 ++++++++++++++++++++++++++++++ 5 files changed, 806 insertions(+), 5 deletions(-) create mode 100644 ui/src/ui/views/skill-workshop.ts diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 2b9a21a9d6b..8403a18e0df 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -199,14 +199,19 @@ const NEW_CHAT_SESSIONS_LOADING_MESSAGE = const NEW_CHAT_CREATE_FAILED_MESSAGE = "New Chat could not create a new session. Try again in a moment."; -export function renderTab(state: AppViewState, tab: Tab, opts?: { collapsed?: boolean }) { +export function renderTab( + state: AppViewState, + tab: Tab, + opts?: { collapsed?: boolean; child?: boolean }, +) { const href = pathForTab(tab, state.basePath); const isActive = tab === "config" ? isSettingsTab(state.tab) : state.tab === tab; const collapsed = opts?.collapsed ?? state.settings.navCollapsed; + const isChild = opts?.child === true; return html` { if ( event.defaultPrevented || diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 6efc95adfb8..f8d594c09d1 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -153,6 +153,8 @@ import { pathForTab, SETTINGS_TABS, TAB_GROUPS, + childTabsOf, + isChildTab, subtitleForTab, titleForTab, type Tab, @@ -444,6 +446,10 @@ const lazyLogs = createLazyView(() => import("./views/logs.ts"), notifyLazyViewC const lazyNodes = createLazyView(() => import("./views/nodes.ts"), notifyLazyViewChanged); const lazySessions = createLazyView(() => import("./views/sessions.ts"), notifyLazyViewChanged); const lazySkills = createLazyView(() => import("./views/skills.ts"), notifyLazyViewChanged); +const lazySkillWorkshop = createLazyView( + () => import("./views/skill-workshop.ts"), + notifyLazyViewChanged, +); const lazyWorkboard = createLazyView(() => import("./views/workboard.ts"), notifyLazyViewChanged); export function formatDreamNextCycle(nextRunAtMs: number | undefined): string | null { @@ -1911,9 +1917,14 @@ export function renderApp(state: AppViewState) { ` : nothing} `; @@ -2846,6 +2857,43 @@ export function renderApp(state: AppViewState) { }), ) : nothing} + ${state.tab === "skillWorkshop" + ? renderLazyView(lazySkillWorkshop, (m) => { + const proposals = m.getDemoSkillWorkshopProposals(); + const counts = m.countProposals(proposals); + const selectedKey = state.skillWorkshopSelectedKey ?? proposals[0]?.key ?? null; + const currentIndex = proposals.findIndex((p) => p.key === selectedKey); + const goto = (offset: number) => { + if (proposals.length === 0) return; + const idx = currentIndex < 0 ? 0 : currentIndex; + const next = (idx + offset + proposals.length) % proposals.length; + state.skillWorkshopSelectedKey = proposals[next].key; + }; + return m.renderSkillWorkshop({ + loading: false, + proposals, + selectedKey, + statusFilter: state.skillWorkshopStatusFilter, + query: state.skillWorkshopQuery, + filePreviewKey: state.skillWorkshopFilePreviewKey, + counts, + onStatusFilterChange: (next) => (state.skillWorkshopStatusFilter = next), + onQueryChange: (next) => (state.skillWorkshopQuery = next), + onSelect: (key) => { + state.skillWorkshopSelectedKey = key; + state.skillWorkshopFilePreviewKey = null; + }, + onPrev: () => goto(-1), + onNext: () => goto(1), + onApply: () => {}, + onRevise: () => {}, + onSetAside: () => {}, + onReject: () => {}, + onPreviewFile: (_key, path) => (state.skillWorkshopFilePreviewKey = path), + onClosePreview: () => (state.skillWorkshopFilePreviewKey = null), + }); + }) + : nothing} ${state.tab === "nodes" ? renderLazyView(lazyNodes, (m) => m.renderNodes({ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index f36285b3ffe..5ba2e068398 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -425,6 +425,10 @@ export type AppViewState = { skillCardContentKeys: Record; skillCardLoadingKey: string | null; skillCardErrors: Record; + skillWorkshopSelectedKey: string | null; + skillWorkshopStatusFilter: "all" | "pending" | "applied" | "rejected" | "quarantined" | "stale"; + skillWorkshopQuery: string; + skillWorkshopFilePreviewKey: string | null; healthLoading: boolean; healthResult: HealthSummary | null; healthError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 736178a4b09..30ccce8b964 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -622,6 +622,17 @@ export class OpenClawApp extends LitElement { @state() skillCardLoadingKey: string | null = null; @state() skillCardErrors: Record = {}; + @state() skillWorkshopSelectedKey: string | null = null; + @state() skillWorkshopStatusFilter: + | "all" + | "pending" + | "applied" + | "rejected" + | "quarantined" + | "stale" = "all"; + @state() skillWorkshopQuery = ""; + @state() skillWorkshopFilePreviewKey: string | null = null; + @state() healthLoading = false; @state() healthResult: HealthSummary | null = null; @state() healthError: string | null = null; diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/ui/views/skill-workshop.ts new file mode 100644 index 00000000000..51ec13f837b --- /dev/null +++ b/ui/src/ui/views/skill-workshop.ts @@ -0,0 +1,733 @@ +import { html, nothing } from "lit"; + +export type SkillWorkshopProposalStatus = + | "pending" + | "applied" + | "rejected" + | "quarantined" + | "stale"; + +export type SkillWorkshopFile = { + path: string; + size: string; + contents: string; +}; + +export type SkillWorkshopProposal = { + key: string; + slug: string; + name: string; + oneLine: string; + body: string; + status: SkillWorkshopProposalStatus; + version: number; + createdAt: number; + updatedAt?: number; + recencyGroup: "today" | "yesterday" | "earlier"; + ageLabel: string; + supportFiles: SkillWorkshopFile[]; + isNew: boolean; +}; + +export type SkillWorkshopStatusFilter = "all" | SkillWorkshopProposalStatus; + +export type SkillWorkshopProps = { + loading: boolean; + proposals: SkillWorkshopProposal[]; + selectedKey: string | null; + statusFilter: SkillWorkshopStatusFilter; + query: string; + filePreviewKey: string | null; + counts: Record; + onStatusFilterChange: (status: SkillWorkshopStatusFilter) => void; + onQueryChange: (query: string) => void; + onSelect: (key: string) => void; + onPrev: () => void; + onNext: () => void; + onApply: (key: string) => void; + onRevise: (key: string) => void; + onSetAside: (key: string) => void; + onReject: (key: string) => void; + onPreviewFile: (key: string, path: string) => void; + onClosePreview: () => void; +}; + +const STATUS_TABS: SkillWorkshopStatusFilter[] = [ + "all", + "pending", + "applied", + "rejected", + "quarantined", + "stale", +]; + +const STATUS_LABEL: Record = { + all: "All", + pending: "Pending", + applied: "Applied", + rejected: "Rejected", + quarantined: "Quarantined", + stale: "Stale", +}; + +const GROUP_LABEL: Record = { + today: "Today", + yesterday: "Yesterday", + earlier: "Earlier this week", +}; + +export function renderSkillWorkshop(props: SkillWorkshopProps) { + const filtered = filterProposals(props.proposals, props.statusFilter, props.query); + const selected = filtered.find((p) => p.key === props.selectedKey) ?? filtered[0]; + const groups = groupByRecency(filtered); + const preview = + selected && props.filePreviewKey + ? selected.supportFiles.find((f) => f.path === props.filePreviewKey) + : null; + + return html` +
+ ${renderLifecycleTabs(props)} +
+ ${renderQueue(props, groups, selected)} + ${selected ? renderDetail(props, selected) : renderEmpty()} +
+
+ ${preview && selected ? renderFilePreview(selected, preview, props.onClosePreview) : nothing} + `; +} + +function renderLifecycleTabs(props: SkillWorkshopProps) { + return html` +
+ ${STATUS_TABS.map((status) => { + const isActive = props.statusFilter === status; + const count = props.counts[status] ?? 0; + return html` + + `; + })} +
+ `; +} + +function renderQueue( + props: SkillWorkshopProps, + groups: Array<{ label: string; items: SkillWorkshopProposal[] }>, + selected: SkillWorkshopProposal | undefined, +) { + const total = groups.reduce((sum, g) => sum + g.items.length, 0); + + return html` + + `; +} + +function renderRow( + props: SkillWorkshopProps, + proposal: SkillWorkshopProposal, + selected: SkillWorkshopProposal | undefined, +) { + const isSelected = selected?.key === proposal.key; + const noveltyClass = proposal.isNew ? "is-new" : "is-seen"; + return html` + + `; +} + +function renderDetail(props: SkillWorkshopProps, proposal: SkillWorkshopProposal) { + const createdLabel = proposal.updatedAt + ? `Edited ${formatRelative(proposal.updatedAt)}` + : `Created ${formatRelative(proposal.createdAt)}`; + + return html` +
+
+
+

${proposal.name}

+
${proposal.oneLine}
+
+ ${createdLabel} + · + v${proposal.version} + · + ${proposal.supportFiles.length} support files +
+
+
+ + +
+
+ +
+
+

${proposal.slug}

+ ${renderProposalBody(proposal.body)} +
+ + ${proposal.supportFiles.length > 0 + ? html` +
+ +
+ ${proposal.supportFiles.map( + (file) => html` + + `, + )} +
+
+ ` + : nothing} +
+ +
+ + + + + +
+ J next + K prev + X multi-select + ? shortcuts +
+
+
+ `; +} + +function renderEmpty() { + return html` +
+

No proposals match

+

+ Try a different lifecycle tab or clear the search to see everything. +

+
+ `; +} + +function renderFilePreview( + proposal: SkillWorkshopProposal, + file: SkillWorkshopFile, + onClose: () => void, +) { + const extension = file.path.split(".").pop()?.toUpperCase() ?? "FILE"; + return html` +
+ + `; +} + +function renderProposalBody(body: string) { + const lines = body.split("\n"); + const out: unknown[] = []; + let para: string[] = []; + let list: string[] = []; + let inCode = false; + let codeBuf: string[] = []; + + const flushPara = () => { + if (para.length) { + out.push(html`

${renderInline(para.join(" "))}

`); + para = []; + } + }; + const flushList = () => { + if (list.length) { + const items = list; + out.push(html` +
    + ${items.map((line) => html`
  1. ${renderInline(line)}
  2. `)} +
+ `); + list = []; + } + }; + + for (const raw of lines) { + const line = raw.trimEnd(); + if (line.startsWith("```")) { + flushPara(); + flushList(); + if (inCode) { + out.push(html`
${codeBuf.join("\n")}
`); + codeBuf = []; + inCode = false; + } else { + inCode = true; + } + continue; + } + if (inCode) { + codeBuf.push(raw); + continue; + } + if (line === "") { + flushPara(); + flushList(); + continue; + } + if (line.startsWith("## ")) { + flushPara(); + flushList(); + out.push(html`

${line.slice(3)}

`); + continue; + } + if (line.startsWith("# ")) { + flushPara(); + flushList(); + out.push(html`

${line.slice(2)}

`); + continue; + } + const olMatch = /^\d+\.\s+(.+)/.exec(line); + if (olMatch) { + flushPara(); + list.push(olMatch[1]); + continue; + } + para.push(line); + } + flushPara(); + flushList(); + if (inCode && codeBuf.length) { + out.push(html`
${codeBuf.join("\n")}
`); + } + return out; +} + +// Inline render: handles `code` and **bold** in text segments. +function renderInline(text: string): unknown { + const parts: unknown[] = []; + const re = /(`[^`]+`|\*\*[^*]+\*\*)/g; + let last = 0; + let match: RegExpExecArray | null; + while ((match = re.exec(text))) { + if (match.index > last) { + parts.push(text.slice(last, match.index)); + } + const token = match[0]; + if (token.startsWith("`")) { + parts.push(html`${token.slice(1, -1)}`); + } else { + parts.push(html`${token.slice(2, -2)}`); + } + last = match.index + token.length; + } + if (last < text.length) { + parts.push(text.slice(last)); + } + return parts; +} + +function filterProposals( + proposals: SkillWorkshopProposal[], + statusFilter: SkillWorkshopStatusFilter, + query: string, +): SkillWorkshopProposal[] { + const q = query.trim().toLowerCase(); + 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; + } + } + return true; + }); +} + +function groupByRecency( + proposals: SkillWorkshopProposal[], +): Array<{ label: string; items: SkillWorkshopProposal[] }> { + const buckets = new Map(); + for (const proposal of proposals) { + const list = buckets.get(proposal.recencyGroup) ?? []; + list.push(proposal); + buckets.set(proposal.recencyGroup, list); + } + const order: Array = ["today", "yesterday", "earlier"]; + return order + .filter((key) => buckets.has(key)) + .map((key) => ({ label: GROUP_LABEL[key], items: buckets.get(key) ?? [] })); +} + +function queueEmptyText(props: SkillWorkshopProps): string { + if (props.loading) { + return "Loading proposals…"; + } + if (props.statusFilter !== "all") { + return `No ${STATUS_LABEL[props.statusFilter].toLowerCase()} proposals.`; + } + return "No proposals match the current filter."; +} + +function formatRelative(ms: number): string { + const diff = Math.max(0, Date.now() - ms); + const sec = Math.floor(diff / 1000); + if (sec < 60) { + return `${sec}s ago`; + } + const min = Math.floor(sec / 60); + if (min < 60) { + return `${min} minutes ago`; + } + const hr = Math.floor(min / 60); + if (hr < 24) { + return `${hr}h ago`; + } + const day = Math.floor(hr / 24); + if (day < 7) { + return `${day}d ago`; + } + 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; +}