Files
openclaw/ui/src/ui/app-render.helpers.node.test.ts
Ian Derrington 266d320062 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>
2026-03-01 08:24:14 -06:00

287 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});