Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion frontend/components/chat/reply-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export function ReplyComposer({
// "Add key" actions open that provider's form here, above the input card.
const keys = useStartFree();
return (
<div className="shrink-0 px-5 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2">
<div
data-session-ui="reply-composer"
className="shrink-0 px-5 pb-[max(1rem,env(safe-area-inset-bottom))] pt-2"
>
{/* Full column width, like the timeline above it: the two edges line up
however wide the conversation is dragged. */}
<div className="w-full">
Expand Down
134 changes: 134 additions & 0 deletions frontend/components/pro/queued-messages-interaction.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((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(<QueuedMessages messages={messages} onRemove={onRemove} />);
});
};
await rerender();

const first = host.querySelector<HTMLButtonElement>('[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(<QueuedMessages messages={messages} onRemove={onRemove} />);
});
};
await rerender();

const remove = host.querySelector<HTMLButtonElement>('[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(<QueuedMessages messages={messages} onRemove={onRemove} />);
});
};
await rerender();

const first = host.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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();
});
28 changes: 26 additions & 2 deletions frontend/components/pro/queued-messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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> | void;
}) {
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const removeButtonRef = useRef<HTMLButtonElement>(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<HTMLButtonElement>(
'[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<HTMLElement>('[data-session-ui="reply-composer"] textarea')
?.focus();
}
} catch {
setFailed(true);
} finally {
Expand Down Expand Up @@ -65,6 +87,7 @@ function Row({
)}
{onRemove && !message.pending && (
<button
ref={removeButtonRef}
type="button"
aria-label={`Remove queued message ${message.position}`}
title="Remove from the queue"
Expand Down Expand Up @@ -100,10 +123,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) => (
<Row
key={message.id}
message={message}
index={index}
onSendNow={message.id === sendNowFor ? onSendNow : undefined}
onRemove={onRemove}
/>
Expand Down
79 changes: 79 additions & 0 deletions frontend/components/session-ui/scroll-to-end-pill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// 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, activate the pill, and read the live focus. The pill
// unmounts itself once the reader is at the end; a keyboard activation must
// keep focus in the reply box, while a pointer tap must not pull focus into it
// (an input focus opens the on-screen keyboard on phones).
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";

async function mountPill() {
const composer = document.createElement("div");
composer.setAttribute("data-session-ui", "reply-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<HTMLElement | null> = { current: scroller };

const host = document.createElement("div");
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(<ScrollToEndPill scrollRef={scrollRef} />);
});

const pill = host.querySelector<HTMLButtonElement>('[data-session-ui="scroll-to-end"]');
expect(pill).not.toBeNull();

const cleanup = () => {
root.unmount();
composer.remove();
scroller.remove();
};
return { pill: pill as HTMLButtonElement, scroller, textarea, cleanup };
}

test("keyboard activation jumps and keeps focus in the reply box", async () => {
const { pill, scroller, textarea, cleanup } = await mountPill();

// Enter/Space on a button fire a click with detail 0.
await act(async () => {
pill.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
});

expect(scroller.scrollTop).toBe(500);
expect(document.activeElement).toBe(textarea);

cleanup();
});

test("pointer tap jumps without pulling focus into the reply box", async () => {
const { pill, scroller, textarea, cleanup } = await mountPill();

// A mouse or touch tap fires a click with a non-zero detail.
await act(async () => {
pill.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 }));
});

expect(scroller.scrollTop).toBe(500);
expect(document.activeElement).not.toBe(textarea);

cleanup();
});
13 changes: 11 additions & 2 deletions frontend/components/session-ui/scroll-to-end-pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
// touch the container's own stick-to-bottom logic (it only reads scroll offset).

import { RiArrowDownLine } from "@remixicon/react";
import { type RefObject, useEffect, useState } from "react";
import { type MouseEvent, type RefObject, useEffect, useState } from "react";

/** Distance from the bottom (px) under which the reader counts as "at the end" -
* matches the conversation's own stick-to-bottom threshold. */
Expand Down Expand Up @@ -43,10 +43,19 @@ export function ScrollToEndPill({ scrollRef }: { scrollRef: RefObject<HTMLElemen

if (atBottom) return null;

const scrollToEnd = () => {
const scrollToEnd = (event: MouseEvent<HTMLButtonElement>) => {
const el = scrollRef.current;
if (!el) return;
jumpToEnd(el);
// The pill unmounts once the reader is at the end, taking its focus with
// it; hand focus to the reply box so keyboard users keep their place.
// Pointer taps skip the hand-off: focusing an input pops open the
// on-screen keyboard on phones.
if (event.detail === 0) {
document
.querySelector<HTMLElement>('[data-session-ui="reply-composer"] textarea')
?.focus();
}
};

return (
Expand Down
Loading