mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat: show real Skill Workshop proposals
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
418
ui/src/ui/controllers/skill-workshop.ts
Normal file
418
ui/src/ui/controllers/skill-workshop.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user