fix(control-ui): keep mobile chat settings in Lit state

Move the mobile chat settings dropdown open state into Lit-owned app state.

- Render the dropdown open class and ARIA disclosure attributes from state.
- Add Escape, outside pointer, tab-change cleanup, and focus restoration.
- Cover closed/open render state and mounted app dismissal flows with browser tests.

Validation:
- pnpm test ui/src/ui/app-render.helpers.browser.test.ts ui/src/ui/navigation.browser.test.ts
- pnpm exec oxfmt --check --threads=1 ui/src/ui/app.ts ui/src/ui/app-view-state.ts ui/src/ui/app-render.helpers.ts ui/src/ui/app-render.helpers.browser.test.ts ui/src/ui/navigation.browser.test.ts
- node scripts/run-oxlint.mjs --tsconfig tsconfig.oxlint.core.json ui/src/ui/app.ts ui/src/ui/app-view-state.ts ui/src/ui/app-render.helpers.ts ui/src/ui/app-render.helpers.browser.test.ts ui/src/ui/navigation.browser.test.ts
This commit is contained in:
Val Alexander
2026-04-29 13:25:41 -05:00
committed by GitHub
parent 68912111cf
commit b1c515270e
5 changed files with 171 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
import { render } from "lit";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { t } from "../i18n/index.ts";
import { renderChatControls, renderChatMobileToggle } from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts";
@@ -43,6 +43,8 @@ function createState(overrides: Partial<AppViewState> = {}) {
chatShowToolCalls: true,
},
applySettings: () => undefined,
chatMobileControlsOpen: false,
setChatMobileControlsOpen: () => undefined,
...overrides,
} as unknown as AppViewState;
}
@@ -104,4 +106,45 @@ describe("chat header controls (browser)", () => {
expect(state.sessionsHideCron).toBe(false);
});
it("renders the mobile dropdown from state instead of mutating DOM classes", async () => {
const setChatMobileControlsOpen = vi.fn();
const state = createState({
chatMobileControlsOpen: false,
setChatMobileControlsOpen,
});
const container = document.createElement("div");
render(renderChatMobileToggle(state), container);
await Promise.resolve();
const toggle = container.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
const dropdown = container.querySelector<HTMLElement>(".chat-controls-dropdown");
expect(toggle).not.toBeNull();
expect(dropdown).not.toBeNull();
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
expect(toggle?.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown");
expect(dropdown?.id).toBe("chat-mobile-controls-dropdown");
expect(dropdown?.classList.contains("open")).toBe(false);
toggle?.click();
expect(setChatMobileControlsOpen).toHaveBeenCalledWith(true, { trigger: toggle });
expect(dropdown?.classList.contains("open")).toBe(false);
render(
renderChatMobileToggle(
createState({
chatMobileControlsOpen: true,
setChatMobileControlsOpen,
}),
),
container,
);
await Promise.resolve();
const openToggle = container.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
const openDropdown = container.querySelector<HTMLElement>(".chat-controls-dropdown");
expect(openToggle?.getAttribute("aria-expanded")).toBe("true");
expect(openDropdown?.classList.contains("open")).toBe(true);
});
});

View File

@@ -381,6 +381,8 @@ export function renderChatControls(state: AppViewState) {
*/
export function renderChatMobileToggle(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const controlsDropdownId = "chat-mobile-controls-dropdown";
const mobileControlsOpen = state.chatMobileControlsOpen;
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@@ -431,21 +433,14 @@ export function renderChatMobileToggle(state: AppViewState) {
class="btn btn--sm btn--icon chat-controls-mobile-toggle"
@click=${(e: Event) => {
e.stopPropagation();
const btn = e.currentTarget as HTMLElement;
const dropdown = btn.nextElementSibling as HTMLElement;
if (dropdown) {
const isOpen = dropdown.classList.toggle("open");
if (isOpen) {
const close = () => {
dropdown.classList.remove("open");
document.removeEventListener("click", close);
};
setTimeout(() => document.addEventListener("click", close, { once: true }), 0);
}
}
state.setChatMobileControlsOpen(!mobileControlsOpen, {
trigger: e.currentTarget as HTMLElement,
});
}}
title="Chat settings"
aria-label="Chat settings"
aria-expanded=${mobileControlsOpen}
aria-controls=${controlsDropdownId}
>
<svg
width="18"
@@ -464,7 +459,8 @@ export function renderChatMobileToggle(state: AppViewState) {
</svg>
</button>
<div
class="chat-controls-dropdown"
id=${controlsDropdownId}
class="chat-controls-dropdown ${mobileControlsOpen ? "open" : ""}"
@click=${(e: Event) => {
e.stopPropagation();
}}
@@ -553,13 +549,11 @@ export function renderChatMobileToggle(state: AppViewState) {
state.sessionsHideCron = !hideCron;
}}
aria-pressed=${hideCron}
title=${
hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")
}
title=${hideCron
? hiddenCronCount > 0
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
: t("chat.showCronSessions")
: t("chat.hideCronSessions")}
>
${renderCronFilterIcon(hiddenCronCount)}
</button>

View File

@@ -119,6 +119,7 @@ export type AppViewState = {
realtimeTalkDetail: string | null;
realtimeTalkTranscript: string | null;
chatManualRefreshInFlight: boolean;
chatMobileControlsOpen: boolean;
nodesLoading: boolean;
nodes: Array<Record<string, unknown>>;
chatNewMessagesBelow: boolean;
@@ -405,6 +406,10 @@ export type AppViewState = {
refreshSessionsAfterChat: Set<string>;
connect: () => void;
setTab: (tab: Tab) => void;
setChatMobileControlsOpen: (
open: boolean,
options?: { trigger?: HTMLElement | null; restoreFocus?: boolean },
) => void;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
setCustomThemeImportUrl: (next: string) => void;

View File

@@ -220,6 +220,8 @@ export class OpenClawApp extends LitElement {
@state() realtimeTalkTranscript: string | null = null;
private realtimeTalkSession: RealtimeTalkSession | null = null;
@state() chatManualRefreshInFlight = false;
@state() chatMobileControlsOpen = false;
private chatMobileControlsTrigger: HTMLElement | null = null;
@state() navDrawerOpen = false;
onSlashAction?: (action: string) => void;
@@ -578,6 +580,23 @@ export class OpenClawApp extends LitElement {
}
}
};
private chatMobileControlsKeydownHandler = (e: KeyboardEvent) => {
if (e.key !== "Escape" || !this.chatMobileControlsOpen) {
return;
}
e.preventDefault();
this.setChatMobileControlsOpen(false, { restoreFocus: true });
};
private chatMobileControlsPointerdownHandler = (e: Event) => {
if (!this.chatMobileControlsOpen) {
return;
}
const wrapper = this.querySelector(".chat-mobile-controls-wrapper");
if (wrapper && e.composedPath().includes(wrapper)) {
return;
}
this.setChatMobileControlsOpen(false);
};
createRenderRoot() {
return this;
@@ -603,6 +622,8 @@ export class OpenClawApp extends LitElement {
}
};
document.addEventListener("keydown", this.globalKeydownHandler);
document.addEventListener("keydown", this.chatMobileControlsKeydownHandler);
document.addEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
void this.initWebPushState();
}
@@ -613,12 +634,19 @@ export class OpenClawApp extends LitElement {
disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler);
document.removeEventListener("keydown", this.chatMobileControlsKeydownHandler);
document.removeEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
this.chatMobileControlsTrigger = null;
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback();
}
protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
// Some render callbacks assign tab directly while preparing nested panel state.
if (changed.has("tab") && this.tab !== "chat" && this.chatMobileControlsOpen) {
this.setChatMobileControlsOpen(false);
}
if (!changed.has("sessionKey") || this.agentsPanel !== "tools") {
return;
}
@@ -693,9 +721,35 @@ export class OpenClawApp extends LitElement {
setTab(next: Tab) {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
if (next !== "chat") {
this.setChatMobileControlsOpen(false);
}
this.navDrawerOpen = false;
}
setChatMobileControlsOpen(
open: boolean,
options?: { trigger?: HTMLElement | null; restoreFocus?: boolean },
) {
if (open) {
this.chatMobileControlsTrigger = options?.trigger ?? this.chatMobileControlsTrigger;
this.chatMobileControlsOpen = true;
return;
}
const focusTarget = options?.restoreFocus ? this.chatMobileControlsTrigger : null;
this.chatMobileControlsOpen = false;
this.chatMobileControlsTrigger = null;
if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) {
return;
}
requestAnimationFrame(() => {
if (focusTarget.isConnected) {
focusTarget.focus();
}
});
}
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this.themeOrder = this.buildThemeOrder(next);

View File

@@ -447,6 +447,60 @@ describe("control UI routing", () => {
expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull();
});
it("closes mobile chat controls on Escape, outside pointerdown, and tab changes", async () => {
const app = mountApp("/chat");
await app.updateComplete;
const toggle = app.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
const dropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
expect(toggle).not.toBeNull();
expect(dropdown).not.toBeNull();
if (!toggle || !dropdown) {
return;
}
toggle.focus();
toggle.click();
await app.updateComplete;
expect(app.chatMobileControlsOpen).toBe(true);
expect(toggle.getAttribute("aria-expanded")).toBe("true");
expect(dropdown.classList.contains("open")).toBe(true);
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
await app.updateComplete;
await nextFrame();
expect(app.chatMobileControlsOpen).toBe(false);
expect(toggle.getAttribute("aria-expanded")).toBe("false");
expect(dropdown.classList.contains("open")).toBe(false);
expect(document.activeElement).toBe(toggle);
toggle.click();
await app.updateComplete;
app.requestUpdate();
await app.updateComplete;
const openDropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
expect(app.chatMobileControlsOpen).toBe(true);
expect(openDropdown?.classList.contains("open")).toBe(true);
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
await app.updateComplete;
const closedDropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
expect(app.chatMobileControlsOpen).toBe(false);
expect(closedDropdown?.classList.contains("open")).toBe(false);
app.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle")?.click();
await app.updateComplete;
expect(app.chatMobileControlsOpen).toBe(true);
app.setTab("channels");
await app.updateComplete;
expect(app.chatMobileControlsOpen).toBe(false);
});
it("preserves session navigation and keeps focus mode scoped to chat", async () => {
const app = mountApp("/sessions?session=agent:main:subagent:task-123");
await app.updateComplete;