feat(ui): add hide-cron toggle to chat session selector (#26976)

* 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 <flux@openclaw.ai>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Ian Derrington
2026-03-01 06:24:14 -08:00
committed by GitHub
parent 4637b90c07
commit 266d320062
10 changed files with 139 additions and 5 deletions

View File

@@ -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 .

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -110,6 +110,9 @@ export const zh_CN: TranslationMap = {
refreshTitle: "刷新聊天数据",
thinkingToggle: "切换助手思考/工作输出",
focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)",
hideCronSessions: "隐藏定时任务会话",
showCronSessions: "显示定时任务会话",
showCronSessionsHidden: "显示定时任务会话 (已隐藏 {count} 个)",
onboardingDisabled: "引导期间禁用",
},
languages: {

View File

@@ -110,6 +110,9 @@ export const zh_TW: TranslationMap = {
refreshTitle: "刷新聊天數據",
thinkingToggle: "切換助手思考/工作輸出",
focusToggle: "切換專注模式 (隱藏側邊欄 + 頁面頁眉)",
hideCronSessions: "隱藏定時任務會話",
showCronSessions: "顯示定時任務會話",
showCronSessionsHidden: "顯示定時任務會話 (已隱藏 {count} 個)",
onboardingDisabled: "引導期間禁用",
},
languages: {

View File

@@ -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);
});
});

View File

@@ -82,12 +82,57 @@ export function renderTab(state: AppViewState, tab: Tab) {
`;
}
function renderCronFilterIcon(hiddenCount: number) {
return html`
<span style="position: relative; display: inline-flex; align-items: center;">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
${
hiddenCount > 0
? html`<span
style="
position: absolute;
top: -5px;
right: -6px;
background: var(--color-accent, #6366f1);
color: #fff;
border-radius: 999px;
font-size: 9px;
line-height: 1;
padding: 1px 3px;
pointer-events: none;
"
>${hiddenCount}</span
>`
: ""
}
</span>
`;
}
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}
</button>
<button
class="btn btn--sm btn--icon ${hideCron ? "active" : ""}"
@click=${() => {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${
hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")
}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>
</div>
`;
}
@@ -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<string>();
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) {

View File

@@ -163,6 +163,7 @@ export type AppViewState = {
sessionsFilterLimit: string;
sessionsIncludeGlobal: boolean;
sessionsIncludeUnknown: boolean;
sessionsHideCron: boolean;
usageLoading: boolean;
usageResult: SessionsUsageResult | null;
usageCostSummary: CostUsageSummary | null;

View File

@@ -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;