feat(coach): ask back when the metric or window is ambiguous

A reply may end with a ---CLARIFY--- block (kind metric, window or
context, 512-byte cap). The server keeps only metric choices the record
holds, drops the question when fewer than two remain, takes window
choices from the presets, screens the question text like any reply, and
never offers a second question right after one. Choice labels come from
the catalog, never from the model.

An answer arrives as clarification { messageId, choiceId? }; it resolves
against the stored question on the conversation's latest message and
adds one server-written line to the next turn's context. The card sits
above the composer, announces politely and takes no focus.

A reply that is only the block, with no question before it, is now an
empty reply instead of falling back to the unstripped text.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:33:32 +02:00
1 parent 78fc22d0f8
commit 6b07d48bf9
11 files changed
+1223 -52

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>
);
}