diff --git a/ui/src/styles/skill-workshop.css b/ui/src/styles/skill-workshop.css index 26e23a9a86a..2daca5c2d7b 100644 --- a/ui/src/styles/skill-workshop.css +++ b/ui/src/styles/skill-workshop.css @@ -75,7 +75,7 @@ /* ── Triage two-pane card ───────────────────────────────────────────── */ .sw-triage { display: grid; - grid-template-columns: 360px 1fr; + grid-template-columns: minmax(260px, var(--sw-queue-width, 360px)) 8px minmax(0, 1fr); gap: 0; flex: 1; min-height: 0; @@ -89,7 +89,6 @@ /* ── Queue (left) ───────────────────────────────────────────────────── */ .sw-queue { - border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; @@ -97,6 +96,20 @@ align-self: stretch; } +.sw-queue-resizer { + border-left: 1px solid var(--border); + border-right: 1px solid var(--border); + cursor: col-resize; + min-width: 8px; + touch-action: none; +} + +.sw-queue-resizer:hover, +.sw-queue-resizer:focus-visible { + background: color-mix(in srgb, var(--accent) 16%, transparent); + outline: none; +} + .sw-queue__head { padding: 14px 16px; border-bottom: 1px solid var(--border); @@ -525,3 +538,13 @@ color: var(--text-strong); text-decoration-color: var(--accent); } + +@media (max-width: 760px) { + .sw-triage { + grid-template-columns: minmax(0, 1fr); + } + + .sw-queue-resizer { + display: none; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 6742179cf0f..50355f6cef5 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -487,7 +487,11 @@ let clawhubSearchTimer: ReturnType | null = null; const UPDATE_BANNER_DISMISS_KEY = "openclaw:control-ui:update-banner-dismissed:v1"; 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 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 CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; const CRON_TIMEZONE_SUGGESTIONS = [ "UTC", @@ -570,6 +574,40 @@ function saveSkillWorkshopReviewedKeys(keys: string[]): void { } } +export function loadSkillWorkshopQueueWidth(): number { + const raw = getSafeLocalStorage()?.getItem(SKILL_WORKSHOP_QUEUE_WIDTH_KEY); + if (!raw) { + return DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH; + } + return clampSkillWorkshopQueueWidth(Number(raw)); +} + +function clampSkillWorkshopQueueWidth(width: number): number { + if (!Number.isFinite(width)) { + return DEFAULT_SKILL_WORKSHOP_QUEUE_WIDTH; + } + return Math.min( + MAX_SKILL_WORKSHOP_QUEUE_WIDTH, + Math.max(MIN_SKILL_WORKSHOP_QUEUE_WIDTH, Math.round(width)), + ); +} + +function setSkillWorkshopQueueWidth( + state: AppViewState, + width: number, + options?: { persist?: boolean }, +): void { + const next = clampSkillWorkshopQueueWidth(width); + state.skillWorkshopQueueWidth = next; + if (options?.persist) { + try { + getSafeLocalStorage()?.setItem(SKILL_WORKSHOP_QUEUE_WIDTH_KEY, String(next)); + } catch { + // Width persistence is a convenience; the current drag state still applies. + } + } +} + function skillWorkshopReviewKey(proposal: SkillWorkshopReviewableProposal): string { return `${proposal.key}:${proposal.version}:${proposal.updatedAt ?? proposal.createdAt}`; } @@ -2956,10 +2994,14 @@ export function renderApp(state: AppViewState) { query: state.skillWorkshopQuery, filePreviewKey: state.skillWorkshopFilePreviewKey, filePreviewQuery: state.skillWorkshopFilePreviewQuery, + queueWidth: state.skillWorkshopQueueWidth, counts, onStatusFilterChange: (next) => (state.skillWorkshopStatusFilter = next), onQueryChange: (next) => (state.skillWorkshopQuery = next), onFilePreviewQueryChange: (next) => (state.skillWorkshopFilePreviewQuery = next), + onQueueWidthChange: (width) => setSkillWorkshopQueueWidth(state, width), + onQueueWidthCommit: (width) => + setSkillWorkshopQueueWidth(state, width, { persist: true }), onSelect: (key) => { const proposal = proposals.find((p) => p.key === key); state.skillWorkshopSelectedKey = key; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 4de0ccfd317..cfd78562e28 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -431,6 +431,7 @@ export type AppViewState = { skillWorkshopFilePreviewKey: string | null; skillWorkshopFilePreviewQuery: string; skillWorkshopReviewedKeys: string[]; + skillWorkshopQueueWidth: number; healthLoading: boolean; healthResult: HealthSummary | null; healthError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9baaeecf00d..76b05da49dd 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -42,7 +42,11 @@ import { } from "./app-lifecycle.ts"; import { initNativeBridge } from "./app-native-bridge.ts"; import { createChatSession as createChatSessionInternal } from "./app-render.helpers.ts"; -import { loadSkillWorkshopReviewedKeys, renderApp } from "./app-render.ts"; +import { + loadSkillWorkshopQueueWidth, + loadSkillWorkshopReviewedKeys, + renderApp, +} from "./app-render.ts"; import { exportLogs as exportLogsInternal, handleActivityScroll as handleActivityScrollInternal, @@ -634,6 +638,7 @@ export class OpenClawApp extends LitElement { @state() skillWorkshopFilePreviewKey: string | null = null; @state() skillWorkshopFilePreviewQuery = ""; @state() skillWorkshopReviewedKeys = loadSkillWorkshopReviewedKeys(); + @state() skillWorkshopQueueWidth = loadSkillWorkshopQueueWidth(); @state() healthLoading = false; @state() healthResult: HealthSummary | null = null; diff --git a/ui/src/ui/views/skill-workshop.ts b/ui/src/ui/views/skill-workshop.ts index b4270677efa..995276a19ce 100644 --- a/ui/src/ui/views/skill-workshop.ts +++ b/ui/src/ui/views/skill-workshop.ts @@ -1,4 +1,5 @@ import { html, nothing } from "lit"; +import { styleMap } from "lit/directives/style-map.js"; import "../components/file-preview-modal.ts"; export type SkillWorkshopProposalStatus = @@ -40,10 +41,13 @@ export type SkillWorkshopProps = { query: string; filePreviewKey: string | null; filePreviewQuery: string; + queueWidth: number; counts: Record; onStatusFilterChange: (status: SkillWorkshopStatusFilter) => void; onQueryChange: (query: string) => void; onFilePreviewQueryChange: (query: string) => void; + onQueueWidthChange: (width: number) => void; + onQueueWidthCommit: (width: number) => void; onSelect: (key: string) => void; onPrev: () => void; onNext: () => void; @@ -90,8 +94,8 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) { return html`
${renderLifecycleTabs(props)} -
- ${renderQueue(props, groups, selected)} +
+ ${renderQueue(props, groups, selected)} ${renderQueueResizer(props)} ${selected ? renderDetail(props, selected) : renderEmpty()}
@@ -113,6 +117,65 @@ export function renderSkillWorkshop(props: SkillWorkshopProps) { `; } +function renderQueueResizer(props: SkillWorkshopProps) { + return html` + + `; +} + +function startQueueResize(event: PointerEvent, props: SkillWorkshopProps): void { + event.preventDefault(); + event.stopPropagation(); + + const startX = event.clientX; + const startWidth = props.queueWidth; + let currentWidth = startWidth; + const body = document.body; + const previousCursor = body.style.cursor; + const previousUserSelect = body.style.userSelect; + body.style.cursor = "col-resize"; + body.style.userSelect = "none"; + + const cleanup = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + body.style.cursor = previousCursor; + body.style.userSelect = previousUserSelect; + }; + + const onMove = (moveEvent: PointerEvent) => { + currentWidth = startWidth + moveEvent.clientX - startX; + props.onQueueWidthChange(currentWidth); + }; + + const onUp = () => { + cleanup(); + props.onQueueWidthCommit(currentWidth); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); +} + +function resizeQueueWithKeyboard(event: KeyboardEvent, props: SkillWorkshopProps): void { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { + return; + } + event.preventDefault(); + const delta = event.key === "ArrowLeft" ? -24 : 24; + props.onQueueWidthCommit(props.queueWidth + delta); +} + function renderLifecycleTabs(props: SkillWorkshopProps) { return html`