Background Job Toasts — OpenClaw Control UI
Real-time bottom-right toast bar that shows running / complete / error status for all background processes in the OpenClaw Control UI: cron jobs, memory compaction, knowledge extraction, and any consumer that pushes to backgroundJobToasts.
Status: ✅ Active
| Component | Status |
|---|---|
| Toast bar (bottom-right) | ✅ Working |
| Cron job name enrichment (gateway) | ✅ Working |
| Context gauge spinner during compaction | ✅ Working |
| Compaction modal phase labels + progress bar | ✅ Working |
| Auto-dismiss after completion | ✅ Working |
Architecture
Data Type (ui/src/ui/app-view-state.ts)
export type BackgroundJobToast = {
jobId: string; // Unique identifier (use "cmp-" prefix for compaction)
jobName: string; // Human-readable label shown in the toast
status: "running" | "complete" | "error";
startedAt: number; // Date.now()
completedAt: number | null;
errorMsg?: string;
};
backgroundJobToasts: BackgroundJobToast[] is a LitElement @state() property on OpenClawApp (app.ts). Assigning to it directly triggers an automatic re-render — no requestUpdate() needed for the toast array itself.
Toast Rendering (ui/src/ui/app-render.ts)
renderBackgroundJobToasts(state) renders the .bg-job-toasts container at the bottom of the app shell (called in the root render() function). It maps each toast to a .bg-job-toast--{status} div with an icon, name, and label.
Status indicators:
running→ spinner icon (CSS animationcompaction-spin), blue tint, "running..."complete→ checkmark icon, green tint, "done"error→ X icon, red tint, "failed"
Styles (ui/src/styles/components.css)
.bg-job-toasts { position: fixed; bottom: 24px; right: 24px; z-index: 9000; … }
.bg-job-toast { border-radius: var(--radius-lg); background: var(--panel-strong); … }
.bg-job-toast--running { color: var(--info); … }
.bg-job-toast--complete { color: var(--ok); … }
.bg-job-toast--error { color: var(--danger); … }
Entry animation: bg-job-toast-in (0.2s, slide up from 6px).
Gateway Cron Event Enrichment (src/gateway/server-cron.ts)
The gateway's onEvent callback enriches cron events with the job name before broadcasting, so the UI can display a meaningful label even when the Cron tab hasn't been opened yet (and thus cronJobs isn't loaded):
onEvent: (evt) => {
const jobForName = cron.getJob(evt.jobId);
const enriched = jobForName?.name ? { ...evt, name: jobForName.name } : evt;
params.broadcast("cron", enriched, { dropIfSlow: true });
// ... rest of webhook handling
}
UI Cron Event Handler (ui/src/ui/app-gateway.ts)
const cronPayload = evt.payload as {
jobId?: string;
name?: string; // ← enriched by gateway
action?: string;
status?: string;
error?: string;
};
// Priority: event.name > loaded cronJobs list > fallback
const jobName = cronPayload.name ?? matchedJob?.name ?? "Background job";
Context Gauge + Compaction Button (ui/src/ui/app-render.helpers.ts)
The renderContextGauge(state) function renders a circular SVG ring in the chat toolbar. When compaction is running:
- The gauge ring is replaced by a spinning
⟳icon - A
compactState.phaseoverlay modal appears with live status
Phase variables must be defined before the template:
const phaseLabel =
compactState.phase === "running"
? "Summarizing your conversation…"
: compactState.phase === "complete"
? "Done! Context has been reduced."
: compactState.phase === "error"
? "Something went wrong."
: "";
const isWorking = compactState.phase === "running";
compactState is read from (state as unknown as Record<string, unknown>).__compactState — a plain property (not @state()), so app.requestUpdate() must be called explicitly after mutations.
Compaction flow:
- Button click →
__compactState = { active: true, phase: "running" }+ push"running"toast →requestUpdate() - RPC
sessions.compactresolves → update toast to"complete"with token counts →__compactState = {}→requestUpdate() - Auto-dismiss toast after 5 seconds
Files Modified
| File | Change |
|---|---|
src/gateway/server-cron.ts | Enrich cron event with name before broadcasting |
ui/src/ui/app-gateway.ts | Read cronPayload.name first in job name resolution |
ui/src/ui/app-render.helpers.ts | Define phaseLabel/isWorking; spinner on gauge button while running |
How to Push a Custom Toast from Anywhere
Any code with access to the OpenClawApp instance can push a toast:
const app = state as unknown as OpenClawApp;
const jobId = "my-job-" + Date.now();
// Start
app.backgroundJobToasts = [
...(app.backgroundJobToasts ?? []).filter(t => t.jobId !== jobId),
{ jobId, jobName: "My Task", status: "running", startedAt: Date.now(), completedAt: null },
];
// Complete (with auto-dismiss)
app.backgroundJobToasts = [
...(app.backgroundJobToasts ?? []).filter(t => t.jobId !== jobId),
{ jobId, jobName: "My Task done", status: "complete", startedAt: Date.now(), completedAt: Date.now() },
];
window.setTimeout(() => {
app.backgroundJobToasts = (app.backgroundJobToasts ?? []).filter(t => t.jobId !== jobId);
}, 5000);
Prefix convention: Use
"cmp-"for compaction jobs (filtered when a new compaction starts). Use a unique domain prefix for other job types.
Known Gotchas
phaseLabel/isWorkingmust be defined before the template literal — referencing them without defining them compiles fine (TypeScript doesn't catch undeclared variables inside template literals) but renders asundefinedat runtime, producing a blank modal with no text and no progress bar.__compactStateis not@state()— always callapp.requestUpdate()after mutating it, or the UI won't re-render.- Cron job name fallback order matters — the
namefield in the event payload (gateway-enriched) must be checked before the localcronJobslist, because the list is only loaded when the Cron tab is open. backgroundJobToastsIS@state()— assigning the array triggers a re-render automatically; norequestUpdate()needed specifically for that.
Reference
See references/source-snapshot.md for a snapshot of the key code sections at time of writing.