diff --git a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx b/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx index e143af716..e7d28a2f3 100644 --- a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx +++ b/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx @@ -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("", () => { - 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("", () => { 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", () => { diff --git a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx index 9cd9cd593..3cd40e333 100644 --- a/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-conversation-deeplink.test.tsx @@ -113,6 +113,18 @@ describe(" 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( + , + makeClient(), + ); + expect(html).toMatch( + /]*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( , diff --git a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx index caa593af2..99e93bcea 100644 --- a/src/components/insights/coach-panel/__tests__/coach-input.test.tsx +++ b/src/components/insights/coach-panel/__tests__/coach-input.test.tsx @@ -111,6 +111,23 @@ describe("", () => { expect(html).toContain("animate-spin"); }); + it("keeps the textarea focusable while a reply streams: read-only, not disabled", () => { + const html = render( + {}} + onSubmit={() => {}} + disabled + isStreaming + />, + ); + const textarea = html.match(/]*>/)?.[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 diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx index df1db9c0c..2bc5a374c 100644 --- a/src/components/insights/coach-panel/clarification-card.tsx +++ b/src/components/insights/coach-panel/clarification-card.tsx @@ -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({ onChoose(choice)} + onClick={() => { + onChoose(choice); + focusCoachComposer(); + }} > {choiceLabel(choice)} diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx index 7f2d30cf9..a0ac9c1dd 100644 --- a/src/components/insights/coach-panel/coach-conversation.tsx +++ b/src/components/insights/coach-panel/coach-conversation.tsx @@ -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")}

) : 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. */} +
+ {openClarification ? t(COACH_CLARIFY_UI_KEYS.cardLabel) : ""} +
{openClarification ? ( 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", )} /> diff --git a/src/components/insights/coach-panel/composer-focus.ts b/src/components/insights/coach-panel/composer-focus.ts new file mode 100644 index 000000000..afcecf39d --- /dev/null +++ b/src/components/insights/coach-panel/composer-focus.ts @@ -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(); +} diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx index ff40dd7f3..525319dcc 100644 --- a/src/components/insights/coach-panel/follow-up-chips.tsx +++ b/src/components/insights/coach-panel/follow-up-chips.tsx @@ -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}