From 44ef355fe1f28b5fdd2e2d46b7f0e0306e97bdf2 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 17:53:41 +0800
Subject: [PATCH 01/15] fix(automations-ui): readable summary cards and NEXT
RUN value
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reflow the summary strip to at most three cards per row so the detail
text no longer wraps one word per line, and let StatCard accept a
valueClassName override so the NEXT RUN date renders at a smaller size
instead of truncating to "Jun…". Default StatCard sizing is unchanged.
---
.../static/js/design-system/primitives.js | 11 ++++++++++-
.../components/automations-summary-strip.js | 6 +++++-
2 files changed, 15 insertions(+), 2 deletions(-)
diff --git a/crates/ironclaw_webui_v2_static/static/js/design-system/primitives.js b/crates/ironclaw_webui_v2_static/static/js/design-system/primitives.js
index 12a39295f43..954333d17b6 100644
--- a/crates/ironclaw_webui_v2_static/static/js/design-system/primitives.js
+++ b/crates/ironclaw_webui_v2_static/static/js/design-system/primitives.js
@@ -42,6 +42,11 @@ export function cx(...classes) {
* detail string (optional sub-text)
* showDivider boolean
* className string
+ * valueClassName string (optional) — overrides the value font-size classes.
+ * Defaults to the large numeric size; pass a smaller size for text values
+ * (e.g. a date) that would otherwise truncate. Note: `cn()` only
+ * concatenates (no tailwind-merge), so this REPLACES the size classes
+ * rather than appending to them.
*/
export function StatCard({
label,
@@ -50,6 +55,7 @@ export function StatCard({
detail,
showDivider = true,
className = "",
+ valueClassName = "text-[1.75rem] md:text-[2rem]",
}) {
return html`
${value}
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js
index 75d83159a0d..221188e5895 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automations-summary-strip.js
@@ -39,12 +39,15 @@ export function AutomationsSummaryStrip({ summary }) {
value: summary?.nextRun || t("automations.summary.none"),
tone: "info",
detail: t("automations.summary.nextRunDetail"),
+ // NEXT RUN is a date string, not a count — use a smaller size so it isn't
+ // truncated to "Jun…" inside a narrow card.
+ valueClassName: "text-lg md:text-xl",
},
];
return html`
<${Panel} className="p-4 sm:p-5">
-
+
${cards.map((card) => html`
From a8c9e01365b245880734663a1662d9489939ff03 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 17:55:43 +0800
Subject: [PATCH 02/15] fix(automations-ui): surface delivery save errors and
gate Slack hint
The delivery-defaults panel swallowed save/clear failures and showed no
feedback; it now renders an inline error from the mutation and flashes the
"Saved" confirmation on Clear as well as Save. The "reply approve
in
Slack" footnote is hidden unless an external Slack-style target exists.
---
.../static/js/i18n/en.js | 2 +
.../automation-delivery-defaults-panel.js | 45 ++++++++++++++-----
2 files changed, 36 insertions(+), 11 deletions(-)
diff --git a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
index 933dbfbc8f9..1b0ae462074 100644
--- a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
+++ b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
@@ -672,6 +672,8 @@ registerPack("en", {
"automations.delivery.save": "Save",
"automations.delivery.clear": "Clear",
"automations.delivery.saved": "Saved",
+ "automations.delivery.saveFailed":
+ "Couldn't save the delivery target. Please try again.",
"automations.delivery.footnote":
"Approval requests sent to your DM are answered by replying {command} in Slack.",
"automations.delivery.pill.ready": "Ready",
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-delivery-defaults-panel.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-delivery-defaults-panel.js
index 5dcd416fc62..dc63f440a33 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-delivery-defaults-panel.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-delivery-defaults-panel.js
@@ -64,23 +64,32 @@ export function AutomationDeliveryDefaultsPanel({ deliveryState }) {
opt?.capabilities?.final_replies &&
opt?.target?.status === "unavailable",
);
+ // The Slack approval footnote only makes sense when an external (Slack-style)
+ // target exists at all — paired or not. Web-only deployments shouldn't see a
+ // "reply in Slack" hint.
+ const hasExternalTargets = hasTargets || hasUnpairedTargets;
- const handleSave = () => {
- if (!canSave) return;
- deliveryState
- .saveFinalReplyTarget(draftTargetId || null)
+ // Flash the "Saved" confirmation; the mutation's rejection is reflected
+ // through `deliveryState.saveError` (rendered below), so the catch here only
+ // prevents an unhandled promise rejection.
+ const flashSavedOnSuccess = (promise) =>
+ promise
.then(() => {
if (savedTimerRef.current) clearTimeout(savedTimerRef.current);
setShowSaved(true);
savedTimerRef.current = setTimeout(() => setShowSaved(false), 2200);
})
.catch(() => {});
+
+ const handleSave = () => {
+ if (!canSave) return;
+ flashSavedOnSuccess(deliveryState.saveFinalReplyTarget(draftTargetId || null));
};
const handleClear = () => {
if (!canClear) return;
setDraftTargetId("");
- deliveryState.saveFinalReplyTarget(null).catch(() => {});
+ flashSavedOnSuccess(deliveryState.saveFinalReplyTarget(null));
};
// ── Derived display values ──────────────────────────────────────────
@@ -314,14 +323,28 @@ export function AutomationDeliveryDefaultsPanel({ deliveryState }) {
${t("automations.delivery.saved")}
`}
+ ${deliveryState.saveError &&
+ !showSaved &&
+ html`
+
+ <${Icon} name="close" className="h-3 w-3" />
+ ${t("automations.delivery.saveFailed")}
+
+ `}
-
-
- ${footnoteSegments}
-
+
+ ${hasExternalTargets &&
+ html`
+
+ ${footnoteSegments}
+
+ `}
/>
From 97de732ad9fe3e8e4d235e0b6b8349a52103a5f1 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 17:58:06 +0800
Subject: [PATCH 03/15] fix(automations-ui): label sub-hourly cron schedules
Minute- and hour-level cadences such as "* * * * *", "*/15 * * * *", and
"0 * * * *" rendered as "Custom schedule" because they have no single clock
time. They now read as "Every minute", "Every 15 minutes", and "Hourly at
:00".
---
.../automations/lib/automations-presenters.js | 32 +++++++++++++++++--
1 file changed, 29 insertions(+), 3 deletions(-)
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
index 9fd5858044f..c10add1d309 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
@@ -110,13 +110,30 @@ export function scheduleLabel(cron, timezone) {
if (!parts) return "Custom schedule";
const { minute, hour, dayOfMonth, month, dayOfWeek, year } = parts;
- const time = formatCronTime(hour, minute);
- if (!time) return "Custom schedule";
const tz = timezone && typeof timezone === "string" ? timezone : null;
const tzSuffix = tz ? ` (${tz})` : "";
+ const everyDate =
+ year === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*";
+
+ // Sub-hourly / hourly cadences, where hour (and possibly minute) is a
+ // wildcard or step and therefore has no single clock time. These are common
+ // ("every minute", "every 15 minutes", "hourly at :00") and previously fell
+ // through to the meaningless "Custom schedule". Timezone is irrelevant for a
+ // minute-of-hour cadence, so it is omitted here.
+ if (everyDate && hour === "*") {
+ if (minute === "*") return "Every minute";
+ const step = minuteStep(minute);
+ if (step) return `Every ${step} minute${step === 1 ? "" : "s"}`;
+ if (isSingleNumber(minute, 0, 59)) {
+ return `Hourly at :${String(Number(minute)).padStart(2, "0")}`;
+ }
+ }
+
+ const time = formatCronTime(hour, minute);
+ if (!time) return "Custom schedule";
- if (year === "*" && dayOfMonth === "*" && month === "*" && dayOfWeek === "*") {
+ if (everyDate) {
return `Every day at ${time}${tzSuffix}`;
}
const normalizedDayOfWeek = normalizeDayOfWeek(dayOfWeek);
@@ -326,6 +343,15 @@ function isSingleNumber(value, min, max) {
return num >= min && num <= max;
}
+// Parse a `*/N` step expression into N, returning null when it isn't a valid
+// minute step (1..=59).
+function minuteStep(value) {
+ const match = /^\*\/(\d+)$/.exec(value);
+ if (!match) return null;
+ const step = Number(match[1]);
+ return step >= 1 && step <= 59 ? step : null;
+}
+
function normalizeDayOfWeek(value) {
const upper = String(value || "").toUpperCase();
const aliases = {
From 5a3c797e339861c98d790ffd7cf84c2ebab45469 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 18:29:12 +0800
Subject: [PATCH 04/15] fix(automations-ui): space the run-row action button
icons
The "Open run" and "Logs" buttons in the recent-runs list rendered the
icon flush against the label because the non-primary Button variants don't
add a gap between children. Add the same icon margin the rest of the app
uses for icon+label buttons.
---
.../js/pages/automations/components/automation-recent-runs.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-recent-runs.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-recent-runs.js
index 1ec16b7d003..a48717b59f9 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-recent-runs.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/components/automation-recent-runs.js
@@ -71,7 +71,7 @@ export function RecentRunRow({ run, onOpenRun, onOpenLogs }) {
disabled=${!canOpen}
onClick=${canOpen ? () => onOpenRun(run.chat_path) : undefined}
>
- <${Icon} name="chat" className="h-4 w-4" />
+ <${Icon} name="chat" className="mr-1.5 h-4 w-4" />
${t("automations.detail.openRun")}
/>
<${Button}
@@ -80,7 +80,7 @@ export function RecentRunRow({ run, onOpenRun, onOpenLogs }) {
disabled=${!canOpenLogs}
onClick=${canOpenLogs ? () => onOpenLogs(logsPath) : undefined}
>
- <${Icon} name="file" className="h-4 w-4" />
+ <${Icon} name="file" className="mr-1.5 h-4 w-4" />
${t("nav.logs")}
/>
From 7e9044e4128b65060e30812dc27afcfed1d69d13 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 17:57:02 +0800
Subject: [PATCH 05/15] fix(automations-ui): consistent summary counts and
next-run
The Running/Failures summary cards counted individual runs while the
matching filter tabs counted automations, so the numbers disagreed; both
now count automations. The soonest "Next run" no longer includes paused
triggers, which keep a stored slot they will never actually fire.
---
.../static/js/i18n/en.js | 5 ++--
.../automations/lib/automations-presenters.js | 23 ++++++++++---------
2 files changed, 15 insertions(+), 13 deletions(-)
diff --git a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
index 1b0ae462074..1598112f58b 100644
--- a/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
+++ b/crates/ironclaw_webui_v2_static/static/js/i18n/en.js
@@ -690,9 +690,10 @@ registerPack("en", {
"automations.summary.paused": "Paused",
"automations.summary.pausedDetail": "Schedules not currently expected to run.",
"automations.summary.running": "Running now",
- "automations.summary.runningDetail": "Runs currently in progress.",
+ "automations.summary.runningDetail": "Automations with a run in progress.",
"automations.summary.failures": "Failures",
- "automations.summary.failuresDetail": "Failed runs in the visible history.",
+ "automations.summary.failuresDetail":
+ "Automations with a failed run in recent history.",
"automations.summary.nextRun": "Next run",
"automations.summary.none": "None",
"automations.summary.nextRunDetail": "Soonest scheduled run in this list.",
diff --git a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
index c10add1d309..27ec286c50e 100644
--- a/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
+++ b/crates/ironclaw_webui_v2_static/static/js/pages/automations/lib/automations-presenters.js
@@ -78,18 +78,19 @@ export function filterAutomations(automations, filter) {
export function automationSummary(automations) {
const active = automations.filter((automation) => isBrowserActive(automation)).length;
- const running = automations.reduce(
- (count, automation) =>
- count + automation.recent_runs.filter((run) => run.status === "running").length,
- 0,
- );
- const failures = automations.reduce(
- (count, automation) =>
- count + automation.recent_runs.filter((run) => run.status === "error").length,
- 0,
- );
+ // Count automations (not individual runs) so each card matches the
+ // same-named filter tab, which filters automations via has_running_run /
+ // has_failed_runs.
+ const running = automations.filter((automation) => automation.has_running_run).length;
+ const failures = automations.filter((automation) => automation.has_failed_runs).length;
+ // Only automations that will actually fire contribute to "soonest next run".
+ // Paused triggers keep their stored next_run_at slot, but they won't run, so
+ // surfacing their time here would imply a run that never happens.
const next = automations
- .filter((automation) => nextRunTimestamp(automation) !== null)
+ .filter(
+ (automation) =>
+ isBrowserActive(automation) && nextRunTimestamp(automation) !== null,
+ )
.sort(
(a, b) =>
(a.next_run_timestamp ?? Number.MAX_SAFE_INTEGER) -
From c4f317e79fa87d61bab25358c350eb157efaf108 Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 18:49:20 +0800
Subject: [PATCH 06/15] test(automations): lock the panel UI fixes into the
served bundle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add static-asset assertions driving the composed router so each Automations
panel UX fix — sub-hourly cron labels, summary card reflow + smaller NEXT RUN
value, run-row icon spacing, and delivery save-error/Slack-hint gating — is
guarded against a regression that drops it from the shipped SPA source.
---
.../tests/webui_v2_serve.rs | 119 ++++++++++++++++++
1 file changed, 119 insertions(+)
diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs
index 52ac5e0d51e..7edcc9c4206 100644
--- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs
+++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs
@@ -2784,3 +2784,122 @@ async fn public_route_mount_is_merged_without_bearer_auth_and_keeps_descriptor_p
.expect("oneshot");
assert_eq!(protected.status(), StatusCode::UNAUTHORIZED);
}
+
+// ─── Automations panel UI (fix/reborn-automations-ux) ─────────────────
+//
+// These lock the served automations SPA source shape so a regression that
+// drops one of the panel UX fixes fails here. Behavioral JS coverage needs a
+// browser harness this workspace does not own, so — per the existing
+// `static_*` precedent — we assert the shipped asset content instead.
+
+#[tokio::test]
+async fn static_automations_presenters_label_sub_hourly_schedules() {
+ let (app, _) = build_app();
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method(Method::GET)
+ .uri("/v2/js/pages/automations/lib/automations-presenters.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("oneshot");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = read_body_string(response).await;
+
+ assert!(
+ body.contains("\"Every minute\""),
+ "presenters must label `* * * * *` as `Every minute` instead of `Custom schedule`"
+ );
+ assert!(
+ body.contains("Every ${step} minute"),
+ "presenters must label `*/N * * * *` as `Every N minutes`"
+ );
+ assert!(
+ body.contains("Hourly at :"),
+ "presenters must label `M * * * *` as an hourly cadence"
+ );
+}
+
+#[tokio::test]
+async fn static_automations_summary_reflows_cards_and_shrinks_next_run() {
+ let (app, _) = build_app();
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method(Method::GET)
+ .uri("/v2/js/pages/automations/components/automations-summary-strip.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("oneshot");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = read_body_string(response).await;
+
+ assert!(
+ body.contains("lg:grid-cols-3"),
+ "summary strip must cap cards per row so detail text stays readable"
+ );
+ assert!(
+ !body.contains("xl:grid-cols-5"),
+ "summary strip must not force five cards into one row"
+ );
+ assert!(
+ body.contains("valueClassName"),
+ "the NEXT RUN card must pass a smaller value font so the date is not truncated"
+ );
+}
+
+#[tokio::test]
+async fn static_automations_run_row_spaces_action_button_icons() {
+ let (app, _) = build_app();
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method(Method::GET)
+ .uri("/v2/js/pages/automations/components/automation-recent-runs.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("oneshot");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = read_body_string(response).await;
+
+ assert!(
+ body.contains("name=\"chat\" className=\"mr-1.5 h-4 w-4\""),
+ "the Open run button icon must be spaced away from its label"
+ );
+ assert!(
+ body.contains("name=\"file\" className=\"mr-1.5 h-4 w-4\""),
+ "the Logs button icon must be spaced away from its label"
+ );
+}
+
+#[tokio::test]
+async fn static_automations_delivery_surfaces_save_error_and_gates_slack_hint() {
+ let (app, _) = build_app();
+ let response = app
+ .oneshot(
+ Request::builder()
+ .method(Method::GET)
+ .uri("/v2/js/pages/automations/components/automation-delivery-defaults-panel.js")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("oneshot");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = read_body_string(response).await;
+
+ assert!(
+ body.contains("deliveryState.saveError"),
+ "the delivery panel must render the save error instead of swallowing it"
+ );
+ assert!(
+ body.contains("hasExternalTargets"),
+ "the Slack approval footnote must be gated on an external target existing"
+ );
+}
From 5c6c0dbc317e2d52347983ae8d320419363600af Mon Sep 17 00:00:00 2001
From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
Date: Mon, 15 Jun 2026 18:13:24 +0800
Subject: [PATCH 07/15] feat(automations): surface scheduler-off state and run
it by default on serve
Scheduled automations never fired because the trigger poller is disabled by
default and nothing told the user. The list response now carries
scheduler_enabled (sourced from runtime readiness) and the panel shows a
"scheduling is turned off" notice when it is false. The local `ironclaw-reborn
serve` surface enables the poller by default; config and env still override it.
---
.../src/reborn_services.rs | 16 ++-
.../src/reborn_services/types.rs | 10 ++
.../tests/reborn_services_contract.rs | 34 +++++
crates/ironclaw_reborn_cli/src/runtime/mod.rs | 1 +
.../src/runtime/trigger_poller.rs | 122 ++++++++++++------
.../src/automation.rs | 18 +++
.../ironclaw_reborn_composition/src/webui.rs | 7 +-
.../tests/webui_v2_serve.rs | 1 +
.../tests/webui_v2_handlers_contract.rs | 1 +
.../static/js/i18n/en.js | 3 +
.../js/pages/automations/automations-page.js | 15 +++
.../pages/automations/hooks/useAutomations.js | 6 +
12 files changed, 193 insertions(+), 41 deletions(-)
diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs
index 1af197766db..3d970d81a54 100644
--- a/crates/ironclaw_product_workflow/src/reborn_services.rs
+++ b/crates/ironclaw_product_workflow/src/reborn_services.rs
@@ -484,6 +484,16 @@ pub trait AutomationProductFacade: Send + Sync {
request: AutomationListRequest,
) -> Result