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 { render } from "lit";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { renderChatControls, renderChatMobileToggle } from "./app-render.helpers.ts"; import { renderChatControls, renderChatMobileToggle } from "./app-render.helpers.ts";
import type { AppViewState } from "./app-view-state.ts"; import type { AppViewState } from "./app-view-state.ts";
@@ -43,6 +43,8 @@ function createState(overrides: Partial<AppViewState> = {}) {
chatShowToolCalls: true, chatShowToolCalls: true,
}, },
applySettings: () => undefined, applySettings: () => undefined,
chatMobileControlsOpen: false,
setChatMobileControlsOpen: () => undefined,
...overrides, ...overrides,
} as unknown as AppViewState; } as unknown as AppViewState;
} }
@@ -104,4 +106,45 @@ describe("chat header controls (browser)", () => {
expect(state.sessionsHideCron).toBe(false); 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) { export function renderChatMobileToggle(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult); const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const controlsDropdownId = "chat-mobile-controls-dropdown";
const mobileControlsOpen = state.chatMobileControlsOpen;
const disableThinkingToggle = state.onboarding; const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding; const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking; 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" class="btn btn--sm btn--icon chat-controls-mobile-toggle"
@click=${(e: Event) => { @click=${(e: Event) => {
e.stopPropagation(); e.stopPropagation();
const btn = e.currentTarget as HTMLElement; state.setChatMobileControlsOpen(!mobileControlsOpen, {
const dropdown = btn.nextElementSibling as HTMLElement; trigger: e.currentTarget 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);
}
}
}} }}
title="Chat settings" title="Chat settings"
aria-label="Chat settings" aria-label="Chat settings"
aria-expanded=${mobileControlsOpen}
aria-controls=${controlsDropdownId}
> >
<svg <svg
width="18" width="18"
@@ -464,7 +459,8 @@ export function renderChatMobileToggle(state: AppViewState) {
</svg> </svg>
</button> </button>
<div <div
class="chat-controls-dropdown" id=${controlsDropdownId}
class="chat-controls-dropdown ${mobileControlsOpen ? "open" : ""}"
@click=${(e: Event) => { @click=${(e: Event) => {
e.stopPropagation(); e.stopPropagation();
}} }}
@@ -553,13 +549,11 @@ export function renderChatMobileToggle(state: AppViewState) {
state.sessionsHideCron = !hideCron; state.sessionsHideCron = !hideCron;
}} }}
aria-pressed=${hideCron} aria-pressed=${hideCron}
title=${ title=${hideCron
hideCron ? hiddenCronCount > 0
? hiddenCronCount > 0 ? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) }) : t("chat.showCronSessions")
: t("chat.showCronSessions") : t("chat.hideCronSessions")}
: t("chat.hideCronSessions")
}
> >
${renderCronFilterIcon(hiddenCronCount)} ${renderCronFilterIcon(hiddenCronCount)}
</button> </button>

View File

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

View File

@@ -220,6 +220,8 @@ export class OpenClawApp extends LitElement {
@state() realtimeTalkTranscript: string | null = null; @state() realtimeTalkTranscript: string | null = null;
private realtimeTalkSession: RealtimeTalkSession | null = null; private realtimeTalkSession: RealtimeTalkSession | null = null;
@state() chatManualRefreshInFlight = false; @state() chatManualRefreshInFlight = false;
@state() chatMobileControlsOpen = false;
private chatMobileControlsTrigger: HTMLElement | null = null;
@state() navDrawerOpen = false; @state() navDrawerOpen = false;
onSlashAction?: (action: string) => void; 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() { createRenderRoot() {
return this; return this;
@@ -603,6 +622,8 @@ export class OpenClawApp extends LitElement {
} }
}; };
document.addEventListener("keydown", this.globalKeydownHandler); document.addEventListener("keydown", this.globalKeydownHandler);
document.addEventListener("keydown", this.chatMobileControlsKeydownHandler);
document.addEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]); handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
void this.initWebPushState(); void this.initWebPushState();
} }
@@ -613,12 +634,19 @@ export class OpenClawApp extends LitElement {
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener("keydown", this.globalKeydownHandler); 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]); handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
super.disconnectedCallback(); super.disconnectedCallback();
} }
protected updated(changed: Map<PropertyKey, unknown>) { protected updated(changed: Map<PropertyKey, unknown>) {
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed); 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") { if (!changed.has("sessionKey") || this.agentsPanel !== "tools") {
return; return;
} }
@@ -693,9 +721,35 @@ export class OpenClawApp extends LitElement {
setTab(next: Tab) { setTab(next: Tab) {
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next); setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
if (next !== "chat") {
this.setChatMobileControlsOpen(false);
}
this.navDrawerOpen = 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]) { setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context); setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
this.themeOrder = this.buildThemeOrder(next); this.themeOrder = this.buildThemeOrder(next);

View File

@@ -447,6 +447,60 @@ describe("control UI routing", () => {
expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull(); 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 () => { it("preserves session navigation and keeps focus mode scoped to chat", async () => {
const app = mountApp("/sessions?session=agent:main:subagent:task-123"); const app = mountApp("/sessions?session=agent:main:subagent:task-123");
await app.updateComplete; await app.updateComplete;