mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-25 08:52:12 +00:00
* 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>
287 lines
9.1 KiB
TypeScript
287 lines
9.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
isCronSessionKey,
|
||
parseSessionKey,
|
||
resolveSessionDisplayName,
|
||
} from "./app-render.helpers.ts";
|
||
import type { SessionsListResult } from "./types.ts";
|
||
|
||
type SessionRow = SessionsListResult["sessions"][number];
|
||
|
||
function row(overrides: Partial<SessionRow> & { key: string }): SessionRow {
|
||
return { kind: "direct", updatedAt: 0, ...overrides };
|
||
}
|
||
|
||
/* ================================================================
|
||
* parseSessionKey – low-level key → type / fallback mapping
|
||
* ================================================================ */
|
||
|
||
describe("parseSessionKey", () => {
|
||
it("identifies main session (bare 'main')", () => {
|
||
expect(parseSessionKey("main")).toEqual({ prefix: "", fallbackName: "Main Session" });
|
||
});
|
||
|
||
it("identifies main session (agent:main:main)", () => {
|
||
expect(parseSessionKey("agent:main:main")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Main Session",
|
||
});
|
||
});
|
||
|
||
it("identifies subagent sessions", () => {
|
||
expect(parseSessionKey("agent:main:subagent:18abfefe-1fa6-43cb-8ba8-ebdc9b43e253")).toEqual({
|
||
prefix: "Subagent:",
|
||
fallbackName: "Subagent:",
|
||
});
|
||
});
|
||
|
||
it("identifies cron sessions", () => {
|
||
expect(parseSessionKey("agent:main:cron:daily-briefing-uuid")).toEqual({
|
||
prefix: "Cron:",
|
||
fallbackName: "Cron Job:",
|
||
});
|
||
expect(parseSessionKey("cron:daily-briefing-uuid")).toEqual({
|
||
prefix: "Cron:",
|
||
fallbackName: "Cron Job:",
|
||
});
|
||
});
|
||
|
||
it("identifies direct chat with known channel", () => {
|
||
expect(parseSessionKey("agent:main:bluebubbles:direct:+19257864429")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "iMessage · +19257864429",
|
||
});
|
||
});
|
||
|
||
it("identifies direct chat with telegram", () => {
|
||
expect(parseSessionKey("agent:main:telegram:direct:user123")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Telegram · user123",
|
||
});
|
||
});
|
||
|
||
it("identifies group chat with known channel", () => {
|
||
expect(parseSessionKey("agent:main:discord:group:guild-chan")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Discord Group",
|
||
});
|
||
});
|
||
|
||
it("capitalises unknown channels in direct/group patterns", () => {
|
||
expect(parseSessionKey("agent:main:mychannel:direct:user1")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Mychannel · user1",
|
||
});
|
||
});
|
||
|
||
it("identifies channel-prefixed legacy keys", () => {
|
||
expect(parseSessionKey("bluebubbles:g-agent-main-bluebubbles-direct-+19257864429")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "iMessage Session",
|
||
});
|
||
expect(parseSessionKey("discord:123:456")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Discord Session",
|
||
});
|
||
});
|
||
|
||
it("handles bare channel name as key", () => {
|
||
expect(parseSessionKey("telegram")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "Telegram Session",
|
||
});
|
||
});
|
||
|
||
it("returns raw key for unknown patterns", () => {
|
||
expect(parseSessionKey("something-unknown")).toEqual({
|
||
prefix: "",
|
||
fallbackName: "something-unknown",
|
||
});
|
||
});
|
||
});
|
||
|
||
/* ================================================================
|
||
* resolveSessionDisplayName – full resolution with row data
|
||
* ================================================================ */
|
||
|
||
describe("resolveSessionDisplayName", () => {
|
||
// ── Key-only fallbacks (no row) ──────────────────
|
||
|
||
it("returns 'Main Session' for agent:main:main key", () => {
|
||
expect(resolveSessionDisplayName("agent:main:main")).toBe("Main Session");
|
||
});
|
||
|
||
it("returns 'Main Session' for bare 'main' key", () => {
|
||
expect(resolveSessionDisplayName("main")).toBe("Main Session");
|
||
});
|
||
|
||
it("returns 'Subagent:' for subagent key without row", () => {
|
||
expect(resolveSessionDisplayName("agent:main:subagent:abc-123")).toBe("Subagent:");
|
||
});
|
||
|
||
it("returns 'Cron Job:' for cron key without row", () => {
|
||
expect(resolveSessionDisplayName("agent:main:cron:abc-123")).toBe("Cron Job:");
|
||
});
|
||
|
||
it("parses direct chat key with channel", () => {
|
||
expect(resolveSessionDisplayName("agent:main:bluebubbles:direct:+19257864429")).toBe(
|
||
"iMessage · +19257864429",
|
||
);
|
||
});
|
||
|
||
it("parses channel-prefixed legacy key", () => {
|
||
expect(resolveSessionDisplayName("discord:123:456")).toBe("Discord Session");
|
||
});
|
||
|
||
it("returns raw key for unknown patterns", () => {
|
||
expect(resolveSessionDisplayName("something-custom")).toBe("something-custom");
|
||
});
|
||
|
||
// ── With row data (label / displayName) ──────────
|
||
|
||
it("returns parsed fallback when row has no label or displayName", () => {
|
||
expect(resolveSessionDisplayName("agent:main:main", row({ key: "agent:main:main" }))).toBe(
|
||
"Main Session",
|
||
);
|
||
});
|
||
|
||
it("returns parsed fallback when displayName matches key", () => {
|
||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", displayName: "mykey" }))).toBe(
|
||
"mykey",
|
||
);
|
||
});
|
||
|
||
it("returns parsed fallback when label matches key", () => {
|
||
expect(resolveSessionDisplayName("mykey", row({ key: "mykey", label: "mykey" }))).toBe("mykey");
|
||
});
|
||
|
||
it("uses label alone when available", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", label: "General" }),
|
||
),
|
||
).toBe("General");
|
||
});
|
||
|
||
it("falls back to displayName when label is absent", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat" }),
|
||
),
|
||
).toBe("My Chat");
|
||
});
|
||
|
||
it("prefers label over displayName when both are present", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat", label: "General" }),
|
||
),
|
||
).toBe("General");
|
||
});
|
||
|
||
it("ignores whitespace-only label and falls back to displayName", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"discord:123:456",
|
||
row({ key: "discord:123:456", displayName: "My Chat", label: " " }),
|
||
),
|
||
).toBe("My Chat");
|
||
});
|
||
|
||
it("uses parsed fallback when whitespace-only label and no displayName", () => {
|
||
expect(
|
||
resolveSessionDisplayName("discord:123:456", row({ key: "discord:123:456", label: " " })),
|
||
).toBe("Discord Session");
|
||
});
|
||
|
||
it("trims label and displayName", () => {
|
||
expect(resolveSessionDisplayName("k", row({ key: "k", label: " General " }))).toBe("General");
|
||
expect(resolveSessionDisplayName("k", row({ key: "k", displayName: " My Chat " }))).toBe(
|
||
"My Chat",
|
||
);
|
||
});
|
||
|
||
// ── Type prefixes applied to labels / displayNames ──
|
||
|
||
it("prefixes subagent label with Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", label: "maintainer-v2" }),
|
||
),
|
||
).toBe("Subagent: maintainer-v2");
|
||
});
|
||
|
||
it("prefixes subagent displayName with Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", displayName: "Task Runner" }),
|
||
),
|
||
).toBe("Subagent: Task Runner");
|
||
});
|
||
|
||
it("prefixes cron label with Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", label: "daily-briefing" }),
|
||
),
|
||
).toBe("Cron: daily-briefing");
|
||
});
|
||
|
||
it("prefixes cron displayName with Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", displayName: "Nightly Sync" }),
|
||
),
|
||
).toBe("Cron: Nightly Sync");
|
||
});
|
||
|
||
it("does not double-prefix cron labels that already include Cron:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:cron:abc-123",
|
||
row({ key: "agent:main:cron:abc-123", label: "Cron: Nightly Sync" }),
|
||
),
|
||
).toBe("Cron: Nightly Sync");
|
||
});
|
||
|
||
it("does not double-prefix subagent display names that already include Subagent:", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:subagent:abc-123",
|
||
row({ key: "agent:main:subagent:abc-123", displayName: "Subagent: Runner" }),
|
||
),
|
||
).toBe("Subagent: Runner");
|
||
});
|
||
|
||
it("does not prefix non-typed sessions with labels", () => {
|
||
expect(
|
||
resolveSessionDisplayName(
|
||
"agent:main:bluebubbles:direct:+19257864429",
|
||
row({ key: "agent:main:bluebubbles:direct:+19257864429", label: "Tyler" }),
|
||
),
|
||
).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);
|
||
});
|
||
});
|