Skip to content

Commit b1c5152

Browse files
authored
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
1 parent 6891211 commit b1c5152

5 files changed

Lines changed: 171 additions & 21 deletions

File tree

ui/src/ui/app-render.helpers.browser.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render } from "lit";
2-
import { describe, expect, it } from "vitest";
2+
import { describe, expect, it, vi } from "vitest";
33
import { t } from "../i18n/index.ts";
44
import { renderChatControls, renderChatMobileToggle } from "./app-render.helpers.ts";
55
import type { AppViewState } from "./app-view-state.ts";
@@ -43,6 +43,8 @@ function createState(overrides: Partial<AppViewState> = {}) {
4343
chatShowToolCalls: true,
4444
},
4545
applySettings: () => undefined,
46+
chatMobileControlsOpen: false,
47+
setChatMobileControlsOpen: () => undefined,
4648
...overrides,
4749
} as unknown as AppViewState;
4850
}
@@ -104,4 +106,45 @@ describe("chat header controls (browser)", () => {
104106

105107
expect(state.sessionsHideCron).toBe(false);
106108
});
109+
110+
it("renders the mobile dropdown from state instead of mutating DOM classes", async () => {
111+
const setChatMobileControlsOpen = vi.fn();
112+
const state = createState({
113+
chatMobileControlsOpen: false,
114+
setChatMobileControlsOpen,
115+
});
116+
const container = document.createElement("div");
117+
render(renderChatMobileToggle(state), container);
118+
await Promise.resolve();
119+
120+
const toggle = container.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
121+
const dropdown = container.querySelector<HTMLElement>(".chat-controls-dropdown");
122+
expect(toggle).not.toBeNull();
123+
expect(dropdown).not.toBeNull();
124+
expect(toggle?.getAttribute("aria-expanded")).toBe("false");
125+
expect(toggle?.getAttribute("aria-controls")).toBe("chat-mobile-controls-dropdown");
126+
expect(dropdown?.id).toBe("chat-mobile-controls-dropdown");
127+
expect(dropdown?.classList.contains("open")).toBe(false);
128+
129+
toggle?.click();
130+
131+
expect(setChatMobileControlsOpen).toHaveBeenCalledWith(true, { trigger: toggle });
132+
expect(dropdown?.classList.contains("open")).toBe(false);
133+
134+
render(
135+
renderChatMobileToggle(
136+
createState({
137+
chatMobileControlsOpen: true,
138+
setChatMobileControlsOpen,
139+
}),
140+
),
141+
container,
142+
);
143+
await Promise.resolve();
144+
145+
const openToggle = container.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
146+
const openDropdown = container.querySelector<HTMLElement>(".chat-controls-dropdown");
147+
expect(openToggle?.getAttribute("aria-expanded")).toBe("true");
148+
expect(openDropdown?.classList.contains("open")).toBe(true);
149+
});
107150
});

ui/src/ui/app-render.helpers.ts

Lines changed: 14 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,8 @@ export function renderChatControls(state: AppViewState) {
381381
*/
382382
export function renderChatMobileToggle(state: AppViewState) {
383383
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
384+
const controlsDropdownId = "chat-mobile-controls-dropdown";
385+
const mobileControlsOpen = state.chatMobileControlsOpen;
384386
const disableThinkingToggle = state.onboarding;
385387
const disableFocusToggle = state.onboarding;
386388
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
@@ -431,21 +433,14 @@ export function renderChatMobileToggle(state: AppViewState) {
431433
class="btn btn--sm btn--icon chat-controls-mobile-toggle"
432434
@click=${(e: Event) => {
433435
e.stopPropagation();
434-
const btn = e.currentTarget as HTMLElement;
435-
const dropdown = btn.nextElementSibling as HTMLElement;
436-
if (dropdown) {
437-
const isOpen = dropdown.classList.toggle("open");
438-
if (isOpen) {
439-
const close = () => {
440-
dropdown.classList.remove("open");
441-
document.removeEventListener("click", close);
442-
};
443-
setTimeout(() => document.addEventListener("click", close, { once: true }), 0);
444-
}
445-
}
436+
state.setChatMobileControlsOpen(!mobileControlsOpen, {
437+
trigger: e.currentTarget as HTMLElement,
438+
});
446439
}}
447440
title="Chat settings"
448441
aria-label="Chat settings"
442+
aria-expanded=${mobileControlsOpen}
443+
aria-controls=${controlsDropdownId}
449444
>
450445
<svg
451446
width="18"
@@ -464,7 +459,8 @@ export function renderChatMobileToggle(state: AppViewState) {
464459
</svg>
465460
</button>
466461
<div
467-
class="chat-controls-dropdown"
462+
id=${controlsDropdownId}
463+
class="chat-controls-dropdown ${mobileControlsOpen ? "open" : ""}"
468464
@click=${(e: Event) => {
469465
e.stopPropagation();
470466
}}
@@ -553,13 +549,11 @@ export function renderChatMobileToggle(state: AppViewState) {
553549
state.sessionsHideCron = !hideCron;
554550
}}
555551
aria-pressed=${hideCron}
556-
title=${
557-
hideCron
558-
? hiddenCronCount > 0
559-
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
560-
: t("chat.showCronSessions")
561-
: t("chat.hideCronSessions")
562-
}
552+
title=${hideCron
553+
? hiddenCronCount > 0
554+
? t("chat.showCronSessionsHidden", { count: String(hiddenCronCount) })
555+
: t("chat.showCronSessions")
556+
: t("chat.hideCronSessions")}
563557
>
564558
${renderCronFilterIcon(hiddenCronCount)}
565559
</button>

ui/src/ui/app-view-state.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export type AppViewState = {
119119
realtimeTalkDetail: string | null;
120120
realtimeTalkTranscript: string | null;
121121
chatManualRefreshInFlight: boolean;
122+
chatMobileControlsOpen: boolean;
122123
nodesLoading: boolean;
123124
nodes: Array<Record<string, unknown>>;
124125
chatNewMessagesBelow: boolean;
@@ -405,6 +406,10 @@ export type AppViewState = {
405406
refreshSessionsAfterChat: Set<string>;
406407
connect: () => void;
407408
setTab: (tab: Tab) => void;
409+
setChatMobileControlsOpen: (
410+
open: boolean,
411+
options?: { trigger?: HTMLElement | null; restoreFocus?: boolean },
412+
) => void;
408413
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
409414
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
410415
setCustomThemeImportUrl: (next: string) => void;

ui/src/ui/app.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ export class OpenClawApp extends LitElement {
220220
@state() realtimeTalkTranscript: string | null = null;
221221
private realtimeTalkSession: RealtimeTalkSession | null = null;
222222
@state() chatManualRefreshInFlight = false;
223+
@state() chatMobileControlsOpen = false;
224+
private chatMobileControlsTrigger: HTMLElement | null = null;
223225
@state() navDrawerOpen = false;
224226

225227
onSlashAction?: (action: string) => void;
@@ -578,6 +580,23 @@ export class OpenClawApp extends LitElement {
578580
}
579581
}
580582
};
583+
private chatMobileControlsKeydownHandler = (e: KeyboardEvent) => {
584+
if (e.key !== "Escape" || !this.chatMobileControlsOpen) {
585+
return;
586+
}
587+
e.preventDefault();
588+
this.setChatMobileControlsOpen(false, { restoreFocus: true });
589+
};
590+
private chatMobileControlsPointerdownHandler = (e: Event) => {
591+
if (!this.chatMobileControlsOpen) {
592+
return;
593+
}
594+
const wrapper = this.querySelector(".chat-mobile-controls-wrapper");
595+
if (wrapper && e.composedPath().includes(wrapper)) {
596+
return;
597+
}
598+
this.setChatMobileControlsOpen(false);
599+
};
581600

582601
createRenderRoot() {
583602
return this;
@@ -603,6 +622,8 @@ export class OpenClawApp extends LitElement {
603622
}
604623
};
605624
document.addEventListener("keydown", this.globalKeydownHandler);
625+
document.addEventListener("keydown", this.chatMobileControlsKeydownHandler);
626+
document.addEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
606627
handleConnected(this as unknown as Parameters<typeof handleConnected>[0]);
607628
void this.initWebPushState();
608629
}
@@ -613,12 +634,19 @@ export class OpenClawApp extends LitElement {
613634

614635
disconnectedCallback() {
615636
document.removeEventListener("keydown", this.globalKeydownHandler);
637+
document.removeEventListener("keydown", this.chatMobileControlsKeydownHandler);
638+
document.removeEventListener("pointerdown", this.chatMobileControlsPointerdownHandler);
639+
this.chatMobileControlsTrigger = null;
616640
handleDisconnected(this as unknown as Parameters<typeof handleDisconnected>[0]);
617641
super.disconnectedCallback();
618642
}
619643

620644
protected updated(changed: Map<PropertyKey, unknown>) {
621645
handleUpdated(this as unknown as Parameters<typeof handleUpdated>[0], changed);
646+
// Some render callbacks assign tab directly while preparing nested panel state.
647+
if (changed.has("tab") && this.tab !== "chat" && this.chatMobileControlsOpen) {
648+
this.setChatMobileControlsOpen(false);
649+
}
622650
if (!changed.has("sessionKey") || this.agentsPanel !== "tools") {
623651
return;
624652
}
@@ -693,9 +721,35 @@ export class OpenClawApp extends LitElement {
693721

694722
setTab(next: Tab) {
695723
setTabInternal(this as unknown as Parameters<typeof setTabInternal>[0], next);
724+
if (next !== "chat") {
725+
this.setChatMobileControlsOpen(false);
726+
}
696727
this.navDrawerOpen = false;
697728
}
698729

730+
setChatMobileControlsOpen(
731+
open: boolean,
732+
options?: { trigger?: HTMLElement | null; restoreFocus?: boolean },
733+
) {
734+
if (open) {
735+
this.chatMobileControlsTrigger = options?.trigger ?? this.chatMobileControlsTrigger;
736+
this.chatMobileControlsOpen = true;
737+
return;
738+
}
739+
740+
const focusTarget = options?.restoreFocus ? this.chatMobileControlsTrigger : null;
741+
this.chatMobileControlsOpen = false;
742+
this.chatMobileControlsTrigger = null;
743+
if (!(focusTarget instanceof HTMLElement) || !focusTarget.isConnected) {
744+
return;
745+
}
746+
requestAnimationFrame(() => {
747+
if (focusTarget.isConnected) {
748+
focusTarget.focus();
749+
}
750+
});
751+
}
752+
699753
setTheme(next: ThemeName, context?: Parameters<typeof setThemeInternal>[2]) {
700754
setThemeInternal(this as unknown as Parameters<typeof setThemeInternal>[0], next, context);
701755
this.themeOrder = this.buildThemeOrder(next);

ui/src/ui/navigation.browser.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,60 @@ describe("control UI routing", () => {
447447
expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull();
448448
});
449449

450+
it("closes mobile chat controls on Escape, outside pointerdown, and tab changes", async () => {
451+
const app = mountApp("/chat");
452+
await app.updateComplete;
453+
454+
const toggle = app.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle");
455+
const dropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
456+
expect(toggle).not.toBeNull();
457+
expect(dropdown).not.toBeNull();
458+
if (!toggle || !dropdown) {
459+
return;
460+
}
461+
462+
toggle.focus();
463+
toggle.click();
464+
await app.updateComplete;
465+
466+
expect(app.chatMobileControlsOpen).toBe(true);
467+
expect(toggle.getAttribute("aria-expanded")).toBe("true");
468+
expect(dropdown.classList.contains("open")).toBe(true);
469+
470+
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
471+
await app.updateComplete;
472+
await nextFrame();
473+
474+
expect(app.chatMobileControlsOpen).toBe(false);
475+
expect(toggle.getAttribute("aria-expanded")).toBe("false");
476+
expect(dropdown.classList.contains("open")).toBe(false);
477+
expect(document.activeElement).toBe(toggle);
478+
479+
toggle.click();
480+
await app.updateComplete;
481+
app.requestUpdate();
482+
await app.updateComplete;
483+
484+
const openDropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
485+
expect(app.chatMobileControlsOpen).toBe(true);
486+
expect(openDropdown?.classList.contains("open")).toBe(true);
487+
488+
document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true }));
489+
await app.updateComplete;
490+
491+
const closedDropdown = app.querySelector<HTMLElement>(".chat-controls-dropdown");
492+
expect(app.chatMobileControlsOpen).toBe(false);
493+
expect(closedDropdown?.classList.contains("open")).toBe(false);
494+
495+
app.querySelector<HTMLButtonElement>(".chat-controls-mobile-toggle")?.click();
496+
await app.updateComplete;
497+
expect(app.chatMobileControlsOpen).toBe(true);
498+
499+
app.setTab("channels");
500+
await app.updateComplete;
501+
expect(app.chatMobileControlsOpen).toBe(false);
502+
});
503+
450504
it("preserves session navigation and keeps focus mode scoped to chat", async () => {
451505
const app = mountApp("/sessions?session=agent:main:subagent:task-123");
452506
await app.updateComplete;

0 commit comments

Comments
 (0)