From af0f78d63472f79443e139ebeac61714e5ed29e4 Mon Sep 17 00:00:00 2001 From: tianxingjian2406 <2550975@tongji.edu.cn> Date: Mon, 26 Oct 2026 23:59:17 +0800 Subject: [PATCH 1/3] Keep keyboard focus after Scroll to end or removing a queued message --- frontend/components/chat/composer.tsx | 1 + .../pro/queued-messages-interaction.test.tsx | 92 +++++++++++++++++++ frontend/components/pro/queued-messages.tsx | 19 +++- .../session-ui/scroll-to-end-pill.test.tsx | 53 +++++++++++ .../session-ui/scroll-to-end-pill.tsx | 3 + 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 frontend/components/pro/queued-messages-interaction.test.tsx create mode 100644 frontend/components/session-ui/scroll-to-end-pill.test.tsx diff --git a/frontend/components/chat/composer.tsx b/frontend/components/chat/composer.tsx index accd75dfd..6cf322954 100644 --- a/frontend/components/chat/composer.tsx +++ b/frontend/components/chat/composer.tsx @@ -457,6 +457,7 @@ export function Composer({ // a native dark pill in dark - never a white island clashing with the dark page.
diff --git a/frontend/components/pro/queued-messages-interaction.test.tsx b/frontend/components/pro/queued-messages-interaction.test.tsx new file mode 100644 index 000000000..20e2198cb --- /dev/null +++ b/frontend/components/pro/queued-messages-interaction.test.tsx @@ -0,0 +1,92 @@ +// The interaction half of the queued-messages tests: register happy-dom's +// globals BEFORE react-dom/client loads, mount the real QueuedMessages, remove +// a row, and read the live focus. Removing a row must not strand the keyboard +// user: focus lands on the next row's Remove, or the reply box when none left. +import { expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +if (typeof window === "undefined") GlobalRegistrator.register(); + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { QueuedMessages } = await import("./queued-messages"); +import type { QueuedMessage } from "./queued-messages"; + +const flushFrame = () => new Promise((resolve) => setTimeout(resolve, 60)); + +function mountComposer(): HTMLTextAreaElement { + const composer = document.createElement("div"); + composer.setAttribute("data-session-ui", "composer"); + const textarea = document.createElement("textarea"); + composer.appendChild(textarea); + document.body.appendChild(composer); + return textarea; +} + +function msg(id: string, position: number): QueuedMessage { + return { id, position, text: `queued ${id}` }; +} + +test("removing a queued message focuses the next row's Remove button", async () => { + const textarea = mountComposer(); + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + + let messages: QueuedMessage[] = [msg("a", 1), msg("b", 2)]; + const onRemove = async (id: string) => { + messages = messages.filter((m) => m.id !== id); + await rerender(); + }; + const rerender = async () => { + await act(async () => { + root.render(); + }); + }; + await rerender(); + + const first = host.querySelector('[aria-label="Remove queued message 1"]'); + expect(first).not.toBeNull(); + await act(async () => { + first?.click(); + }); + await flushFrame(); + + expect(document.activeElement?.getAttribute("aria-label")).toBe("Remove queued message 2"); + + root.unmount(); + textarea.parentElement?.remove(); + host.remove(); +}); + +test("removing the last queued message focuses the reply box", async () => { + const textarea = mountComposer(); + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + + let messages: QueuedMessage[] = [msg("only", 1)]; + const onRemove = async (id: string) => { + messages = messages.filter((m) => m.id !== id); + await rerender(); + }; + const rerender = async () => { + await act(async () => { + root.render(); + }); + }; + await rerender(); + + const remove = host.querySelector('[aria-label="Remove queued message 1"]'); + expect(remove).not.toBeNull(); + await act(async () => { + remove?.click(); + }); + await flushFrame(); + + expect(document.activeElement).toBe(textarea); + + root.unmount(); + textarea.parentElement?.remove(); + host.remove(); +}); diff --git a/frontend/components/pro/queued-messages.tsx b/frontend/components/pro/queued-messages.tsx index 52adba1d1..1e95a3c8d 100644 --- a/frontend/components/pro/queued-messages.tsx +++ b/frontend/components/pro/queued-messages.tsx @@ -22,10 +22,13 @@ export interface QueuedMessage { function Row({ message, + index, onSendNow, onRemove, }: { message: QueuedMessage; + /** Place of this row in the rendered list; the anchor for focus after removal. */ + index: number; onSendNow?: () => void; onRemove?: (id: string) => Promise | void; }) { @@ -37,6 +40,19 @@ function Row({ setFailed(false); try { await onRemove(message.id); + // The row (and its focused Remove button) has left the list; keep + // keyboard users in place on the next row's Remove, or the reply box + // when no queued message is left. Waits one frame for the list commit. + requestAnimationFrame(() => { + const buttons = document.querySelectorAll( + '[data-session-ui="queued-messages"] [aria-label^="Remove queued message"]', + ); + if (buttons.length > 0) { + buttons[Math.min(index, buttons.length - 1)]?.focus(); + } else { + document.querySelector('[data-session-ui="composer"] textarea')?.focus(); + } + }); } catch { setFailed(true); } finally { @@ -100,10 +116,11 @@ export function QueuedMessages({ aria-label="Queued messages" className="scrollbar-slim mb-1.5 flex max-h-40 flex-col gap-1 overflow-y-auto" > - {messages.map((message) => ( + {messages.map((message, index) => ( diff --git a/frontend/components/session-ui/scroll-to-end-pill.test.tsx b/frontend/components/session-ui/scroll-to-end-pill.test.tsx new file mode 100644 index 000000000..515f2aa26 --- /dev/null +++ b/frontend/components/session-ui/scroll-to-end-pill.test.tsx @@ -0,0 +1,53 @@ +// The interaction half of the scroll-to-end tests: register happy-dom's +// globals BEFORE react-dom/client loads, mount the real ScrollToEndPill next +// to a stand-in composer, click the pill, and read the live focus. The pill +// unmounts itself once the reader is at the end; that must not drop focus. +import { expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +if (typeof window === "undefined") GlobalRegistrator.register(); + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { ScrollToEndPill } = await import("./scroll-to-end-pill"); +import type { RefObject } from "react"; + +test("activating Scroll to end jumps and keeps focus in the reply box", async () => { + const composer = document.createElement("div"); + composer.setAttribute("data-session-ui", "composer"); + const textarea = document.createElement("textarea"); + composer.appendChild(textarea); + document.body.appendChild(composer); + + const scroller = document.createElement("div"); + document.body.appendChild(scroller); + Object.defineProperty(scroller, "scrollHeight", { value: 500, configurable: true }); + Object.defineProperty(scroller, "clientHeight", { value: 200, configurable: true }); + Object.defineProperty(scroller, "scrollTop", { + value: 0, + writable: true, + configurable: true, + }); + const scrollRef: RefObject = { current: scroller }; + + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + await act(async () => { + root.render(); + }); + + const pill = host.querySelector('[data-session-ui="scroll-to-end"]'); + expect(pill).not.toBeNull(); + + await act(async () => { + pill?.click(); + }); + + expect(scroller.scrollTop).toBe(500); + expect(document.activeElement).toBe(textarea); + + root.unmount(); + composer.remove(); + scroller.remove(); +}); diff --git a/frontend/components/session-ui/scroll-to-end-pill.tsx b/frontend/components/session-ui/scroll-to-end-pill.tsx index e3a1117e4..31ee5ac1b 100644 --- a/frontend/components/session-ui/scroll-to-end-pill.tsx +++ b/frontend/components/session-ui/scroll-to-end-pill.tsx @@ -47,6 +47,9 @@ export function ScrollToEndPill({ scrollRef }: { scrollRef: RefObject('[data-session-ui="composer"] textarea')?.focus(); }; return ( From a76ad595ebdaf45e3bed5c7c11521bb6a336eaa8 Mon Sep 17 00:00:00 2001 From: tianxingjian2406 <2550975@tongji.edu.cn> Date: Tue, 27 Oct 2026 00:04:56 +0800 Subject: [PATCH 2/3] Anchor reply box focus on the reply composer --- frontend/components/chat/composer.tsx | 1 - frontend/components/chat/reply-composer.tsx | 5 ++++- frontend/components/pro/queued-messages-interaction.test.tsx | 2 +- frontend/components/pro/queued-messages.tsx | 4 +++- frontend/components/session-ui/scroll-to-end-pill.test.tsx | 2 +- frontend/components/session-ui/scroll-to-end-pill.tsx | 4 +++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/frontend/components/chat/composer.tsx b/frontend/components/chat/composer.tsx index 6cf322954..accd75dfd 100644 --- a/frontend/components/chat/composer.tsx +++ b/frontend/components/chat/composer.tsx @@ -457,7 +457,6 @@ export function Composer({ // a native dark pill in dark - never a white island clashing with the dark page.
diff --git a/frontend/components/chat/reply-composer.tsx b/frontend/components/chat/reply-composer.tsx index b9701e0e6..46f5fb954 100644 --- a/frontend/components/chat/reply-composer.tsx +++ b/frontend/components/chat/reply-composer.tsx @@ -91,7 +91,10 @@ export function ReplyComposer({ // "Add key" actions open that provider's form here, above the input card. const keys = useStartFree(); return ( -
+
{/* Full column width, like the timeline above it: the two edges line up however wide the conversation is dragged. */}
diff --git a/frontend/components/pro/queued-messages-interaction.test.tsx b/frontend/components/pro/queued-messages-interaction.test.tsx index 20e2198cb..a518abcc9 100644 --- a/frontend/components/pro/queued-messages-interaction.test.tsx +++ b/frontend/components/pro/queued-messages-interaction.test.tsx @@ -16,7 +16,7 @@ const flushFrame = () => new Promise((resolve) => setTimeout(resolve, 60)) function mountComposer(): HTMLTextAreaElement { const composer = document.createElement("div"); - composer.setAttribute("data-session-ui", "composer"); + composer.setAttribute("data-session-ui", "reply-composer"); const textarea = document.createElement("textarea"); composer.appendChild(textarea); document.body.appendChild(composer); diff --git a/frontend/components/pro/queued-messages.tsx b/frontend/components/pro/queued-messages.tsx index 1e95a3c8d..ab84171a2 100644 --- a/frontend/components/pro/queued-messages.tsx +++ b/frontend/components/pro/queued-messages.tsx @@ -50,7 +50,9 @@ function Row({ if (buttons.length > 0) { buttons[Math.min(index, buttons.length - 1)]?.focus(); } else { - document.querySelector('[data-session-ui="composer"] textarea')?.focus(); + document + .querySelector('[data-session-ui="reply-composer"] textarea') + ?.focus(); } }); } catch { diff --git a/frontend/components/session-ui/scroll-to-end-pill.test.tsx b/frontend/components/session-ui/scroll-to-end-pill.test.tsx index 515f2aa26..00e639100 100644 --- a/frontend/components/session-ui/scroll-to-end-pill.test.tsx +++ b/frontend/components/session-ui/scroll-to-end-pill.test.tsx @@ -14,7 +14,7 @@ import type { RefObject } from "react"; test("activating Scroll to end jumps and keeps focus in the reply box", async () => { const composer = document.createElement("div"); - composer.setAttribute("data-session-ui", "composer"); + composer.setAttribute("data-session-ui", "reply-composer"); const textarea = document.createElement("textarea"); composer.appendChild(textarea); document.body.appendChild(composer); diff --git a/frontend/components/session-ui/scroll-to-end-pill.tsx b/frontend/components/session-ui/scroll-to-end-pill.tsx index 31ee5ac1b..f1c141653 100644 --- a/frontend/components/session-ui/scroll-to-end-pill.tsx +++ b/frontend/components/session-ui/scroll-to-end-pill.tsx @@ -49,7 +49,9 @@ export function ScrollToEndPill({ scrollRef }: { scrollRef: RefObject('[data-session-ui="composer"] textarea')?.focus(); + document + .querySelector('[data-session-ui="reply-composer"] textarea') + ?.focus(); }; return ( From 9411b0200d535c1434d1cc26466a149e13cbf27b Mon Sep 17 00:00:00 2001 From: tianxingjian2406 <2550975@tongji.edu.cn> Date: Tue, 27 Oct 2026 12:57:58 +0800 Subject: [PATCH 3/3] Keep focus on a surviving row when a queued message leaves later The app sends the cancel first and drops the row when the thread updates, so focusing the clicked row's own button left it on a control that unmounts. Focus the next row's Remove (or the reply box) right away instead. Scroll to end now hands focus to the reply box only for keyboard activation (click detail 0); a pointer tap no longer opens the on-screen keyboard. Both behaviours are covered in the interaction tests. --- .../pro/queued-messages-interaction.test.tsx | 42 +++++++++++++++++++ frontend/components/pro/queued-messages.tsx | 35 +++++++++------- .../session-ui/scroll-to-end-pill.test.tsx | 40 ++++++++++++++---- .../session-ui/scroll-to-end-pill.tsx | 14 ++++--- 4 files changed, 104 insertions(+), 27 deletions(-) diff --git a/frontend/components/pro/queued-messages-interaction.test.tsx b/frontend/components/pro/queued-messages-interaction.test.tsx index a518abcc9..c5a3229e5 100644 --- a/frontend/components/pro/queued-messages-interaction.test.tsx +++ b/frontend/components/pro/queued-messages-interaction.test.tsx @@ -2,6 +2,8 @@ // globals BEFORE react-dom/client loads, mount the real QueuedMessages, remove // a row, and read the live focus. Removing a row must not strand the keyboard // user: focus lands on the next row's Remove, or the reply box when none left. +// The app sends the cancel first and drops the row later, when the thread +// updates, so one test keeps the row mounted through the whole removal. import { expect, test } from "bun:test"; import { GlobalRegistrator } from "@happy-dom/global-registrator"; @@ -90,3 +92,43 @@ test("removing the last queued message focuses the reply box", async () => { textarea.parentElement?.remove(); host.remove(); }); + +test("a removal that lands later keeps focus on a surviving row's Remove", async () => { + const textarea = mountComposer(); + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + + // The app only sends the cancel here; the row leaves when the thread + // updates, so onRemove deliberately leaves the list untouched. + let messages: QueuedMessage[] = [msg("a", 1), msg("b", 2)]; + const onRemove = async () => {}; + const rerender = async () => { + await act(async () => { + root.render(); + }); + }; + await rerender(); + + const first = host.querySelector('[aria-label="Remove queued message 1"]'); + expect(first).not.toBeNull(); + await act(async () => { + first?.click(); + }); + await flushFrame(); + + // The clicked row is still mounted. Focus must sit on the next row's + // button, which survives the later list commit. + const surviving = host.querySelector('[aria-label="Remove queued message 2"]'); + expect(surviving).not.toBeNull(); + expect(document.activeElement).toBe(surviving); + + // The thread update finally drops the row; focus stays on that button. + messages = [msg("b", 1)]; + await rerender(); + expect(document.activeElement).toBe(surviving); + + root.unmount(); + textarea.parentElement?.remove(); + host.remove(); +}); diff --git a/frontend/components/pro/queued-messages.tsx b/frontend/components/pro/queued-messages.tsx index ab84171a2..19d5fe8e9 100644 --- a/frontend/components/pro/queued-messages.tsx +++ b/frontend/components/pro/queued-messages.tsx @@ -9,7 +9,7 @@ // the queued run itself, which the API fails before it ever starts. import { RiCloseLine } from "@remixicon/react"; -import { useState } from "react"; +import { useRef, useState } from "react"; export interface QueuedMessage { readonly id: string; @@ -34,27 +34,31 @@ function Row({ }) { const [busy, setBusy] = useState(false); const [failed, setFailed] = useState(false); + const removeButtonRef = useRef(null); const remove = async () => { if (!onRemove || busy) return; setBusy(true); setFailed(false); try { await onRemove(message.id); - // The row (and its focused Remove button) has left the list; keep - // keyboard users in place on the next row's Remove, or the reply box - // when no queued message is left. Waits one frame for the list commit. - requestAnimationFrame(() => { - const buttons = document.querySelectorAll( + // The app only sends the cancel here; the row leaves the list later, + // when the thread updates. Focus a control that survives either way - + // the next row's Remove, the previous row's when this was the last, or + // the reply box when no other row remains - and never this row's own + // button, which is about to unmount and would drop focus to the page. + const own = removeButtonRef.current; + const buttons = [ + ...document.querySelectorAll( '[data-session-ui="queued-messages"] [aria-label^="Remove queued message"]', - ); - if (buttons.length > 0) { - buttons[Math.min(index, buttons.length - 1)]?.focus(); - } else { - document - .querySelector('[data-session-ui="reply-composer"] textarea') - ?.focus(); - } - }); + ), + ].filter((button) => button !== own); + if (buttons.length > 0) { + buttons[Math.min(index, buttons.length - 1)]?.focus(); + } else { + document + .querySelector('[data-session-ui="reply-composer"] textarea') + ?.focus(); + } } catch { setFailed(true); } finally { @@ -83,6 +87,7 @@ function Row({ )} {onRemove && !message.pending && (