feat(coach): show what the Coach looked at above the answer

While a turn runs, the header names the read in progress with a soft
shimmer; when it ends the list folds into "Looked at N sources". Each
row reads domain, window, and the count or the reason nothing was
found. Completed steps are announced through a polite status region at
most once every 1.5 s, and a reloaded conversation stays quiet. The
spinner and shimmer stop under reduced motion.
This commit is contained in:
Marc-André Bombeck committed 2026-09-27 11:44:55 +02:00
1 parent ea2eaf2074
commit 1914764ddc
2 files changed
+580 -6

No files matched your search

@@ -0,0 +1,240 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
import type { CoachStep } from "@/lib/ai/coach/types";
import {
CoachTurnStepList,
CoachTurnSteps,
currentStep,
describeStep,
nextAnnouncement,
stepLabel,
} from "../turn-steps";
function render(node: React.ReactNode, locale: Locale = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function translators(locale: Locale) {
const { t } = getServerTranslator(locale);
const tCount = (base: string, count: number) =>
t(pluralKey(base, count, locale), { count });
return { t, tCount };
}
const BP: CoachStep = {
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Reading Blood pressure, last 90 days",
domain: "bp",
window: "last90days",
status: "done",
count: 142,
};
const SLEEP: CoachStep = {
id: "s2",
tool: "get_sleep",
labelKey: "coach.step.readWindow",
label: "Reading Sleep, last 30 days",
domain: "sleep",
window: "last30days",
status: "empty",
reason: "no_data",
};
const LABS_RUNNING: CoachStep = {
id: "s3",
tool: "get_labs",
labelKey: "coach.step.readWindow",
label: "Reading Lab results, last 12 months",
domain: "labs",
window: "lastYear",
status: "running",
};
describe("describeStep", () => {
const { t, tCount } = translators("en");
it("reads domain · window · count on a found step", () => {
expect(describeStep(BP, t, tCount)).toEqual({
title: "Blood pressure",
meta: ["last 90 days", "142 readings"],
});
});
it("says why nothing was found on an empty step", () => {
expect(describeStep(SLEEP, t, tCount)).toEqual({
title: "Sleep",
meta: ["last 30 days", "no readings"],
});
});
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
const outside: CoachStep = {
...SLEEP,
reason: "outside_window",
count: 40,
};
expect(describeStep(outside, t, tCount).meta).toEqual([
"last 30 days",
"no readings in this window",
]);
});
it("counts metrics on the snapshot step and rows on labs", () => {
const snapshot: CoachStep = {
id: "s1",
tool: "snapshot",
labelKey: "coach.step.snapshot",
label: "Reading your record summary",
domain: "snapshot",
status: "done",
count: 1,
};
expect(describeStep(snapshot, t, tCount)).toEqual({
title: "Record summary",
meta: ["1 metric"],
});
expect(
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
.meta,
).toEqual(["last 12 months", "3 rows"]);
});
it("renders in the reader's language from the keys, not the server label", () => {
const de = translators("de");
expect(stepLabel(BP, de.t)).toBe("Lese Blutdruck, letzte 90 Tage");
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
"letzte 90 Tage",
"142 Messwerte",
]);
});
it("falls back to the server label for a key the bundle lacks", () => {
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
BP.label,
);
});
});
describe("currentStep / nextAnnouncement", () => {
it("names the latest running step, else the latest step", () => {
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
expect(currentStep([])).toBeNull();
});
it("announces only settled steps not yet told, the newest one", () => {
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
"s2",
);
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
});
});
describe("<CoachTurnSteps>", () => {
it("renders nothing without steps", () => {
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
"coach-turn-steps",
);
});
it("while active: the running step in the header, with a stable accessible name", () => {
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
expect(html).toContain('data-slot="coach-turn-steps-active"');
expect(html).toContain("Reading Lab results, last 12 months…");
expect(html).toContain('aria-label="Show what the Coach looked at"');
expect(html).toContain('aria-expanded="false"');
// The shimmer comes from the class that stops under reduced motion.
expect(html).toContain("skeleton-shimmer");
// Collapsed by default.
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
});
it("when done: folds into a count of sources, closed", () => {
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
expect(html).toContain("Looked at 2 sources");
expect(html).not.toContain("skeleton-shimmer");
expect(html).not.toContain("aria-label=");
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
});
it("carries a polite status region that starts empty", () => {
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
);
});
it("uses theme tokens only, and every motion stops under reduced motion", () => {
const html =
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />) +
render(
<CoachTurnStepList
steps={[
BP,
SLEEP,
LABS_RUNNING,
{
...SLEEP,
id: "s4",
status: "failed",
reason: "retrieval_failed",
},
]}
active
/>,
);
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
expect(html).not.toMatch(/text-muted-foreground\/\d/);
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
m[1].split(/\s+/),
);
for (const cls of classes) {
if (cls.startsWith("transition")) {
throw new Error(`unguarded transition: ${cls}`);
}
}
// The spinner stops under reduced motion (the repo-wide convention).
expect(html).toContain("animate-spin motion-reduce:animate-none");
});
});
describe("<CoachTurnStepList>", () => {
it("one row per step: domain · window · count or reason, with its status", () => {
const html = render(
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
);
expect(html).toContain('aria-label="What the Coach looked at"');
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
expect(html).toContain('data-status="done"');
expect(html).toContain('data-status="empty"');
expect(html).toContain('data-status="running"');
const text = html
.replace(/<span class="sr-only">, <\/span>/g, "")
.replace(/<[^>]+>/g, "");
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
expect(text).toContain("Sleep · last 30 days · no readings");
expect(text).toContain("Lab results · last 12 months");
});
it("a reloaded list shows the same rows", () => {
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
const stored = render(
<CoachTurnStepList
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
active={false}
/>,
);
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
expect(text(stored)).toBe(text(live));
});
});
@@ -1,12 +1,41 @@
"use client";
/**
* v1.39.4 — what the Coach read on a turn: a compact list of steps under
* the assistant avatar, live while the turn runs (from the `step` frames)
* and restored from `metricSource.steps` on reload.
* v1.39.4 — what the Coach read on a turn: a compact list of steps above
* the answer, live while the turn runs (from the `step` frames) and
* restored from `metricSource.steps` on reload.
*
* Not built yet: renders nothing.
* While the turn runs the header names the step in progress ("Reading
* Blood pressure, last 90 days…") with a soft shimmer; once it ends the
* header folds into "Looked at 3 sources" and the list closes. The list
* itself is one row per step: domain, window, and a count or the reason
* nothing was found. Everything shown comes from catalog keys and the
* server's enums and counts; a row never shows a value, a tool argument or
* model text.
*
* Screen readers hear each completed step once, through a polite status
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
* wins). A reloaded conversation announces nothing.
*/
import { useEffect, useId, useRef, useState } from "react";
import {
Check,
ChevronRight,
Loader2,
Minus,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
COACH_STEP_REASON_KEYS,
COACH_STEP_UI_KEYS,
coachDomainLabelKey,
coachGranularityLabelKey,
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachStep } from "@/lib/ai/coach/types";
export interface CoachTurnStepsProps {
@@ -15,6 +44,311 @@ export interface CoachTurnStepsProps {
active: boolean;
}
export function CoachTurnSteps(_props: CoachTurnStepsProps) {
return null;
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type TranslateCount = (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
function tOr(
t: Translate,
key: string,
fallback: string,
params?: Record<string, string | number>,
) {
const value = t(key, params);
return value === key ? fallback : value;
}
/**
* The step's label in the reader's language: the catalog key rendered with
* the domain and window names, or the server's label when the key is not
* in the bundle.
*/
export function stepLabel(step: CoachStep, t: Translate): string {
return tOr(t, step.labelKey, step.label, {
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
});
}
/**
* One row's text: the domain as the title, then the window, period,
* granularity, and either how much was read or why nothing was.
*/
export function describeStep(
step: CoachStep,
t: Translate,
tCount: TranslateCount,
): { title: string; meta: string[] } {
const title = step.domain
? t(coachDomainLabelKey(step.domain))
: stepLabel(step, t);
const meta: string[] = [];
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
if (step.period && step.period !== "current") {
meta.push(t(coachPeriodLabelKey(step.period)));
}
if (step.granularity)
meta.push(t(coachGranularityLabelKey(step.granularity)));
if (step.status === "done" && step.count !== undefined) {
const base =
step.tool === "snapshot"
? "coach.step.metrics"
: step.tool === "get_labs"
? "coach.step.rows"
: "coach.step.readings";
meta.push(tCount(base, step.count));
} else if (step.status !== "running" && step.reason) {
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
}
return { title, meta };
}
function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
const { title, meta } = describeStep(step, t, tCount);
return [title, ...meta].join(" · ");
}
/**
* The step the active header names: the latest one still running, else the
* latest one overall (between two rounds nothing is running).
*/
export function currentStep(steps: CoachStep[]): CoachStep | null {
for (let i = steps.length - 1; i >= 0; i -= 1) {
if (steps[i].status === "running") return steps[i];
}
return steps.at(-1) ?? null;
}
/**
* The newest settled step the reader has not been told about yet, given the
* ids already announced (or present when the list mounted). Null when there
* is nothing new.
*/
export function nextAnnouncement(
steps: CoachStep[],
told: ReadonlySet<string>,
): CoachStep | null {
let latest: CoachStep | null = null;
for (const step of steps) {
if (step.status !== "running" && !told.has(step.id)) latest = step;
}
return latest;
}
/**
* The status region's text: each newly settled step, throttled. Steps that
* were already settled when the list mounted are never announced, so a
* reloaded conversation stays quiet.
*/
function useStepAnnouncement(
steps: CoachStep[],
describe: (step: CoachStep) => string,
): string {
// Mutable on purpose: the ids the reader has been told about, seeded with
// whatever was already settled at mount.
const [told] = useState(
() => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
);
const lastAt = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [text, setText] = useState("");
useEffect(() => {
const next = nextAnnouncement(steps, told);
if (!next) return;
for (const step of steps) {
if (step.status !== "running") told.add(step.id);
}
const nextText = describe(next);
const wait = Math.max(
0,
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
timer.current = null;
lastAt.current = Date.now();
setText(nextText);
}, wait);
}, [steps, describe, told]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
return text;
}
function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
const className = "mt-0.5 size-3 shrink-0";
if (step.status === "running" && active) {
return (
<Loader2
aria-hidden="true"
className={cn(
className,
"text-muted-foreground animate-spin motion-reduce:animate-none",
)}
/>
);
}
if (step.status === "done") {
return (
<Check
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
if (step.status === "failed") {
return (
<TriangleAlert
aria-hidden="true"
className={cn(className, "text-warning")}
/>
);
}
return (
<Minus
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
/** The rows under the header: one per step, in the order they started. */
export function CoachTurnStepList({
id,
steps,
active,
}: CoachTurnStepsProps & { id?: string }) {
const { t, tCount } = useTranslations();
return (
<ol
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
return (
<li
key={step.id}
data-slot="coach-turn-step"
data-status={step.status}
className="flex items-start gap-1.5 leading-relaxed"
>
<StepIcon step={step} active={active} />
<span className="text-muted-foreground min-w-0">
<span className="text-foreground/80">{title}</span>
{meta.map((part, i) => (
<span key={i}>
<span aria-hidden="true"> · </span>
<span className="sr-only">, </span>
{part}
</span>
))}
</span>
</li>
);
})}
</ol>
);
}
export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
const { t, tCount } = useTranslations();
const listId = useId();
// Closed by default; the reader's toggle holds until the turn ends, and
// the list folds away again when it does.
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
const [wasActive, setWasActive] = useState(active);
if (wasActive !== active) {
setWasActive(active);
setOpenOverride(null);
}
const announcement = useStepAnnouncement(steps, (step) =>
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
);
if (steps.length === 0) return null;
const open = openOverride ?? false;
const current = active ? currentStep(steps) : null;
return (
<div
data-slot="coach-turn-steps"
className="flex max-w-full flex-col text-xs"
>
<button
type="button"
data-slot="coach-turn-steps-toggle"
aria-expanded={open}
aria-controls={open ? listId : undefined}
// While the turn runs the visible text changes with every step; the
// status region below announces completions, so the button keeps a
// stable name instead of re-announcing each label.
{...(current
? {
"aria-label": t(
open
? COACH_STEP_UI_KEYS.toggleHide
: COACH_STEP_UI_KEYS.toggleShow,
),
}
: {})}
onClick={() => setOpenOverride(!open)}
className={cn(
"text-muted-foreground hover:text-foreground flex w-fit max-w-full items-center gap-1.5",
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
{current ? (
<span
aria-hidden="true"
data-slot="coach-turn-steps-active"
className="skeleton-shimmer min-w-0 truncate rounded"
>
{t(COACH_STEP_UI_KEYS.headerActive, {
label: stepLabel(current, t),
})}
</span>
) : (
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
{tCount("coach.step.headerDone", steps.length)}
</span>
)}
</button>
{open && <CoachTurnStepList id={listId} steps={steps} active={active} />}
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</span>
</div>
);
}