From e61fc75099eda775c04039a0bcd53f695c42dbdf Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 10 Mar 2026 16:50:22 -0500 Subject: [PATCH] fix(ui): address review follow-ups --- test/ui-chat-regressions.test.ts | 108 ++++++++++++++++++++++++++ ui/src/ui/app-settings.test.ts | 30 +++++++- ui/src/ui/app-settings.ts | 11 ++- ui/src/ui/chat/search-match.ts | 10 +++ ui/src/ui/chat/session-cache.ts | 26 +++++++ ui/src/ui/storage.node.test.ts | 118 ++++++++++++++--------------- ui/src/ui/storage.ts | 2 + ui/src/ui/views/chat.ts | 38 ++++------ ui/src/ui/views/command-palette.ts | 63 ++++----------- 9 files changed, 267 insertions(+), 139 deletions(-) create mode 100644 ui/src/ui/chat/search-match.ts create mode 100644 ui/src/ui/chat/session-cache.ts diff --git a/test/ui-chat-regressions.test.ts b/test/ui-chat-regressions.test.ts index b547437f0c1..410fda6d61e 100644 --- a/test/ui-chat-regressions.test.ts +++ b/test/ui-chat-regressions.test.ts @@ -8,6 +8,11 @@ import { import { DeletedMessages } from "../ui/src/ui/chat/deleted-messages.ts"; import { buildChatMarkdown } from "../ui/src/ui/chat/export.ts"; import { getPinnedMessageSummary } from "../ui/src/ui/chat/pinned-summary.ts"; +import { messageMatchesSearchQuery } from "../ui/src/ui/chat/search-match.ts"; +import { + MAX_CACHED_CHAT_SESSIONS, + getOrCreateSessionCacheValue, +} from "../ui/src/ui/chat/session-cache.ts"; import type { GatewayBrowserClient } from "../ui/src/ui/gateway.ts"; function createStorageMock(): Storage { @@ -68,6 +73,38 @@ function createHost(overrides: Partial = {}): ChatHost & Record; +} + beforeEach(() => { vi.resetModules(); const { window, document } = parseHTML(""); @@ -149,6 +186,77 @@ describe("chat regressions", () => { expect(markdown).toContain("general kenobi"); }); + it("matches chat search against extracted structured message text", () => { + expect( + messageMatchesSearchQuery( + { + role: "assistant", + content: [{ type: "text", text: "Structured search target" }], + }, + "search target", + ), + ).toBe(true); + expect( + messageMatchesSearchQuery( + { + role: "assistant", + content: [{ type: "text", text: "Structured search target" }], + }, + "missing", + ), + ).toBe(false); + }); + + it("bounds cached per-session chat state", () => { + const cache = new Map(); + for (let i = 0; i < MAX_CACHED_CHAT_SESSIONS; i++) { + getOrCreateSessionCacheValue(cache, `session-${i}`, () => i); + } + + expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS); + expect(getOrCreateSessionCacheValue(cache, "session-0", () => -1)).toBe(0); + + getOrCreateSessionCacheValue(cache, `session-${MAX_CACHED_CHAT_SESSIONS}`, () => 99); + + expect(cache.size).toBe(MAX_CACHED_CHAT_SESSIONS); + expect(cache.has("session-0")).toBe(true); + expect(cache.has("session-1")).toBe(false); + }); + + it("keeps the command palette in sync with slash commands", async () => { + const { getPaletteItems } = await import("../ui/src/ui/views/command-palette.ts"); + const labels = getPaletteItems().map((item) => item.label); + + expect(labels).toContain("/agents"); + expect(labels).toContain("/clear"); + expect(labels).toContain("/kill"); + expect(labels).toContain("/skill"); + expect(labels).toContain("/steer"); + }); + + it("falls back to addListener/removeListener for system theme changes", async () => { + const { attachThemeListener, detachThemeListener } = + await import("../ui/src/ui/app-settings.ts"); + const host = createSettingsHost(); + const addListener = vi.fn(); + const removeListener = vi.fn(); + + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addListener, + removeListener, + })), + ); + + attachThemeListener(host); + expect(addListener).toHaveBeenCalledTimes(1); + + detachThemeListener(host); + expect(removeListener).toHaveBeenCalledTimes(1); + }); + it("queues local slash commands that would mutate session state during an active run", async () => { const { handleSendChat } = await import("../ui/src/ui/app-chat.ts"); const request = vi.fn(); diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 48411bbe5b0..434034251b3 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { setTabFromRoute } from "./app-settings.ts"; +import { attachThemeListener, detachThemeListener, setTabFromRoute } from "./app-settings.ts"; import type { Tab } from "./navigation.ts"; type SettingsHost = Parameters[0] & { @@ -13,14 +13,17 @@ const createHost = (tab: Tab): SettingsHost => ({ token: "", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }, - theme: "system", + theme: "claw", + themeMode: "system", themeResolved: "dark", applySessionKey: "main", sessionKey: "main", @@ -35,6 +38,7 @@ const createHost = (tab: Tab): SettingsHost => ({ themeMediaHandler: null, logsPollInterval: null, debugPollInterval: null, + systemThemeCleanup: null, }); describe("setTabFromRoute", () => { @@ -67,4 +71,24 @@ describe("setTabFromRoute", () => { setTabFromRoute(host, "chat"); expect(host.debugPollInterval).toBeNull(); }); + + it("falls back to addListener/removeListener for older MediaQueryList implementations", () => { + const host = createHost("chat"); + const addListener = vi.fn(); + const removeListener = vi.fn(); + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ + matches: false, + addListener, + removeListener, + })), + ); + + attachThemeListener(host); + expect(addListener).toHaveBeenCalledTimes(1); + + detachThemeListener(host); + expect(removeListener).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 6ce81cc1834..03b54e346bb 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -334,8 +334,15 @@ function syncSystemThemeListener(host: SettingsHost) { } applyResolvedTheme(host, resolveTheme(host.theme, "system")); }; - mql.addEventListener("change", onChange); - host.systemThemeCleanup = () => mql.removeEventListener("change", onChange); + if (typeof mql.addEventListener === "function") { + mql.addEventListener("change", onChange); + host.systemThemeCleanup = () => mql.removeEventListener("change", onChange); + return; + } + if (typeof mql.addListener === "function") { + mql.addListener(onChange); + host.systemThemeCleanup = () => mql.removeListener(onChange); + } } export function syncTabWithLocation(host: SettingsHost, replace: boolean) { diff --git a/ui/src/ui/chat/search-match.ts b/ui/src/ui/chat/search-match.ts new file mode 100644 index 00000000000..501a4ce4785 --- /dev/null +++ b/ui/src/ui/chat/search-match.ts @@ -0,0 +1,10 @@ +import { extractTextCached } from "./message-extract.ts"; + +export function messageMatchesSearchQuery(message: unknown, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase(); + if (!normalizedQuery) { + return true; + } + const text = (extractTextCached(message) ?? "").toLowerCase(); + return text.includes(normalizedQuery); +} diff --git a/ui/src/ui/chat/session-cache.ts b/ui/src/ui/chat/session-cache.ts new file mode 100644 index 00000000000..2891effa939 --- /dev/null +++ b/ui/src/ui/chat/session-cache.ts @@ -0,0 +1,26 @@ +export const MAX_CACHED_CHAT_SESSIONS = 20; + +export function getOrCreateSessionCacheValue( + map: Map, + sessionKey: string, + create: () => T, +): T { + if (map.has(sessionKey)) { + const existing = map.get(sessionKey) as T; + // Refresh insertion order so recently used sessions stay cached. + map.delete(sessionKey); + map.set(sessionKey, existing); + return existing; + } + + const created = create(); + map.set(sessionKey, created); + while (map.size > MAX_CACHED_CHAT_SESSIONS) { + const oldest = map.keys().next().value; + if (typeof oldest !== "string") { + break; + } + map.delete(oldest); + } + return created; +} diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index a6f2d3d9790..9ae61351ce9 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -62,6 +62,24 @@ function expectedGatewayUrl(basePath: string): string { return `${proto}://${location.host}${basePath}`; } +function createSettings(overrides: Record = {}) { + return { + gatewayUrl: "wss://gateway.example:8443/openclaw", + token: "", + sessionKey: "main", + lastActiveSessionKey: "main", + theme: "claw", + themeMode: "system", + chatFocusMode: false, + chatShowThinking: true, + splitRatio: 0.6, + navCollapsed: false, + navWidth: 220, + navGroupsCollapsed: {}, + ...overrides, + }; +} + describe("loadSettings default gateway URL derivation", () => { beforeEach(() => { vi.resetModules(); @@ -128,11 +146,13 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://gateway.example:8443/openclaw", sessionKey: "agent", lastActiveSessionKey: "agent", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(sessionStorage.length).toBe(0); @@ -146,18 +166,7 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "session-token", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }); + saveSettings(createSettings({ token: "session-token" })); expect(loadSettings()).toMatchObject({ gatewayUrl: "wss://gateway.example:8443/openclaw", @@ -173,18 +182,7 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "gateway-a-token", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }); + saveSettings(createSettings({ token: "gateway-a-token" })); localStorage.setItem( "openclaw.control.settings.v1", @@ -192,11 +190,13 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://other-gateway.example:8443/openclaw", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }), ); @@ -215,18 +215,7 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "memory-only-token", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }); + saveSettings(createSettings({ token: "memory-only-token" })); expect(loadSettings()).toMatchObject({ gatewayUrl: "wss://gateway.example:8443/openclaw", token: "memory-only-token", @@ -236,16 +225,41 @@ describe("loadSettings default gateway URL derivation", () => { gatewayUrl: "wss://gateway.example:8443/openclaw", sessionKey: "main", lastActiveSessionKey: "main", - theme: "system", + theme: "claw", + themeMode: "system", chatFocusMode: false, chatShowThinking: true, splitRatio: 0.6, navCollapsed: false, + navWidth: 220, navGroupsCollapsed: {}, }); expect(sessionStorage.length).toBe(1); }); + it("persists theme mode and nav width", async () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const { loadSettings, saveSettings } = await import("./storage.ts"); + saveSettings( + createSettings({ + theme: "dash", + themeMode: "light", + navWidth: 360, + }), + ); + + expect(loadSettings()).toMatchObject({ + theme: "dash", + themeMode: "light", + navWidth: 360, + }); + }); + it("clears the current-tab token when saving an empty token", async () => { setTestLocation({ protocol: "https:", @@ -254,30 +268,8 @@ describe("loadSettings default gateway URL derivation", () => { }); const { loadSettings, saveSettings } = await import("./storage.ts"); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "stale-token", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }); - saveSettings({ - gatewayUrl: "wss://gateway.example:8443/openclaw", - token: "", - sessionKey: "main", - lastActiveSessionKey: "main", - theme: "system", - chatFocusMode: false, - chatShowThinking: true, - splitRatio: 0.6, - navCollapsed: false, - navGroupsCollapsed: {}, - }); + saveSettings(createSettings({ token: "stale-token" })); + saveSettings(createSettings()); expect(loadSettings().token).toBe(""); expect(sessionStorage.length).toBe(0); diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 5dc1e0b59a2..db295dd8d97 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -194,10 +194,12 @@ function persistSettings(next: UiSettings) { sessionKey: next.sessionKey, lastActiveSessionKey: next.lastActiveSessionKey, theme: next.theme, + themeMode: next.themeMode, chatFocusMode: next.chatFocusMode, chatShowThinking: next.chatShowThinking, splitRatio: next.splitRatio, navCollapsed: next.navCollapsed, + navWidth: next.navWidth, navGroupsCollapsed: next.navGroupsCollapsed, ...(next.locale ? { locale: next.locale } : {}), }; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index fcfcc04b724..fb1b7cb4a80 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -16,6 +16,8 @@ import { InputHistory } from "../chat/input-history.ts"; import { normalizeMessage, normalizeRoleForGrouping } from "../chat/message-normalizer.ts"; import { PinnedMessages } from "../chat/pinned-messages.ts"; import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; +import { messageMatchesSearchQuery } from "../chat/search-match.ts"; +import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; import { CATEGORY_LABELS, SLASH_COMMANDS, @@ -117,30 +119,23 @@ const pinnedMessagesMap = new Map(); const deletedMessagesMap = new Map(); function getInputHistory(sessionKey: string): InputHistory { - let h = inputHistories.get(sessionKey); - if (!h) { - h = new InputHistory(); - inputHistories.set(sessionKey, h); - } - return h; + return getOrCreateSessionCacheValue(inputHistories, sessionKey, () => new InputHistory()); } function getPinnedMessages(sessionKey: string): PinnedMessages { - let p = pinnedMessagesMap.get(sessionKey); - if (!p) { - p = new PinnedMessages(sessionKey); - pinnedMessagesMap.set(sessionKey, p); - } - return p; + return getOrCreateSessionCacheValue( + pinnedMessagesMap, + sessionKey, + () => new PinnedMessages(sessionKey), + ); } function getDeletedMessages(sessionKey: string): DeletedMessages { - let d = deletedMessagesMap.get(sessionKey); - if (!d) { - d = new DeletedMessages(sessionKey); - deletedMessagesMap.set(sessionKey, d); - } - return d; + return getOrCreateSessionCacheValue( + deletedMessagesMap, + sessionKey, + () => new DeletedMessages(sessionKey), + ); } // Module-level ephemeral UI state (reset on navigation away) @@ -1361,11 +1356,8 @@ function buildChatItems(props: ChatProps): Array { } // Apply search filter if active - if (searchOpen && searchQuery.trim()) { - const text = typeof normalized.content === "string" ? normalized.content : ""; - if (!text.toLowerCase().includes(searchQuery.toLowerCase())) { - continue; - } + if (searchOpen && searchQuery.trim() && !messageMatchesSearchQuery(msg, searchQuery)) { + continue; } items.push({ diff --git a/ui/src/ui/views/command-palette.ts b/ui/src/ui/views/command-palette.ts index 1ea61c5b355..001921a88aa 100644 --- a/ui/src/ui/views/command-palette.ts +++ b/ui/src/ui/views/command-palette.ts @@ -1,6 +1,7 @@ import { html, nothing } from "lit"; import { ref } from "lit/directives/ref.js"; import { t } from "../../i18n/index.ts"; +import { SLASH_COMMANDS } from "../chat/slash-commands.ts"; import { icons, type IconName } from "../icons.ts"; type PaletteItem = { @@ -12,55 +13,17 @@ type PaletteItem = { description?: string; }; +const SLASH_PALETTE_ITEMS: PaletteItem[] = SLASH_COMMANDS.map((command) => ({ + id: `slash:${command.name}`, + label: `/${command.name}`, + icon: command.icon ?? "terminal", + category: "search", + action: `/${command.name}`, + description: command.description, +})); + const PALETTE_ITEMS: PaletteItem[] = [ - { - id: "status", - label: "/status", - icon: "radio", - category: "search", - action: "/status", - description: "Show current status", - }, - { - id: "models", - label: "/model", - icon: "monitor", - category: "search", - action: "/model", - description: "Show/set model", - }, - { - id: "usage", - label: "/usage", - icon: "barChart", - category: "search", - action: "/usage", - description: "Show usage", - }, - { - id: "think", - label: "/think", - icon: "brain", - category: "search", - action: "/think", - description: "Set thinking level", - }, - { - id: "reset", - label: "/reset", - icon: "loader", - category: "search", - action: "/reset", - description: "Reset session", - }, - { - id: "help", - label: "/help", - icon: "book", - category: "search", - action: "/help", - description: "Show help", - }, + ...SLASH_PALETTE_ITEMS, { id: "nav-overview", label: "Overview", @@ -115,6 +78,10 @@ const PALETTE_ITEMS: PaletteItem[] = [ }, ]; +export function getPaletteItems(): readonly PaletteItem[] { + return PALETTE_ITEMS; +} + export type CommandPaletteProps = { open: boolean; query: string;