mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user