From b1c515270eb582e39a3ece337566a9ca2bea6c44 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:25:41 -0500 Subject: [PATCH] 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 --- ui/src/ui/app-render.helpers.browser.test.ts | 45 +++++++++++++++- ui/src/ui/app-render.helpers.ts | 34 +++++------- ui/src/ui/app-view-state.ts | 5 ++ ui/src/ui/app.ts | 54 ++++++++++++++++++++ ui/src/ui/navigation.browser.test.ts | 54 ++++++++++++++++++++ 5 files changed, 171 insertions(+), 21 deletions(-) diff --git a/ui/src/ui/app-render.helpers.browser.test.ts b/ui/src/ui/app-render.helpers.browser.test.ts index c486710dc24..bbb2aec7062 100644 --- a/ui/src/ui/app-render.helpers.browser.test.ts +++ b/ui/src/ui/app-render.helpers.browser.test.ts @@ -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 = {}) { 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(".chat-controls-mobile-toggle"); + const dropdown = container.querySelector(".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(".chat-controls-mobile-toggle"); + const openDropdown = container.querySelector(".chat-controls-dropdown"); + expect(openToggle?.getAttribute("aria-expanded")).toBe("true"); + expect(openDropdown?.classList.contains("open")).toBe(true); + }); }); diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 5b648da1df8..aa1e73ba9a1 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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} >
{ 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)} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 561fea39ed3..a6d1eac857b 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -119,6 +119,7 @@ export type AppViewState = { realtimeTalkDetail: string | null; realtimeTalkTranscript: string | null; chatManualRefreshInFlight: boolean; + chatMobileControlsOpen: boolean; nodesLoading: boolean; nodes: Array>; chatNewMessagesBelow: boolean; @@ -405,6 +406,10 @@ export type AppViewState = { refreshSessionsAfterChat: Set; 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; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index a0c6f7aa259..b53c5699306 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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[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[0]); super.disconnectedCallback(); } protected updated(changed: Map) { handleUpdated(this as unknown as Parameters[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[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[2]) { setThemeInternal(this as unknown as Parameters[0], next, context); this.themeOrder = this.buildThemeOrder(next); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index d281901e807..aa0d3e5333c 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -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(".chat-controls-mobile-toggle"); + const dropdown = app.querySelector(".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(".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(".chat-controls-dropdown"); + expect(app.chatMobileControlsOpen).toBe(false); + expect(closedDropdown?.classList.contains("open")).toBe(false); + + app.querySelector(".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;