From bfac12a184d9f300a99bde0fe2cbbda575e5786b Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 31 May 2026 18:46:59 +0100 Subject: [PATCH] feat: route Skill Workshop revisions through reusable sessions --- ui/src/ui/app-render.ts | 296 +++++++++++++++++++++--- ui/src/ui/app-view-state.ts | 2 + ui/src/ui/app.ts | 4 + ui/src/ui/controllers/skill-workshop.ts | 15 +- ui/src/ui/views/skill-workshop.ts | 6 + 5 files changed, 282 insertions(+), 41 deletions(-) diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index ebeaec71b87..15538a2ac49 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -119,6 +119,7 @@ import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; import { branchSessionFromCheckpoint, + createSessionAndRefresh, deleteSessionsAndRefresh, loadSessions, parseSessionsFilterInteger, @@ -496,7 +497,12 @@ const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v const SKILL_WORKSHOP_REVIEWED_KEY = "openclaw:control-ui:skill-workshop-reviewed:v1"; const SKILL_WORKSHOP_QUEUE_WIDTH_KEY = "openclaw:control-ui:skill-workshop-queue-width:v1"; const SKILL_WORKSHOP_MODE_KEY = "openclaw:control-ui:skill-workshop-mode:v1"; +const SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY = + "openclaw:control-ui:skill-workshop-current-chat-revisions:v1"; +const SKILL_WORKSHOP_REVISION_SESSIONS_KEY = + "openclaw:control-ui:skill-workshop-revision-sessions:v1"; const MAX_SKILL_WORKSHOP_REVIEWED_KEYS = 500; +const MAX_SKILL_WORKSHOP_REVISION_SESSIONS = 200; const DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH = 360; const MIN_SKILL_WORKSHOP_QUEUE_WIDTH = 280; const MAX_SKILL_WORKSHOP_QUEUE_WIDTH = 560; @@ -548,12 +554,84 @@ type SkillWorkshopReviewableProposal = { key: string; slug?: string; status: string; + origin?: { + agentId?: string; + sessionKey?: string; + }; version: number; createdAt: number; updatedAt?: number; isNew: boolean; }; +type SkillWorkshopRevisionSessionEntry = { + sessionKey: string; + updatedAt: number; +}; + +export function loadSkillWorkshopUseCurrentChatForRevisions(): boolean { + return getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY) === "true"; +} + +function setSkillWorkshopUseCurrentChatForRevisions(state: AppViewState, enabled: boolean): void { + state.skillWorkshopUseCurrentChatForRevisions = enabled; + try { + getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_CURRENT_CHAT_REVISIONS_KEY, String(enabled)); + } catch { + // Storage is only for the UI preference; the current toggle state still applies. + } +} + +export function loadSkillWorkshopRevisionSessions(): Record< + string, + SkillWorkshopRevisionSessionEntry +> { + const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_REVISION_SESSIONS_KEY); + if (!raw) { + return {}; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return {}; + } + const entries = Object.entries(parsed) + .flatMap(([proposalId, value]) => { + if (!value || typeof value !== "object") { + return []; + } + const record = value as { sessionKey?: unknown; updatedAt?: unknown }; + const sessionKey = normalizeOptionalString(record.sessionKey); + const updatedAt = + typeof record.updatedAt === "number" && Number.isFinite(record.updatedAt) + ? record.updatedAt + : 0; + return proposalId && sessionKey ? [[proposalId, { sessionKey, updatedAt }] as const] : []; + }) + .toSorted((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, MAX_SKILL_WORKSHOP_REVISION_SESSIONS); + return Object.fromEntries(entries); + } catch { + return {}; + } +} + +function saveSkillWorkshopRevisionSessions( + sessions: Record, +): void { + try { + const entries = Object.entries(sessions) + .toSorted((a, b) => b[1].updatedAt - a[1].updatedAt) + .slice(0, MAX_SKILL_WORKSHOP_REVISION_SESSIONS); + getSafeLocalStorage()?.setItem( + SKILL_WORKSHOP_REVISION_SESSIONS_KEY, + JSON.stringify(Object.fromEntries(entries)), + ); + } catch { + // Revision session persistence is a convenience; created sessions remain usable normally. + } +} + export function loadSkillWorkshopReviewedKeys(): string[] { const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_REVIEWED_KEY); if (!raw) { @@ -634,44 +712,59 @@ function setSkillWorkshopMode(state: AppViewState, mode: "board" | "today"): voi } } -function renderSkillWorkshopModeSwitch(state: AppViewState) { +function renderSkillWorkshopHeaderControls(state: AppViewState) { return html` -
- - - + + + +
`; } @@ -704,6 +797,126 @@ function rememberSkillWorkshopProposalReviewed row.key === key); + if (current) { + return current; + } + for (const rows of Object.values(state.chatAgentSessionRowsByAgent ?? {})) { + const cached = rows.find((row) => row.key === key); + if (cached) { + return cached; + } + } + return null; +} + +function isUsableSkillWorkshopRevisionSession( + row: GatewaySessionRow | null, +): row is GatewaySessionRow { + return Boolean(row && !row.archived && !row.hasActiveRun); +} + +function rememberSkillWorkshopRevisionSession( + state: AppViewState, + proposalId: string, + sessionKey: string, +): void { + const key = normalizeOptionalString(proposalId); + const value = normalizeOptionalString(sessionKey); + if (!key || !value) { + return; + } + const next = { + ...state.skillWorkshopRevisionSessions, + [key]: { sessionKey: value, updatedAt: Date.now() }, + }; + state.skillWorkshopRevisionSessions = next; + saveSkillWorkshopRevisionSessions(next); +} + +async function ensureSkillWorkshopRevisionSessionsLoaded( + state: AppViewState, + agentId: string, +): Promise { + const resultAgentId = normalizeOptionalString(state.sessionsResultAgentId); + if (resultAgentId === agentId && state.sessionsResult?.sessions.length) { + return; + } + await loadSessions(state, { + ...createChatSessionsLoadOverrides(state), + agentId, + }); +} + +async function resolveSkillWorkshopRevisionSessionKey( + state: AppViewState, + proposal: SkillWorkshopReviewableProposal, +): Promise { + if (state.skillWorkshopUseCurrentChatForRevisions) { + return normalizeOptionalString(state.sessionKey) ?? null; + } + + const agentId = normalizeAgentId( + proposal.origin?.agentId ?? resolveSidebarSelectedAgentId(state), + ); + await ensureSkillWorkshopRevisionSessionsLoaded(state, agentId); + + const originRow = findSkillWorkshopRevisionSessionRow(state, proposal.origin?.sessionKey); + if (isUsableSkillWorkshopRevisionSession(originRow)) { + return originRow.key; + } + + const mappedSessionKey = state.skillWorkshopRevisionSessions[proposal.key]?.sessionKey; + const mappedRow = findSkillWorkshopRevisionSessionRow(state, mappedSessionKey); + if (isUsableSkillWorkshopRevisionSession(mappedRow)) { + return mappedRow.key; + } + + const labelTarget = normalizeOptionalString(proposal.slug) ?? proposal.key; + const created = await createSessionAndRefresh( + state as unknown as Parameters[0], + { + agentId, + label: `Skill Workshop: ${labelTarget}`.slice(0, 80), + }, + { + ...createChatSessionsLoadOverrides(state), + agentId, + }, + ); + if (created) { + rememberSkillWorkshopRevisionSession(state, proposal.key, created); + } + return created; +} + +async function sendSkillWorkshopRevisionRequest( + state: AppViewState, + message: string, + proposal: SkillWorkshopReviewableProposal, +): Promise { + if (!state.client || !state.connected) { + throw new Error("Gateway is not connected."); + } + const sessionKey = await resolveSkillWorkshopRevisionSessionKey(state, proposal); + if (!sessionKey) { + throw new Error(state.sessionsError ?? "Could not prepare a Skill Workshop session."); + } + switchChatSession(state, sessionKey); + if (state.tab !== "chat") { + state.setTab("chat" as Tab); + } + await state.handleSendChat(message); +} + function loadDismissedUpdateBanner(): DismissedUpdateBanner | null { try { const raw = getSafeLocalStorage()?.getItem(UPDATE_BANNER_DISMISS_KEY); @@ -2200,7 +2413,9 @@ export function renderApp(state: AppViewState) { ${isChat ? nothing : html`
${subtitleForTab(state.tab)}
`}
- ${state.tab === "skillWorkshop" ? renderSkillWorkshopModeSwitch(state) : nothing} + ${state.tab === "skillWorkshop" + ? renderSkillWorkshopHeaderControls(state) + : nothing} ${state.tab === "dreams" ? html`
@@ -3118,7 +3333,12 @@ export function renderApp(state: AppViewState) { if (!state.skillWorkshopRevisionDraft.trim()) { return; } - void requestSkillWorkshopRevision(state, key); + void (async () => { + await loadSkillWorkshopProposalDetail(state, key); + await requestSkillWorkshopRevision(state, key, (message, proposal) => + sendSkillWorkshopRevisionRequest(state, message, proposal), + ); + })(); }, onPreviewFile: (_key, path) => { state.skillWorkshopFilePreviewKey = path; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 2066833fc37..249e03be940 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -439,6 +439,8 @@ export type AppViewState = { skillWorkshopProposals: SkillWorkshopProposal[]; skillWorkshopReviewedKeys: string[]; skillWorkshopQueueWidth: number; + skillWorkshopUseCurrentChatForRevisions: boolean; + skillWorkshopRevisionSessions: Record; skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null; skillWorkshopActionNotice: { key: string; label: string; slug: string } | null; skillWorkshopRevisionKey: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index e1337cfd72c..8be08f7d4a0 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -46,6 +46,8 @@ import { loadSkillWorkshopQueueWidth, loadSkillWorkshopMode, loadSkillWorkshopReviewedKeys, + loadSkillWorkshopRevisionSessions, + loadSkillWorkshopUseCurrentChatForRevisions, renderApp, } from "./app-render.ts"; import { @@ -647,6 +649,8 @@ export class OpenClawApp extends LitElement { @state() skillWorkshopReviewedKeys = loadSkillWorkshopReviewedKeys(); @state() skillWorkshopQueueWidth = loadSkillWorkshopQueueWidth(); @state() skillWorkshopMode: "board" | "today" = loadSkillWorkshopMode(); + @state() skillWorkshopUseCurrentChatForRevisions = loadSkillWorkshopUseCurrentChatForRevisions(); + @state() skillWorkshopRevisionSessions = loadSkillWorkshopRevisionSessions(); @state() skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null = null; @state() skillWorkshopActionNotice: { key: string; label: string; slug: string } | null = null; diff --git a/ui/src/ui/controllers/skill-workshop.ts b/ui/src/ui/controllers/skill-workshop.ts index a360db4dff9..fae30090769 100644 --- a/ui/src/ui/controllers/skill-workshop.ts +++ b/ui/src/ui/controllers/skill-workshop.ts @@ -1,4 +1,3 @@ -import type { ChatSendOptions } from "../app-chat.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; import type { SkillWorkshopAction, @@ -36,6 +35,13 @@ type SkillProposalSupportFileRecord = { sizeBytes: number; }; +type SkillProposalOrigin = { + agentId?: string; + sessionKey?: string; + runId?: string; + messageId?: string; +}; + type SkillProposalRecord = { id: string; kind: SkillProposalKind; @@ -45,6 +51,7 @@ type SkillProposalRecord = { createdAt: string; updatedAt: string; proposedVersion: string; + origin?: SkillProposalOrigin; supportFiles?: SkillProposalSupportFileRecord[]; target: { skillName: string; @@ -77,7 +84,6 @@ export type SkillWorkshopState = { skillWorkshopActionNoticeTimer?: ReturnType | number | null; skillWorkshopRevisionKey: string | null; skillWorkshopRevisionDraft: string; - handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise; }; function getErrorMessage(err: unknown): string { @@ -176,6 +182,7 @@ function proposalFromManifest( oneLine: entry.description, body: previousIsCurrent ? previous.body : "", status: entry.status, + ...(previousIsCurrent && previous.origin ? { origin: previous.origin } : {}), version: previousIsCurrent ? previous.version : 1, createdAt, updatedAt, @@ -200,6 +207,7 @@ function proposalFromInspect( oneLine: record.description, body: stripProposalFrontmatter(result.content), status: record.status, + ...(record.origin ? { origin: record.origin } : {}), version: proposedVersionNumber(record.proposedVersion), createdAt, updatedAt, @@ -388,6 +396,7 @@ function buildRevisionRequest(proposal: SkillWorkshopProposal, instructions: str export async function requestSkillWorkshopRevision( state: SkillWorkshopState, proposalId: string, + sendRevisionRequest: (message: string, proposal: SkillWorkshopProposal) => Promise, ): Promise { if (state.skillWorkshopActionBusy) { return; @@ -401,7 +410,7 @@ export async function requestSkillWorkshopRevision( state.skillWorkshopActionNotice = null; state.skillWorkshopError = null; try { - await state.handleSendChat(buildRevisionRequest(proposal, instructions)); + await sendRevisionRequest(buildRevisionRequest(proposal, instructions), proposal); state.skillWorkshopRevisionKey = null; state.skillWorkshopRevisionDraft = ""; showActionNotice(state, proposal, "Revision requested"); diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/ui/views/skill-workshop.ts index 0c438548cdb..80bc6bcc90f 100644 --- a/ui/src/ui/views/skill-workshop.ts +++ b/ui/src/ui/views/skill-workshop.ts @@ -23,6 +23,12 @@ export type SkillWorkshopProposal = { oneLine: string; body: string; status: SkillWorkshopProposalStatus; + origin?: { + agentId?: string; + sessionKey?: string; + runId?: string; + messageId?: string; + }; version: number; createdAt: number; updatedAt?: number;