fix(ui): address review follow-ups

This commit is contained in:
Val Alexander
2026-03-10 16:50:22 -05:00
parent a9159eee51
commit e61fc75099
9 changed files with 267 additions and 139 deletions

View File

@@ -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> = {}): ChatHost & Record<string
};
}
function createSettingsHost() {
return {
settings: {
gatewayUrl: "",
token: "",
sessionKey: "main",
lastActiveSessionKey: "main",
theme: "claw",
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
navGroupsCollapsed: {},
},
theme: "claw",
themeMode: "system",
themeResolved: "dark",
applySessionKey: "main",
sessionKey: "main",
tab: "chat",
connected: false,
chatHasAutoScrolled: false,
logsAtBottom: false,
eventLog: [],
eventLogBuffer: [],
basePath: "",
systemThemeCleanup: null,
} as Record<string, unknown>;
}
beforeEach(() => {
vi.resetModules();
const { window, document } = parseHTML("<html><body></body></html>");
@@ -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<string, number>();
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();

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
export const MAX_CACHED_CHAT_SESSIONS = 20;
export function getOrCreateSessionCacheValue<T>(
map: Map<string, T>,
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;
}

View File

@@ -62,6 +62,24 @@ function expectedGatewayUrl(basePath: string): string {
return `${proto}://${location.host}${basePath}`;
}
function createSettings(overrides: Record<string, unknown> = {}) {
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);

View File

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

View File

@@ -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<string, PinnedMessages>();
const deletedMessagesMap = new Map<string, DeletedMessages>();
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<ChatItem | MessageGroup> {
}
// 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({

View File

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