From 266d3200624f1bcc4b64ecfbdde55bb425cb1591 Mon Sep 17 00:00:00 2001 From: Ian Derrington <76016868+ianderrington@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:24:14 -0800 Subject: [PATCH] feat(ui): add hide-cron toggle to chat session selector (#26976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add hide-cron toggle to chat session selector Adds a clock icon toggle button in the chat controls bar that filters cron sessions out of the session dropdown. Default: hidden (true). Why: cron sessions (key prefix `cron:`) accumulate fast — a job running every 15 min produces 48 entries/day. They pollute the session selector on small screens and devices like the Rabbit R1. Changes: - app-render.helpers.ts - isCronSessionKey() — exported helper (exported for tests) - countHiddenCronSessions() — counts filterable crons, skips active key - resolveSessionOptions() — new hideCron param; skips cron: keys unless that key is the currently active session (never drop it) - renderCronFilterIcon() — clock SVG with optional badge count - renderChatControls() — reads state.sessionsHideCron (default true), passes hideCron to resolveSessionOptions, adds toggle button at the end of the controls bar showing hidden count as a badge - app-view-state.ts — adds sessionsHideCron: boolean to AppViewState - app.ts — @state() sessionsHideCron = true (persists across re-renders) - app-render.helpers.node.test.ts — tests for isCronSessionKey * fix(ui): harden cron session filtering and i18n labels --------- Co-authored-by: FLUX Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/i18n/locales/de.ts | 3 + ui/src/i18n/locales/en.ts | 3 + ui/src/i18n/locales/pt-BR.ts | 3 + ui/src/i18n/locales/zh-CN.ts | 3 + ui/src/i18n/locales/zh-TW.ts | 3 + ui/src/ui/app-render.helpers.node.test.ts | 25 +++++- ui/src/ui/app-render.helpers.ts | 101 +++++++++++++++++++++- ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + 10 files changed, 139 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba94a0c3a9..a30bd5b7744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,6 +83,7 @@ Docs: https://docs.openclaw.ai - Cron/Failure alerts: add configurable repeated-failure alerting with per-job overrides and Web UI cron editor support (`inherit|disabled|custom` with threshold/cooldown/channel/target fields). (#24789) Thanks xbrak. - Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6. - Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf. +- Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington. - Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks . - Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks . - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks . diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index ed853e4e6c1..bbdf2bdb3b5 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -114,6 +114,9 @@ export const de: TranslationMap = { refreshTitle: "Chat-Daten aktualisieren", thinkingToggle: "Ausgabe des Assistenten ein-/ausblenden", focusToggle: "Fokusmodus ein-/ausschalten (Seitenleiste + Kopfzeile ausblenden)", + hideCronSessions: "Cron-Sitzungen ausblenden", + showCronSessions: "Cron-Sitzungen anzeigen", + showCronSessionsHidden: "Cron-Sitzungen anzeigen ({count} ausgeblendet)", onboardingDisabled: "Während der Einrichtung deaktiviert", }, languages: { diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 83af74d2612..342ca8c85a5 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -111,6 +111,9 @@ export const en: TranslationMap = { refreshTitle: "Refresh chat data", thinkingToggle: "Toggle assistant thinking/working output", focusToggle: "Toggle focus mode (hide sidebar + page header)", + hideCronSessions: "Hide cron sessions", + showCronSessions: "Show cron sessions", + showCronSessionsHidden: "Show cron sessions ({count} hidden)", onboardingDisabled: "Disabled during onboarding", }, languages: { diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 1c101272c50..7a973a13992 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -113,6 +113,9 @@ export const pt_BR: TranslationMap = { refreshTitle: "Atualizar dados do chat", thinkingToggle: "Alternar saída de pensamento/trabalho do assistente", focusToggle: "Alternar modo de foco (ocultar barra lateral + cabeçalho da página)", + hideCronSessions: "Ocultar sessões de cron", + showCronSessions: "Mostrar sessões de cron", + showCronSessionsHidden: "Mostrar sessões de cron ({count} ocultas)", onboardingDisabled: "Desativado durante a integração", }, languages: { diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index cd7273958b6..aad258d8bf4 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -110,6 +110,9 @@ export const zh_CN: TranslationMap = { refreshTitle: "刷新聊天数据", thinkingToggle: "切换助手思考/工作输出", focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)", + hideCronSessions: "隐藏定时任务会话", + showCronSessions: "显示定时任务会话", + showCronSessionsHidden: "显示定时任务会话 (已隐藏 {count} 个)", onboardingDisabled: "引导期间禁用", }, languages: { diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 8e60f3c917e..1165d56fe4e 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -110,6 +110,9 @@ export const zh_TW: TranslationMap = { refreshTitle: "刷新聊天數據", thinkingToggle: "切換助手思考/工作輸出", focusToggle: "切換專注模式 (隱藏側邊欄 + 頁面頁眉)", + hideCronSessions: "隱藏定時任務會話", + showCronSessions: "顯示定時任務會話", + showCronSessionsHidden: "顯示定時任務會話 (已隱藏 {count} 個)", onboardingDisabled: "引導期間禁用", }, languages: { diff --git a/ui/src/ui/app-render.helpers.node.test.ts b/ui/src/ui/app-render.helpers.node.test.ts index 7bea77067ed..72f39209be3 100644 --- a/ui/src/ui/app-render.helpers.node.test.ts +++ b/ui/src/ui/app-render.helpers.node.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { parseSessionKey, resolveSessionDisplayName } from "./app-render.helpers.ts"; +import { + isCronSessionKey, + parseSessionKey, + resolveSessionDisplayName, +} from "./app-render.helpers.ts"; import type { SessionsListResult } from "./types.ts"; type SessionRow = SessionsListResult["sessions"][number]; @@ -36,6 +40,10 @@ describe("parseSessionKey", () => { prefix: "Cron:", fallbackName: "Cron Job:", }); + expect(parseSessionKey("cron:daily-briefing-uuid")).toEqual({ + prefix: "Cron:", + fallbackName: "Cron Job:", + }); }); it("identifies direct chat with known channel", () => { @@ -261,3 +269,18 @@ describe("resolveSessionDisplayName", () => { ).toBe("Tyler"); }); }); + +describe("isCronSessionKey", () => { + it("returns true for cron: prefixed keys", () => { + expect(isCronSessionKey("cron:abc-123")).toBe(true); + expect(isCronSessionKey("cron:weekly-agent-roundtable")).toBe(true); + expect(isCronSessionKey("agent:main:cron:abc-123")).toBe(true); + expect(isCronSessionKey("agent:main:cron:abc-123:run:run-1")).toBe(true); + }); + + it("returns false for non-cron keys", () => { + expect(isCronSessionKey("main")).toBe(false); + expect(isCronSessionKey("discord:group:eng")).toBe(false); + expect(isCronSessionKey("agent:main:slack:cron:job:run:uuid")).toBe(false); + }); +}); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index d954147297b..68dfbe5e76d 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -82,12 +82,57 @@ export function renderTab(state: AppViewState, tab: Tab) { `; } +function renderCronFilterIcon(hiddenCount: number) { + return html` + + + ${ + hiddenCount > 0 + ? html`${hiddenCount}` + : "" + } + + `; +} + export function renderChatControls(state: AppViewState) { const mainSessionKey = resolveMainSessionKey(state.hello, state.sessionsResult); + const hideCron = state.sessionsHideCron ?? true; + const hiddenCronCount = hideCron + ? countHiddenCronSessions(state.sessionKey, state.sessionsResult) + : 0; const sessionOptions = resolveSessionOptions( state.sessionKey, state.sessionsResult, mainSessionKey, + hideCron, ); const disableThinkingToggle = state.onboarding; const disableFocusToggle = state.onboarding; @@ -226,6 +271,22 @@ export function renderChatControls(state: AppViewState) { > ${focusIcon} + `; } @@ -281,6 +342,8 @@ function capitalize(s: string): string { * fallback display name. Exported for testing. */ export function parseSessionKey(key: string): SessionKeyInfo { + const normalized = key.toLowerCase(); + // ── Main session ───────────────────────────────── if (key === "main" || key === "agent:main:main") { return { prefix: "", fallbackName: "Main Session" }; @@ -292,7 +355,7 @@ export function parseSessionKey(key: string): SessionKeyInfo { } // ── Cron job ───────────────────────────────────── - if (key.includes(":cron:")) { + if (normalized.startsWith("cron:") || key.includes(":cron:")) { return { prefix: "Cron:", fallbackName: "Cron Job:" }; } @@ -349,10 +412,30 @@ export function resolveSessionDisplayName( return fallbackName; } +export function isCronSessionKey(key: string): boolean { + const normalized = key.trim().toLowerCase(); + if (!normalized) { + return false; + } + if (normalized.startsWith("cron:")) { + return true; + } + if (!normalized.startsWith("agent:")) { + return false; + } + const parts = normalized.split(":").filter(Boolean); + if (parts.length < 3) { + return false; + } + const rest = parts.slice(2).join(":"); + return rest.startsWith("cron:"); +} + function resolveSessionOptions( sessionKey: string, sessions: SessionsListResult | null, mainSessionKey?: string | null, + hideCron = false, ) { const seen = new Set(); const options: Array<{ key: string; displayName?: string }> = []; @@ -369,7 +452,8 @@ function resolveSessionOptions( }); } - // Add current session key next + // Add current session key next — always include it even if it's a cron session, + // so the active session is never silently dropped from the select. if (!seen.has(sessionKey)) { seen.add(sessionKey); options.push({ @@ -378,10 +462,10 @@ function resolveSessionOptions( }); } - // Add sessions from the result + // Add sessions from the result, optionally filtering out cron sessions. if (sessions?.sessions) { for (const s of sessions.sessions) { - if (!seen.has(s.key)) { + if (!seen.has(s.key) && !(hideCron && isCronSessionKey(s.key))) { seen.add(s.key); options.push({ key: s.key, @@ -394,6 +478,15 @@ function resolveSessionOptions( return options; } +/** Count sessions with a cron: key that would be hidden when hideCron=true. */ +function countHiddenCronSessions(sessionKey: string, sessions: SessionsListResult | null): number { + if (!sessions?.sessions) { + return 0; + } + // Don't count the currently active session even if it's a cron. + return sessions.sessions.filter((s) => isCronSessionKey(s.key) && s.key !== sessionKey).length; +} + const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"]; export function renderThemeToggle(state: AppViewState) { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 03a47768864..362a9e332c3 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -163,6 +163,7 @@ export type AppViewState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + sessionsHideCron: boolean; usageLoading: boolean; usageResult: SessionsUsageResult | null; usageCostSummary: CostUsageSummary | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index af0f3b6538e..409697e785f 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -245,6 +245,7 @@ export class OpenClawApp extends LitElement { @state() sessionsFilterLimit = "120"; @state() sessionsIncludeGlobal = true; @state() sessionsIncludeUnknown = false; + @state() sessionsHideCron = true; @state() usageLoading = false; @state() usageResult: import("./types.js").SessionsUsageResult | null = null;