mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -06:00
fix(ui): preserve focus on composer accessory clicks (#122243)
This commit is contained in:
committed by
GitHub
parent
d699662083
commit
776c19eaaa
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { installMockGateway } from "../test-helpers/control-ui-e2e.ts";
|
||||
import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts";
|
||||
|
||||
const suite = createControlUiE2eSuite({
|
||||
name: "Control UI composer accessory focus",
|
||||
});
|
||||
|
||||
suite.define(() => {
|
||||
it("keeps focus in place when pointer-opening passive composer popovers", async () => {
|
||||
await suite.withPage({ viewport: { width: 1440, height: 900 } }, async ({ page }) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
models: [{ id: "gpt-5.6", name: "GPT-5.6", provider: "openai" }],
|
||||
methodResponses: {
|
||||
"sessions.list": {
|
||||
count: 1,
|
||||
defaults: {
|
||||
contextTokens: 200_000,
|
||||
model: "gpt-5.6",
|
||||
modelProvider: "openai",
|
||||
thinkingDefault: "medium",
|
||||
thinkingLevels: [
|
||||
{ id: "off", label: "off" },
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "medium", label: "medium" },
|
||||
{ id: "high", label: "high" },
|
||||
],
|
||||
},
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
contextTokens: 200_000,
|
||||
key: "main",
|
||||
kind: "direct",
|
||||
model: "gpt-5.6",
|
||||
modelProvider: "openai",
|
||||
status: "done",
|
||||
totalTokens: 42_000,
|
||||
totalTokensFresh: true,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
ts: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
await gateway.waitForRequest("chat.startup");
|
||||
|
||||
const composer = page.locator(".agent-chat__input");
|
||||
const textarea = composer.locator("textarea");
|
||||
await composer.waitFor({ state: "visible" });
|
||||
await page.evaluate(() => {
|
||||
const outside = document.createElement("button");
|
||||
outside.id = "composer-accessory-focus-sentinel";
|
||||
outside.textContent = "Outside focus sentinel";
|
||||
document.body.prepend(outside);
|
||||
});
|
||||
const outside = page.locator("#composer-accessory-focus-sentinel");
|
||||
|
||||
for (const triggerSelector of [
|
||||
".context-usage > details > summary",
|
||||
".chat-controls__effort-picker > summary",
|
||||
]) {
|
||||
const trigger = composer.locator(triggerSelector);
|
||||
await trigger.waitFor({ state: "visible" });
|
||||
|
||||
await outside.focus();
|
||||
await trigger.click();
|
||||
expect(await outside.evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
const artifactDir = process.env.OPENCLAW_UI_E2E_ARTIFACT_DIR?.trim();
|
||||
if (artifactDir && triggerSelector.startsWith(".context-usage")) {
|
||||
await fs.mkdir(artifactDir, { recursive: true });
|
||||
const composerBox = await composer.boundingBox();
|
||||
const popoverBox = await composer.locator(".context-usage__popover").boundingBox();
|
||||
if (!composerBox || !popoverBox) {
|
||||
throw new Error("expected composer and context popover bounds for focus proof");
|
||||
}
|
||||
const y = Math.max(0, popoverBox.y - 16);
|
||||
const clip = {
|
||||
x: Math.max(0, composerBox.x - 16),
|
||||
y,
|
||||
width: composerBox.width + 32,
|
||||
height: composerBox.y + composerBox.height + 16 - y,
|
||||
};
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDir, "after-context-click-no-focus.png"),
|
||||
clip,
|
||||
});
|
||||
await trigger.focus();
|
||||
await page.screenshot({
|
||||
path: path.join(artifactDir, "before-context-trigger-focused.png"),
|
||||
clip,
|
||||
});
|
||||
await outside.focus();
|
||||
}
|
||||
await trigger.click();
|
||||
|
||||
await textarea.focus();
|
||||
await trigger.click();
|
||||
expect(await textarea.evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
await trigger.click();
|
||||
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
expect(await trigger.evaluate((element) => document.activeElement === element)).toBe(true);
|
||||
expect(
|
||||
await trigger.evaluate(
|
||||
(element) => element.closest<HTMLDetailsElement>("details")?.open ?? false,
|
||||
),
|
||||
).toBe(true);
|
||||
await trigger.press("Enter");
|
||||
}
|
||||
|
||||
for (const popover of [
|
||||
{
|
||||
focus: ".chat-controls__model-search",
|
||||
trigger: ".chat-controls__model-picker > summary",
|
||||
},
|
||||
{
|
||||
focus: ".agent-chat__attach-menu-option",
|
||||
trigger: ".agent-chat__input-btn--attach",
|
||||
},
|
||||
]) {
|
||||
await outside.focus();
|
||||
await composer.locator(popover.trigger).click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page
|
||||
.locator(popover.focus)
|
||||
.first()
|
||||
.evaluate((element) => document.activeElement === element),
|
||||
)
|
||||
.toBe(true);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -475,14 +475,6 @@ export function renderChatAttachmentMenuTrigger(disabled: boolean | undefined) {
|
||||
aria-label=${t("chat.composer.addAttachment")}
|
||||
?disabled=${disabled}
|
||||
title=${t("chat.composer.addAttachment")}
|
||||
@pointerdown=${(event: PointerEvent) => {
|
||||
const composer = (event.currentTarget as HTMLElement)
|
||||
.closest(".agent-chat__composer-shell")
|
||||
?.querySelector("textarea");
|
||||
if (document.activeElement === composer) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
${icons.plus}
|
||||
</button>
|
||||
|
||||
@@ -187,18 +187,34 @@ export function scheduleTextareaHeightAdjustment(el: HTMLTextAreaElement) {
|
||||
});
|
||||
}
|
||||
|
||||
export function focusComposerFromChrome(event: MouseEvent, connected: boolean) {
|
||||
if (!connected || event.defaultPrevented) {
|
||||
export function focusComposerFromChrome(event: MouseEvent | PointerEvent, connected: boolean) {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
const target = event.target;
|
||||
const currentTarget = event.currentTarget;
|
||||
if (!(target instanceof Element) || !(currentTarget instanceof HTMLElement)) {
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
if (event.type === "pointerdown") {
|
||||
// Cancel only pointer focus; click and popover-owned focus still run.
|
||||
if (
|
||||
event.button === 0 &&
|
||||
target.closest("summary, wa-dropdown>[slot='trigger'], .agent-chat__session-overrides-open")
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!connected) {
|
||||
return;
|
||||
}
|
||||
if (target.closest(COMPOSER_CHROME_INTERACTIVE_SELECTOR)) {
|
||||
return;
|
||||
}
|
||||
const currentTarget = event.currentTarget;
|
||||
if (!(currentTarget instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
currentTarget
|
||||
.querySelector<HTMLTextAreaElement>(".agent-chat__composer-combobox > textarea")
|
||||
?.focus({ preventScroll: true });
|
||||
|
||||
@@ -170,6 +170,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
|
||||
? html`<div
|
||||
class="agent-chat__input ${props.offline ? "agent-chat__input--offline" : ""}"
|
||||
@click=${(event: MouseEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
@pointerdown=${(event: PointerEvent) => focusComposerFromChrome(event, canCompose)}
|
||||
${ref(state.composerInputRef ?? undefined)}
|
||||
>
|
||||
${props.offline
|
||||
|
||||
Reference in New Issue
Block a user