Merge branch 'coach/wp5-clarify' into release/v1.39.4

# Conflicts:
#	src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:47:52 +02:00
commit 00a5a8862f
9 files changed
+1219 -50

No files matched your search

@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
// The card's only hook is the translator; the server one resolves the same
// bundles and returns the key for a missing string, like the client one. With
// it, the card can be called as a plain function and its handlers exercised
// (the node test environment has no DOM to click through).
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string) => getServerTranslator("en").t(key),
}),
}));
import { CoachClarificationCard } from "../clarification-card";
import type { CoachClarification } from "@/lib/ai/coach/types";
const METRIC: CoachClarification = {
kind: "metric",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.pulse",
label: "Pulse (server)",
value: { metric: "pulse" },
},
{
id: "c2",
labelKey: "coach.not.a.key",
label: "Server label",
value: { metric: "resting_hr" },
},
],
freeText: true,
};
function card(clarification: CoachClarification, disabled = false) {
const onChoose = vi.fn();
const tree = CoachClarificationCard({
clarification,
messageId: "m1",
disabled,
onChoose,
});
return { tree, onChoose, html: renderToStaticMarkup(tree) };
}
function choiceButtons(
node: ReactNode,
): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap(choiceButtons);
if (!isValidElement(node)) return [];
const el = node as ReactElement<Record<string, unknown>>;
const own = el.props["data-choice-id"] ? [el] : [];
return [...own, ...choiceButtons(el.props.children as ReactNode)];
}
describe("<CoachClarificationCard>", () => {
it("announces politely and takes no focus", () => {
const { html } = card(METRIC);
expect(html).toContain('aria-live="polite"');
expect(html).toContain('aria-label="The Coach has a question"');
expect(html).not.toContain("autofocus");
expect(html).not.toContain("autoFocus");
});
it("renders the catalog label, falling back to the server label", () => {
const { html } = card(METRIC);
expect(html).toContain('aria-label="Possible answers"');
expect(html).toContain(">Pulse</button>");
expect(html).toContain(">Server label</button>");
expect(html).toContain("You can also type your own answer.");
});
it("sends the tapped choice", () => {
const { tree, onChoose } = card(METRIC);
const buttons = choiceButtons(tree);
expect(buttons).toHaveLength(2);
(buttons[1].props.onClick as () => void)();
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
});
it("disables the choices while a turn is in flight", () => {
const { tree } = card(METRIC, true);
for (const button of choiceButtons(tree)) {
expect(button.props.disabled).toBe(true);
}
});
it("asks for a typed answer on a context question", () => {
const { html, tree } = card({
kind: "context",
choices: [],
freeText: true,
});
expect(choiceButtons(tree)).toHaveLength(0);
expect(html).not.toContain('role="group"');
expect(html).toContain("Type your answer below.");
});
});
@@ -2,12 +2,21 @@
/**
* v1.39.4 — the choices for a clarifying question, above the composer.
* The question itself is the assistant reply; a tap on a choice sends its
* label with `clarification: { messageId, choiceId }`, and a typed answer
* goes out with `clarification: { messageId }`.
* The question itself is the assistant reply in the thread, so the card
* does not repeat it: a short heading, the choices, and a meta line saying a
* typed answer works too. A tap on a choice sends its label with
* `clarification: { messageId, choiceId }`; a typed answer goes out with
* `clarification: { messageId }`.
*
* Not built yet: renders nothing.
* Announced politely when it appears and never takes focus: the person may
* already be typing.
*/
import { MessageCircleQuestionMark } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type {
CoachClarification,
CoachClarificationChoice,
@@ -22,6 +31,70 @@ export interface CoachClarificationCardProps {
onChoose: (choice: CoachClarificationChoice) => void;
}
export function CoachClarificationCard(_props: CoachClarificationCardProps) {
return null;
export function CoachClarificationCard({
clarification,
messageId,
disabled,
onChoose,
}: CoachClarificationCardProps) {
const { t } = useTranslations();
// The catalog string in the reader's locale when the bundle has it, the
// server's rendering when it does not.
const choiceLabel = (choice: CoachClarificationChoice) => {
const local = t(choice.labelKey);
return local === choice.labelKey ? choice.label : local;
};
const { choices } = clarification;
const hint =
choices.length === 0
? t(COACH_CLARIFY_UI_KEYS.contextHint)
: clarification.freeText
? t(COACH_CLARIFY_UI_KEYS.freeTextHint)
: null;
return (
<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"
>
<div className="flex items-center gap-2 px-4 md:px-6">
<MessageCircleQuestionMark
aria-hidden="true"
className="text-foreground size-4 shrink-0"
/>
<p className="text-sm font-medium">
{t(COACH_CLARIFY_UI_KEYS.cardLabel)}
</p>
</div>
{choices.length > 0 ? (
<div
role="group"
aria-label={t(COACH_CLARIFY_UI_KEYS.choicesLabel)}
className="flex flex-wrap gap-2 px-4 md:px-6"
>
{choices.map((choice) => (
<Button
key={choice.id}
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled}
data-choice-id={choice.id}
onClick={() => onChoose(choice)}
>
{choiceLabel(choice)}
</Button>
))}
</div>
) : null}
{hint ? (
<p className="text-muted-foreground px-4 text-xs md:px-6">{hint}</p>
) : null}
</Card>
);
}