From 776c19eaaa4b1a52877c5a78a4f47e6b0a4877ac Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Tue, 11 Aug 2026 17:50:28 -0300 Subject: [PATCH] fix(ui): preserve focus on composer accessory clicks (#122243) --- .../chat-composer-accessory-focus.e2e.test.ts | 141 ++++++++++++++++++ .../pages/chat/components/chat-attachments.ts | 8 - .../chat/components/chat-composer-dom.ts | 24 ++- .../chat/components/chat-composer-view.ts | 1 + 4 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts diff --git a/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts new file mode 100644 index 000000000000..eae397457712 --- /dev/null +++ b/ui/src/e2e/chat-composer-accessory-focus.e2e.test.ts @@ -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("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"); + } + }); + }); +}); diff --git a/ui/src/pages/chat/components/chat-attachments.ts b/ui/src/pages/chat/components/chat-attachments.ts index 616e73808b35..d3e2725bf3cb 100644 --- a/ui/src/pages/chat/components/chat-attachments.ts +++ b/ui/src/pages/chat/components/chat-attachments.ts @@ -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} diff --git a/ui/src/pages/chat/components/chat-composer-dom.ts b/ui/src/pages/chat/components/chat-composer-dom.ts index 143e14bc495d..57b94961dc40 100644 --- a/ui/src/pages/chat/components/chat-composer-dom.ts +++ b/ui/src/pages/chat/components/chat-composer-dom.ts @@ -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(".agent-chat__composer-combobox > textarea") ?.focus({ preventScroll: true }); diff --git a/ui/src/pages/chat/components/chat-composer-view.ts b/ui/src/pages/chat/components/chat-composer-view.ts index f06c61cbe084..45eecaff5083 100644 --- a/ui/src/pages/chat/components/chat-composer-view.ts +++ b/ui/src/pages/chat/components/chat-composer-view.ts @@ -170,6 +170,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) { ? html`
focusComposerFromChrome(event, canCompose)} + @pointerdown=${(event: PointerEvent) => focusComposerFromChrome(event, canCompose)} ${ref(state.composerInputRef ?? undefined)} > ${props.offline