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