fix(ui): preserve focus on composer accessory clicks (#122243)

This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 17:50:28 -03:00
committed by GitHub
parent d699662083
commit 776c19eaaa
4 changed files with 162 additions and 12 deletions
@@ -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