feat: route Skill Workshop revisions through reusable sessions

This commit is contained in:
Shakker
2026-05-31 18:46:59 +01:00
committed by Shakker
parent cdcc151145
commit bfac12a184
5 changed files with 282 additions and 41 deletions

View File

@@ -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<string, SkillWorkshopRevisionSessionEntry>,
): 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`
<div
class="sw-mode-switch"
role="tablist"
aria-label="Workshop view"
data-mode=${state.skillWorkshopMode}
>
<button
class="sw-mode-switch__opt ${state.skillWorkshopMode === "board" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "board"}
title="Board view"
@click=${() => setSkillWorkshopMode(state, "board")}
<div class="sw-header-controls">
<label class="sw-revision-session-toggle">
<input
type="checkbox"
.checked=${state.skillWorkshopUseCurrentChatForRevisions}
@change=${(event: Event) =>
setSkillWorkshopUseCurrentChatForRevisions(
state,
(event.currentTarget as HTMLInputElement).checked,
)}
/>
<span class="sw-revision-session-toggle__track" aria-hidden="true"></span>
<span class="sw-revision-session-toggle__label">Use current chat</span>
</label>
<div
class="sw-mode-switch"
role="tablist"
aria-label="Workshop view"
data-mode=${state.skillWorkshopMode}
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<rect x="3" y="4" width="7" height="16" rx="1.5" />
<rect x="14" y="4" width="7" height="9" rx="1.5" />
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>Board</span>
</button>
<button
class="sw-mode-switch__opt ${state.skillWorkshopMode === "today" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "today"}
title="Today view"
@click=${() => setSkillWorkshopMode(state, "today")}
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"
/>
</svg>
<span>Today</span>
</button>
<span class="sw-mode-switch__indicator" aria-hidden="true"></span>
<button
class="sw-mode-switch__opt ${state.skillWorkshopMode === "board" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "board"}
title="Board view"
@click=${() => setSkillWorkshopMode(state, "board")}
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<rect x="3" y="4" width="7" height="16" rx="1.5" />
<rect x="14" y="4" width="7" height="9" rx="1.5" />
<rect x="14" y="15" width="7" height="5" rx="1.5" />
</svg>
<span>Board</span>
</button>
<button
class="sw-mode-switch__opt ${state.skillWorkshopMode === "today" ? "is-active" : ""}"
role="tab"
aria-selected=${state.skillWorkshopMode === "today"}
title="Today view"
@click=${() => setSkillWorkshopMode(state, "today")}
>
<svg viewBox="0 0 24 24" class="sw-mode-switch__icon" aria-hidden="true">
<circle cx="12" cy="12" r="4" />
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4 7 17M17 7l1.4-1.4"
/>
</svg>
<span>Today</span>
</button>
<span class="sw-mode-switch__indicator" aria-hidden="true"></span>
</div>
</div>
`;
}
@@ -704,6 +797,126 @@ function rememberSkillWorkshopProposalReviewed<T extends SkillWorkshopReviewable
return next;
}
function findSkillWorkshopRevisionSessionRow(
state: AppViewState,
sessionKey: string | undefined,
): GatewaySessionRow | null {
const key = normalizeOptionalString(sessionKey);
if (!key) {
return null;
}
const current = state.sessionsResult?.sessions.find((row) => 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<void> {
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<string | null> {
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<typeof createSessionAndRefresh>[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<void> {
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`<div class="page-sub">${subtitleForTab(state.tab)}</div>`}
</div>
<div class="page-meta">
${state.tab === "skillWorkshop" ? renderSkillWorkshopModeSwitch(state) : nothing}
${state.tab === "skillWorkshop"
? renderSkillWorkshopHeaderControls(state)
: nothing}
${state.tab === "dreams"
? html`
<div class="dreaming-header-controls">
@@ -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;

View File

@@ -439,6 +439,8 @@ export type AppViewState = {
skillWorkshopProposals: SkillWorkshopProposal[];
skillWorkshopReviewedKeys: string[];
skillWorkshopQueueWidth: number;
skillWorkshopUseCurrentChatForRevisions: boolean;
skillWorkshopRevisionSessions: Record<string, { sessionKey: string; updatedAt: number }>;
skillWorkshopActionBusy: { key: string; action: "apply" | "revise" | "reject" } | null;
skillWorkshopActionNotice: { key: string; label: string; slug: string } | null;
skillWorkshopRevisionKey: string | null;

View File

@@ -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;

View File

@@ -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<typeof globalThis.setTimeout> | number | null;
skillWorkshopRevisionKey: string | null;
skillWorkshopRevisionDraft: string;
handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise<void>;
};
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<void>,
): Promise<void> {
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");

View File

@@ -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;