mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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 .
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -110,6 +110,9 @@ export const zh_CN: TranslationMap = {
|
||||
refreshTitle: "刷新聊天数据",
|
||||
thinkingToggle: "切换助手思考/工作输出",
|
||||
focusToggle: "切换专注模式 (隐藏侧边栏 + 页面页眉)",
|
||||
hideCronSessions: "隐藏定时任务会话",
|
||||
showCronSessions: "显示定时任务会话",
|
||||
showCronSessionsHidden: "显示定时任务会话 (已隐藏 {count} 个)",
|
||||
onboardingDisabled: "引导期间禁用",
|
||||
},
|
||||
languages: {
|
||||
|
||||
@@ -110,6 +110,9 @@ export const zh_TW: TranslationMap = {
|
||||
refreshTitle: "刷新聊天數據",
|
||||
thinkingToggle: "切換助手思考/工作輸出",
|
||||
focusToggle: "切換專注模式 (隱藏側邊欄 + 頁面頁眉)",
|
||||
hideCronSessions: "隱藏定時任務會話",
|
||||
showCronSessions: "顯示定時任務會話",
|
||||
showCronSessionsHidden: "顯示定時任務會話 (已隱藏 {count} 個)",
|
||||
onboardingDisabled: "引導期間禁用",
|
||||
},
|
||||
languages: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -163,6 +163,7 @@ export type AppViewState = {
|
||||
sessionsFilterLimit: string;
|
||||
sessionsIncludeGlobal: boolean;
|
||||
sessionsIncludeUnknown: boolean;
|
||||
sessionsHideCron: boolean;
|
||||
usageLoading: boolean;
|
||||
usageResult: SessionsUsageResult | null;
|
||||
usageCostSummary: CostUsageSummary | null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user