fix(coach): move focus to the composer after a chip or a clarification choice

A tapped follow-up chip goes away and a clarification choice disables as
the turn starts, which dropped focus to the page. Both now hand focus to
the composer, which stays focusable while a reply streams (read-only with
aria-disabled rather than disabled). The clarification question is
announced through a live region that is mounted before the question
arrives; the card itself no longer carries one.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 13:07:18 +02:00
1 parent 1dd74bfa20
commit 9913734961
8 files changed
+95 -11

No files matched your search

@@ -14,6 +14,11 @@ vi.mock("@/lib/i18n/context", () => ({
}),
}));
const focusComposer = vi.fn();
vi.mock("../composer-focus", () => ({
focusCoachComposer: () => focusComposer(),
}));
import { CoachClarificationCard } from "../clarification-card";
import type { CoachClarification } from "@/lib/ai/coach/types";
@@ -58,9 +63,11 @@ function choiceButtons(
}
describe("<CoachClarificationCard>", () => {
it("announces politely and takes no focus", () => {
it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
const { html } = card(METRIC);
expect(html).toContain('aria-live="polite"');
// The card mounts with its content, so a live region on the card itself
// is never heard; the conversation keeps one mounted for it.
expect(html).not.toContain("aria-live");
expect(html).toContain('aria-label="The Coach has a question"');
expect(html).not.toContain("autofocus");
expect(html).not.toContain("autoFocus");
@@ -78,8 +85,11 @@ describe("<CoachClarificationCard>", () => {
const { tree, onChoose } = card(METRIC);
const buttons = choiceButtons(tree);
expect(buttons).toHaveLength(2);
focusComposer.mockClear();
(buttons[1].props.onClick as () => void)();
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
// The choices disable while the answer runs: focus goes to the composer.
expect(focusComposer).toHaveBeenCalledTimes(1);
});
it("disables the choices while a turn is in flight", () => {
@@ -113,6 +113,18 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).not.toContain('data-slot="coach-hero"');
});
it("keeps the clarification live region mounted before any question arrives", () => {
// A region that mounts together with its content is not announced; the
// empty region has to be in the page first.
const html = render(
<CoachConversation surface="page" initialConversationId="older" />,
makeClient(),
);
expect(html).toMatch(
/<div[^>]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
);
});
it("auto-opens the most-recent thread when none is pinned", () => {
const html = render(
<CoachConversation surface="page" autoOpenMostRecent />,
@@ -111,6 +111,23 @@ describe("<CoachInput>", () => {
expect(html).toContain("animate-spin");
});
it("keeps the textarea focusable while a reply streams: read-only, not disabled", () => {
const html = render(
<CoachInput
value=""
onChange={() => {}}
onSubmit={() => {}}
disabled
isStreaming
/>,
);
const textarea = html.match(/<textarea[^>]*>/)?.[0];
expect(textarea).toContain('id="coach-composer-textarea"');
expect(textarea).toMatch(/\sreadonly(=""|\s|>)/i);
expect(textarea).toContain('aria-disabled="true"');
expect(textarea).not.toMatch(/\sdisabled(=""|\s|>)/);
});
it("swaps the send button for a Stop control while streaming with onCancel", () => {
// v1.11.3 D1 — while a reply streams the composer must surface a
// visible Stop affordance bound to the abort handler so the user
@@ -8,8 +8,10 @@
* `clarification: { messageId, choiceId }`; a typed answer goes out with
* `clarification: { messageId }`.
*
* Announced politely when it appears and never takes focus: the person may
* already be typing.
* Announced politely when it appears, through the live region the
* conversation keeps mounted beside it (a region that arrives with its
* content is never heard), and never takes focus on its own: the person may
* already be typing. A tapped choice hands focus to the composer.
*/
import { MessageCircleQuestionMark } from "lucide-react";
@@ -22,6 +24,8 @@ import type {
CoachClarificationChoice,
} from "@/lib/ai/coach/types";
import { focusCoachComposer } from "./composer-focus";
export interface CoachClarificationCardProps {
clarification: CoachClarification;
/** The assistant message that asked. */
@@ -56,7 +60,6 @@ export function CoachClarificationCard({
<Card
data-slot="coach-clarification-card"
data-message-id={messageId}
aria-live="polite"
aria-label={t(COACH_CLARIFY_UI_KEYS.cardLabel)}
role="region"
className="gap-2 py-3 md:py-4"
@@ -85,7 +88,10 @@ export function CoachClarificationCard({
className="min-h-11 sm:min-h-9"
disabled={disabled}
data-choice-id={choice.id}
onClick={() => onChoose(choice)}
onClick={() => {
onChoose(choice);
focusCoachComposer();
}}
>
{choiceLabel(choice)}
</Button>
@@ -16,6 +16,7 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type {
@@ -861,6 +862,17 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
{/* v1.39.4 — mounted empty before any question, so the question is
announced when it arrives (a live region that mounts with its
content is not heard). Visually hidden, so it takes no room in the
stack; the card below carries the choices. */}
<div
data-slot="coach-clarification-live"
aria-live="polite"
className="sr-only"
>
{openClarification ? t(COACH_CLARIFY_UI_KEYS.cardLabel) : ""}
</div>
{openClarification ? (
<CoachClarificationCard
clarification={openClarification.clarification}
@@ -31,6 +31,8 @@ import {
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_COMPOSER_ID } from "./composer-focus";
/**
* v1.4.20 phase B2b — Coach composer.
*
@@ -172,7 +174,7 @@ export function CoachInput({
onCancel,
disabled = false,
isStreaming = false,
inputId = "coach-composer-textarea",
inputId = COACH_COMPOSER_ID,
autoFocusOnOpen = false,
placeholder,
showHub = false,
@@ -446,7 +448,11 @@ export function CoachInput({
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
disabled={disabled}
// Read-only rather than disabled while a reply streams: a disabled
// field drops focus, and a follow-up chip or a clarification choice
// hands focus here right as the turn starts.
readOnly={disabled}
aria-disabled={disabled || undefined}
enterKeyHint="send"
autoCapitalize="sentences"
rows={1}
@@ -455,7 +461,7 @@ export function CoachInput({
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
"[scrollbar-width:thin] [scrollbar-color:color-mix(in_srgb,var(--primary)_35%,transparent)_transparent]",
"placeholder:text-muted-foreground disabled:opacity-60",
"placeholder:text-muted-foreground aria-disabled:opacity-60",
"placeholder:overflow-hidden placeholder:text-ellipsis placeholder:whitespace-nowrap",
)}
/>
@@ -0,0 +1,15 @@
/**
* v1.39.4 — the Coach composer's id, and handing focus to it.
*
* A follow-up chip or a clarification choice sends a message and then goes
* away (chips hide while the turn runs, choices disable), which would drop
* keyboard and screen-reader focus to the page. Focus moves to the composer
* instead: that is where the person continues. The composer stays focusable
* while a reply streams (it is read-only then, not disabled).
*/
export const COACH_COMPOSER_ID = "coach-composer-textarea";
export function focusCoachComposer(): void {
if (typeof document === "undefined") return;
document.getElementById(COACH_COMPOSER_ID)?.focus();
}
@@ -9,13 +9,16 @@
* re-rendered from `labelKey` here: a related-metric chip names its metric
* in the label, and the key alone does not carry it. Hidden while a turn is
* in flight, so a chip can never be tapped against a reply that is about to
* stop being the latest.
* stop being the latest. The tapped chip goes away with the rest, so focus
* moves to the composer.
*/
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
import { focusCoachComposer } from "./composer-focus";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
@@ -54,7 +57,10 @@ export function CoachFollowUpChips({
className="min-h-11"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => onSelect(chip, messageId)}
onClick={() => {
onSelect(chip, messageId);
focusCoachComposer();
}}
>
{chip.label}
</Button>