mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:40:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -330,6 +330,7 @@ export type AppViewState = {
|
||||
| "cronStatus"
|
||||
| "cronError"
|
||||
| "cronForm"
|
||||
| "cronFormCollapsed"
|
||||
| "cronFieldErrors"
|
||||
| "cronEditingJobId"
|
||||
| "cronRunsJobId"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user