diff --git a/frontend/components/chat/reply-composer.tsx b/frontend/components/chat/reply-composer.tsx index b9701e0e..46f5fb95 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 new file mode 100644 index 00000000..c5a3229e --- /dev/null +++ b/frontend/components/pro/queued-messages-interaction.test.tsx @@ -0,0 +1,134 @@ +// 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. +// 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"; + +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", "reply-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(); +}); + +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 52adba1d..19d5fe8e 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; @@ -22,21 +22,43 @@ 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; }) { 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 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"]', + ), + ].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 { @@ -65,6 +87,7 @@ function Row({ )} {onRemove && !message.pending && (