feat(control-ui): collapse cron new job panel

Add a collapsible Control UI cron New Job panel so operators can reclaim list space while keeping create/edit one click away.

Verification:
- pnpm exec oxfmt --check --threads=1 CHANGELOG.md ui/src/styles/components.css ui/src/ui/controllers/cron.ts ui/src/ui/controllers/cron.test.ts ui/src/ui/views/cron.ts ui/src/ui/views/cron.test.ts ui/src/ui/app.ts ui/src/ui/app-render.ts ui/src/ui/app-view-state.ts
- pnpm test ui/src/ui/views/cron.test.ts ui/src/ui/controllers/cron.test.ts
- Browser preview at http://localhost:5173/cron
- Testbox check:changed passed guard/type lanes; lint:core hit unrelated existing origin/main sessionsShowArchived Boolean findings.
This commit is contained in:
Val Alexander
2026-05-04 02:46:48 -05:00
committed by GitHub
parent e8d0cf75ea
commit e622223bcd
9 changed files with 175 additions and 11 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Control UI/header: show the active agent name in dashboard breadcrumbs without adding the current session key, keeping non-chat views oriented without crowding the topbar.
- Control UI/cron: make the New Job sidebar collapsible so the jobs list can reclaim space while keeping the form one click away. Thanks @BunsDev.
- Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure.
- Control UI/performance: record browser long animation frame or long task entries in the debug event log when supported, making slow dashboard renders easier to attribute from the UI.
- Channels/streaming: add unified `streaming.mode: "progress"` drafts with auto single-word status labels and shared progress configuration across Discord, Telegram, Matrix, Slack, and Microsoft Teams.

View File

@@ -1008,6 +1008,10 @@
align-items: start;
}
.cron-workspace--form-collapsed {
grid-template-columns: minmax(0, 1fr) 64px;
}
.cron-workspace-main {
display: grid;
gap: 16px;
@@ -1021,12 +1025,62 @@
overflow-y: auto;
}
.cron-workspace-form--collapsed {
min-height: 180px;
overflow: hidden;
padding: 10px;
}
.cron-form-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.cron-form-header__copy {
min-width: 0;
}
.cron-form-collapse-toggle {
flex: 0 0 auto;
width: 34px;
height: 34px;
padding: 0;
justify-content: center;
}
.cron-workspace-form--collapsed .cron-form-header {
min-height: 160px;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
.cron-workspace-form--collapsed .cron-form-header__copy {
display: flex;
justify-content: center;
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
}
.cron-workspace-form--collapsed .card-title {
font-size: 13px;
}
.cron-form {
margin-top: 16px;
display: grid;
gap: 14px;
}
.cron-form[hidden],
.cron-form-status[hidden],
.cron-form-actions[hidden] {
display: none;
}
.cron-form-section {
border: 1px solid var(--border);
border-radius: var(--radius-md);
@@ -1399,6 +1453,25 @@
order: -1;
}
.cron-workspace--form-collapsed {
grid-template-columns: 1fr;
}
.cron-workspace-form--collapsed {
min-height: 0;
}
.cron-workspace-form--collapsed .cron-form-header {
min-height: 0;
flex-direction: row;
align-items: center;
}
.cron-workspace-form--collapsed .cron-form-header__copy {
writing-mode: horizontal-tb;
transform: none;
}
.cron-form-grid {
grid-template-columns: 1fr;
gap: 12px;

View File

@@ -1814,6 +1814,7 @@ export function renderApp(state: AppViewState) {
error: state.cronError,
busy: state.cronBusy,
form: state.cronForm,
cronFormCollapsed: state.cronFormCollapsed,
channels: state.channelsSnapshot?.channelMeta?.length
? state.channelsSnapshot.channelMeta.map((entry) => entry.id)
: (state.channelsSnapshot?.channelOrder ?? []),
@@ -1844,9 +1845,18 @@ export function renderApp(state: AppViewState) {
},
onRefresh: () => state.loadCron(),
onAdd: () => addCronJob(state),
onEdit: (job) => startCronEdit(state, job),
onClone: (job) => startCronClone(state, job),
onEdit: (job) => {
state.cronFormCollapsed = false;
startCronEdit(state, job);
},
onClone: (job) => {
state.cronFormCollapsed = false;
startCronClone(state, job);
},
onCancelEdit: () => cancelCronEdit(state),
onToggleFormCollapsed: (collapsed) => {
state.cronFormCollapsed = collapsed;
},
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job, mode) => runCronJob(state, job, mode ?? "force"),
onRemove: (job) => removeCronJob(state, job),

View File

@@ -330,6 +330,7 @@ export type AppViewState = {
| "cronStatus"
| "cronError"
| "cronForm"
| "cronFormCollapsed"
| "cronFieldErrors"
| "cronEditingJobId"
| "cronRunsJobId"

View File

@@ -471,6 +471,7 @@ export class OpenClawApp extends LitElement {
@state() cronStatus: CronStatus | null = null;
@state() cronError: string | null = null;
@state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
@state() cronFormCollapsed = false;
@state() cronFieldErrors: import("./controllers/cron.js").CronFieldErrors = {};
@state() cronEditingJobId: string | null = null;
@state() cronRunsJobId: string | null = null;

View File

@@ -38,6 +38,7 @@ function createState(overrides: Partial<CronState> = {}): CronState {
cronStatus: null,
cronError: null,
cronForm: { ...DEFAULT_CRON_FORM },
cronFormCollapsed: false,
cronFieldErrors: {},
cronEditingJobId: null,
cronRunsJobId: null,

View File

@@ -67,6 +67,7 @@ export type CronState = {
cronStatus: CronStatus | null;
cronError: string | null;
cronForm: CronFormState;
cronFormCollapsed: boolean;
cronFieldErrors: CronFieldErrors;
cronEditingJobId: string | null;
cronRunsJobId: string | null;

View File

@@ -277,6 +277,45 @@ describe("cron view", () => {
expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull();
});
it("collapses the new job sidebar without rendering the full form", () => {
const container = document.createElement("div");
const onToggleFormCollapsed = vi.fn();
const expandedProps = createProps() as CronProps & {
cronFormCollapsed: boolean;
onToggleFormCollapsed: (collapsed: boolean) => void;
};
expandedProps.cronFormCollapsed = false;
expandedProps.onToggleFormCollapsed = onToggleFormCollapsed;
render(renderCron(expandedProps), container);
const collapseButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]');
expect(collapseButton).not.toBeNull();
expect(collapseButton?.getAttribute("aria-expanded")).toBe("true");
collapseButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onToggleFormCollapsed).toHaveBeenCalledWith(true);
expect(container.querySelector(".cron-form")).not.toBeNull();
const collapsedProps = createProps() as CronProps & {
cronFormCollapsed: boolean;
onToggleFormCollapsed: (collapsed: boolean) => void;
};
collapsedProps.cronFormCollapsed = true;
collapsedProps.onToggleFormCollapsed = onToggleFormCollapsed;
render(renderCron(collapsedProps), container);
const collapsedButton = container.querySelector('[data-test-id="cron-form-collapse-toggle"]');
expect(container.querySelector(".cron-workspace--form-collapsed")).not.toBeNull();
expect(container.querySelector(".cron-workspace-form--collapsed")).not.toBeNull();
expect(collapsedButton?.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(".cron-form")?.hasAttribute("hidden")).toBe(true);
expect(container.querySelector(".cron-form-actions")?.hasAttribute("hidden")).toBe(true);
collapsedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onToggleFormCollapsed).toHaveBeenLastCalledWith(false);
});
it("shows webhook delivery details for jobs", () => {
const container = document.createElement("div");
const job = {

View File

@@ -45,6 +45,7 @@ export type CronProps = {
fieldErrors: CronFieldErrors;
canSubmit: boolean;
editingJobId: string | null;
cronFormCollapsed?: boolean;
channels: string[];
channelLabels?: Record<string, string>;
channelMeta?: ChannelUiMetaEntry[];
@@ -71,6 +72,7 @@ export type CronProps = {
onEdit: (job: CronJob) => void;
onClone: (job: CronJob) => void;
onCancelEdit: () => void;
onToggleFormCollapsed?: (collapsed: boolean) => void;
onToggle: (job: CronJob, enabled: boolean) => void;
onRun: (job: CronJob, mode?: "force" | "due") => void;
onRemove: (job: CronJob) => void;
@@ -383,6 +385,9 @@ export function renderCron(props: CronProps) {
props.form.sessionTarget !== "main" && props.form.payloadKind === "agentTurn";
const selectedDeliveryMode =
props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode;
const formCollapsed = props.cronFormCollapsed === true;
const formTitle = isEditing ? t("cron.form.editJob") : t("cron.form.newJob");
const toggleFormCollapsed = props.onToggleFormCollapsed;
const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode);
const blockedByValidation = !props.busy && blockingFields.length > 0;
const hasActiveJobsFilters =
@@ -437,7 +442,7 @@ export function renderCron(props: CronProps) {
</div>
</section>
<section class="cron-workspace">
<section class=${`cron-workspace ${formCollapsed ? "cron-workspace--form-collapsed" : ""}`}>
<div class="cron-workspace-main">
<section class="card">
<div
@@ -707,12 +712,37 @@ export function renderCron(props: CronProps) {
</section>
</div>
<section class="card cron-workspace-form">
<div class="card-title">${isEditing ? t("cron.form.editJob") : t("cron.form.newJob")}</div>
<div class="card-sub">
${isEditing ? t("cron.form.updateSubtitle") : t("cron.form.createSubtitle")}
<section
class=${`card cron-workspace-form ${formCollapsed ? "cron-workspace-form--collapsed" : ""}`}
>
<div class="cron-form-header">
<div class="cron-form-header__copy">
<div class="card-title">${formTitle}</div>
${formCollapsed
? nothing
: html`
<div class="card-sub">
${isEditing ? t("cron.form.updateSubtitle") : t("cron.form.createSubtitle")}
</div>
`}
</div>
${toggleFormCollapsed
? html`
<button
type="button"
class="btn cron-form-collapse-toggle"
data-test-id="cron-form-collapse-toggle"
title=${formCollapsed ? t("nav.expand") : t("nav.collapse")}
aria-label=${formCollapsed ? t("nav.expand") : t("nav.collapse")}
aria-expanded=${formCollapsed ? "false" : "true"}
@click=${() => toggleFormCollapsed(!formCollapsed)}
>
<span aria-hidden="true">${formCollapsed ? "<" : ">"}</span>
</button>
`
: nothing}
</div>
<div class="cron-form">
<div class="cron-form" ?hidden=${formCollapsed}>
<div class="cron-required-legend">
<span class="cron-required-marker" aria-hidden="true">*</span> ${t(
"cron.form.required",
@@ -1317,7 +1347,12 @@ export function renderCron(props: CronProps) {
</div>
${blockedByValidation
? html`
<div class="cron-form-status" role="status" aria-live="polite">
<div
class="cron-form-status"
role="status"
aria-live="polite"
?hidden=${formCollapsed}
>
<div class="cron-form-status__title">${t("cron.form.cantAddYet")}</div>
<div class="cron-help">${t("cron.form.fillRequired")}</div>
<ul class="cron-form-status__list">
@@ -1338,7 +1373,7 @@ export function renderCron(props: CronProps) {
</div>
`
: nothing}
<div class="row cron-form-actions">
<div class="row cron-form-actions" ?hidden=${formCollapsed}>
<button
class="btn primary"
?disabled=${props.busy || !props.canSubmit}
@@ -1351,7 +1386,9 @@ export function renderCron(props: CronProps) {
: t("cron.form.addJob")}
</button>
${submitDisabledReason
? html`<div class="cron-submit-reason" aria-live="polite">${submitDisabledReason}</div>`
? html`
<div class="cron-submit-reason" aria-live="polite">${submitDisabledReason}</div>
`
: nothing}
${isEditing
? html`