diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts index cb873c56725..d6db0841122 100644 --- a/ui/src/e2e/chat-flow.e2e.test.ts +++ b/ui/src/e2e/chat-flow.e2e.test.ts @@ -617,6 +617,53 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => { } }); + it("routes page typing to the active composer without stealing text input focus", async () => { + const context = await newBrowserContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { height: 900, width: 1280 }, + }); + const page = await context.newPage(); + await installMockGateway(page, { + historyMessages: [ + { + content: [{ text: "Type whenever you are ready.", type: "text" }], + role: "assistant", + timestamp: Date.now(), + }, + ], + }); + + try { + await page.goto(`${server.baseUrl}chat`); + await page.getByText("Type whenever you are ready.").click(); + + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await expect + .poll(() => composer.evaluate((element) => element === document.activeElement)) + .toBe(false); + + await page.keyboard.type("first character preserved"); + expect(await composer.inputValue()).toBe("first character preserved"); + await expect + .poll(() => composer.evaluate((element) => element === document.activeElement)) + .toBe(true); + + await page.getByRole("button", { name: "Open command palette" }).click(); + const paletteInput = page.locator(".cmd-palette__input"); + await paletteInput.waitFor({ state: "visible", timeout: 10_000 }); + await expect + .poll(() => paletteInput.evaluate((element) => element === document.activeElement)) + .toBe(true); + await page.keyboard.type("session search"); + + expect(await paletteInput.inputValue()).toBe("session search"); + expect(await composer.inputValue()).toBe("first character preserved"); + } finally { + await closeBrowserContext(context); + } + }); + it("keeps stale context visible as approximate without warning or compaction", async () => { const context = await newBrowserContext({ locale: "en-US", diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 7be93784ce7..d861b4fb1c4 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -88,6 +88,12 @@ type PaneSessionChangeOptions = { replace?: boolean }; const CHAT_OPEN_DETAILS_SELECTOR = ".chat-controls__inline-select[open], .context-usage details[open], .agent-chat__talk-select[open], .agent-chat__attach-menu[open]"; +const CHAT_COMPOSER_TEXTAREA_SELECTOR = ".agent-chat__composer-combobox > textarea"; +const CHAT_TEXT_ENTRY_SELECTOR = + "input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='combobox'], [role='listbox'], [role='textbox']"; +const CHAT_SPACE_ACTIVATION_SELECTOR = + "a[href], button, summary, [role='button'], [role='checkbox'], [role='link'], [role='radio'], [role='switch']"; +const CHAT_MODAL_SELECTOR = "dialog[open], [aria-modal='true']"; const NEW_SESSION_ACTIVE_RUN_MESSAGE = "Start a new session after the active run or queued messages finish."; @@ -96,6 +102,12 @@ const NEW_SESSION_LIST_LOADING_MESSAGE = const NEW_SESSION_CREATE_FAILED_MESSAGE = "New Chat could not create a new session. Try again in a moment."; +function keyboardEventPathMatches(event: KeyboardEvent, selector: string): boolean { + return event + .composedPath() + .some((target) => target instanceof Element && target.matches(selector)); +} + class ChatPane extends LitElement { @consume({ context: applicationContext, subscribe: false }) private context!: ChatPageContext; @@ -341,6 +353,26 @@ class ChatPane extends LitElement { } private readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if ( + this.active && + !event.defaultPrevented && + !event.isComposing && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + event.key.length === 1 && + !keyboardEventPathMatches(event, CHAT_TEXT_ENTRY_SELECTOR) && + !(event.key === " " && keyboardEventPathMatches(event, CHAT_SPACE_ACTIVATION_SELECTOR)) && + !document.querySelector(CHAT_MODAL_SELECTOR) + ) { + const composer = this.querySelector(CHAT_COMPOSER_TEXTAREA_SELECTOR); + if (composer && !composer.disabled && !composer.readOnly) { + // Focus during keydown capture so the browser delivers beforeinput/input, + // including the first character, through the composer's normal pipeline. + composer.focus({ preventScroll: true }); + } + } + if (event.defaultPrevented || event.key !== "Escape") { return; }