diff --git a/messages/en.json b/messages/en.json index 4ae37f0a7..aaf1bacfd 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9485,8 +9485,8 @@ }, "noRecordedData": "I don't have any recorded readings for what you asked about, so there is nothing I can give you a figure from. Once you log some, ask me again and I'll work from your own numbers.", "step": { - "read": "Reading {domain}", - "readWindow": "Reading {domain}, {window}", + "read": "Checking: {domain}", + "readWindow": "Checking: {domain}, {window}", "snapshot": "Reading your record summary", "showResult": "Opening an earlier table", "reuse": "Using the table from before", diff --git a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap index f196add90..591a86a5a 100644 --- a/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap +++ b/src/app/api/insights/chat/__tests__/__snapshots__/golden-transcripts.test.ts.snap @@ -816,6 +816,17 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot "metrics": [ "bp", ], + "steps": [ + { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + ], "windows": [ "last30days", ], @@ -844,6 +855,18 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "step": { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + "type": "step", + }, { "token": "Your ", "type": "token", @@ -889,6 +912,17 @@ exports[`coach chat golden transcripts > 03 no-tools path with the full snapshot "metrics": [ "bp", ], + "steps": [ + { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + ], "windows": [ "last30days", ], @@ -4004,6 +4038,17 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy "metrics": [ "bp", ], + "steps": [ + { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + ], "windows": [ "last30days", ], @@ -4032,6 +4077,18 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy ], "contentType": "text/event-stream; charset=utf-8", "frames": [ + { + "step": { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + "type": "step", + }, { "token": "Your ", "type": "token", @@ -4077,6 +4134,17 @@ exports[`coach chat golden transcripts > 16b an empty chain served by the legacy "metrics": [ "bp", ], + "steps": [ + { + "count": 1, + "domain": "snapshot", + "id": "s1", + "label": "Reading your record summary", + "labelKey": "coach.step.snapshot", + "status": "done", + "tool": "snapshot", + }, + ], "windows": [ "last30days", ], @@ -5259,6 +5327,352 @@ exports[`coach chat golden transcripts > 21 the German locale with a stored self } `; +exports[`coach chat golden transcripts > 22 tool mode with live steps: found, empty and failed, all before the first token 1`] = ` +{ + "body": null, + "calls": [ + { + "call": "createConversation", + }, + { + "call": "appendMessage", + "content": "How is my blood pressure?", + "metricSource": null, + "model": null, + "providerType": null, + "role": "user", + "tokensUsed": null, + }, + { + "call": "storeDeterministicFacts", + }, + { + "call": "assertConsentForChain", + "chain": [ + "anthropic", + ], + }, + { + "amount": 1800, + "call": "reserveBudget", + "cap": 2000000, + "dateKey": "2026-09-26", + "owner": "user", + "surface": "coach", + }, + { + "call": "runCoachToolLoop", + "fallbackWindow": "allTime", + "maxTokens": 600, + "messages": [ + "user:0c52b5bc28a6:239", + ], + "providers": [ + "anthropic", + ], + "sharedScope": { + "sources": [ + "bp", + ], + "window": "last30days", + }, + "signal": true, + "system": "c7cb63b1723d:2954", + "temperature": 0.4, + "timeoutMs": 180000, + "tools": 2, + }, + { + "actual": 80, + "attribution": { + "reservedOwner": "user", + "servedBy": "anthropic", + }, + "cached": 0, + "call": "reconcileSpend", + "dateKey": "2026-09-26", + "reserved": 1800, + }, + { + "call": "appendMessage", + "content": "Your systolic averaged 128 mmHg.", + "metricSource": { + "groundedFigures": [ + 128, + 82, + 46, + 105, + 35.9375, + 56.09756097560976, + 42, + ], + "method": { + "entries": [ + { + "count": 142, + "domain": "bp", + "window": "last90days", + }, + { + "absent": "no_data", + "domain": "sleep", + "window": "allTime", + }, + ], + "text": "Blood pressure, last 90 days: 142 readings · Sleep, all time: no readings", + }, + "metrics": [ + "bp", + ], + "steps": [ + { + "count": 142, + "domain": "bp", + "id": "s1", + "label": "Checking: Blood pressure, last 90 days", + "labelKey": "coach.step.readWindow", + "status": "done", + "tool": "get_metric_series", + "window": "last90days", + }, + { + "domain": "sleep", + "id": "s2", + "label": "Checking: Sleep, all time", + "labelKey": "coach.step.readWindow", + "reason": "no_data", + "status": "empty", + "tool": "get_sleep", + "window": "allTime", + }, + { + "domain": "labs", + "id": "s3", + "label": "Checking: Lab results, last 12 months", + "labelKey": "coach.step.readWindow", + "reason": "retrieval_failed", + "status": "failed", + "tool": "get_labs", + "window": "lastYear", + }, + ], + "toolCalls": [ + { + "name": "get_metric_series", + "present": true, + }, + ], + "windows": [ + "last30days", + ], + }, + "model": "m-tool", + "providerType": "anthropic", + "role": "assistant", + "tokensUsed": 80, + }, + { + "call": "annotate", + "metaKeys": [ + "clusterCount", + "conversationId", + "historyTurns", + "promptChars", + "promptVersion", + "provider", + "snapshotSent", + "tokens", + "toolMode", + "toolsCalled", + ], + "name": "insights.coach.replied", + }, + ], + "contentType": "text/event-stream; charset=utf-8", + "frames": [ + { + "step": { + "domain": "bp", + "id": "s1", + "label": "Checking: Blood pressure, last 90 days", + "labelKey": "coach.step.readWindow", + "status": "running", + "tool": "get_metric_series", + "window": "last90days", + }, + "type": "step", + }, + { + "step": { + "domain": "sleep", + "id": "s2", + "label": "Checking: Sleep, all time", + "labelKey": "coach.step.readWindow", + "status": "running", + "tool": "get_sleep", + "window": "allTime", + }, + "type": "step", + }, + { + "step": { + "domain": "labs", + "id": "s3", + "label": "Checking: Lab results, last 12 months", + "labelKey": "coach.step.readWindow", + "status": "running", + "tool": "get_labs", + "window": "lastYear", + }, + "type": "step", + }, + { + "step": { + "count": 142, + "domain": "bp", + "id": "s1", + "label": "Checking: Blood pressure, last 90 days", + "labelKey": "coach.step.readWindow", + "status": "done", + "tool": "get_metric_series", + "window": "last90days", + }, + "type": "step", + }, + { + "step": { + "domain": "sleep", + "id": "s2", + "label": "Checking: Sleep, all time", + "labelKey": "coach.step.readWindow", + "reason": "no_data", + "status": "empty", + "tool": "get_sleep", + "window": "allTime", + }, + "type": "step", + }, + { + "step": { + "domain": "labs", + "id": "s3", + "label": "Checking: Lab results, last 12 months", + "labelKey": "coach.step.readWindow", + "reason": "retrieval_failed", + "status": "failed", + "tool": "get_labs", + "window": "lastYear", + }, + "type": "step", + }, + { + "token": "Your ", + "type": "token", + }, + { + "token": "systolic ", + "type": "token", + }, + { + "token": "averaged ", + "type": "token", + }, + { + "token": "128 ", + "type": "token", + }, + { + "token": "mmHg.", + "type": "token", + }, + { + "metricSource": { + "groundedFigures": [ + 128, + 82, + 46, + 105, + 35.9375, + 56.09756097560976, + 42, + ], + "method": { + "entries": [ + { + "count": 142, + "domain": "bp", + "window": "last90days", + }, + { + "absent": "no_data", + "domain": "sleep", + "window": "allTime", + }, + ], + "text": "Blood pressure, last 90 days: 142 readings · Sleep, all time: no readings", + }, + "metrics": [ + "bp", + ], + "steps": [ + { + "count": 142, + "domain": "bp", + "id": "s1", + "label": "Checking: Blood pressure, last 90 days", + "labelKey": "coach.step.readWindow", + "status": "done", + "tool": "get_metric_series", + "window": "last90days", + }, + { + "domain": "sleep", + "id": "s2", + "label": "Checking: Sleep, all time", + "labelKey": "coach.step.readWindow", + "reason": "no_data", + "status": "empty", + "tool": "get_sleep", + "window": "allTime", + }, + { + "domain": "labs", + "id": "s3", + "label": "Checking: Lab results, last 12 months", + "labelKey": "coach.step.readWindow", + "reason": "retrieval_failed", + "status": "failed", + "tool": "get_labs", + "window": "lastYear", + }, + ], + "toolCalls": [ + { + "name": "get_metric_series", + "present": true, + }, + ], + "windows": [ + "last30days", + ], + }, + "type": "provenance", + }, + { + "conversationId": "c-new", + "messageId": "m2", + "type": "done", + "usage": { + "model": "m-tool", + "totalTokens": 80, + }, + }, + ], + "status": 200, + "thrown": null, +} +`; + exports[`coach chat golden transcripts — refusals before the stream > R1 a fence-drift conversation 404s with an audit row 1`] = ` { "body": null, diff --git a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts index f6a48aa2f..aecc3083c 100644 --- a/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts +++ b/src/app/api/insights/chat/__tests__/golden-transcripts.test.ts @@ -120,8 +120,13 @@ vi.mock("@/lib/medications/scheduled-doses", () => ({ vi.mock("@/lib/ai/coach/workout-evidence-builder", () => ({ buildWorkoutEvidenceSection: h.m.buildWorkoutEvidenceSection, })); -vi.mock("@/lib/ai/coach/tools", () => ({ +vi.mock("@/lib/ai/coach/tools", async () => ({ COACH_TOOL_DEFS: [{ name: "get_metric_series" }, { name: "get_sleep" }], + parseCoachToolArgs: ( + await vi.importActual( + "@/lib/ai/coach/tools/definitions", + ) + ).parseCoachToolArgs, MAX_ROUNDS: 3, buildCoachDataInventory: h.m.buildCoachDataInventory, renderDataInventory: h.m.renderDataInventory, @@ -521,6 +526,54 @@ describe("coach chat golden transcripts", () => { reply("Du läufst 30 km pro Woche, dein Blutdruck liegt bei 128 mmHg."); await golden({ message: "Wie ist mein Blutdruck?", locale: "de" }); }); + + it("22 tool mode with live steps: found, empty and failed, all before the first token", async () => { + type Call = { id: string; name: string; arguments: string }; + type Settled = { present: boolean; reason?: string; data?: unknown }; + const calls: Array<[Call, Settled]> = [ + [ + { + id: "a", + name: "get_metric_series", + arguments: '{"metric":"bp","window":"last90days"}', + }, + { + present: true, + data: { + metric: "bp", + section: { aggregate: { coverage: { count: 142 } } }, + }, + }, + ], + [ + { id: "b", name: "get_sleep", arguments: "{}" }, + { present: false, reason: "no_data" }, + ], + [ + { id: "c", name: "get_labs", arguments: '{"analyte":"Ferritin"}' }, + { present: false, reason: "retrieval_failed" }, + ], + ]; + m.runCoachToolLoop.mockImplementation( + async (args: { + onCallStart?: (call: Call, index: number) => void; + onCallSettled?: (call: Call, result: Settled, index: number) => void; + }) => { + calls.forEach(([call], i) => args.onCallStart?.(call, i)); + calls.forEach(([call, result], i) => + args.onCallSettled?.(call, result, i), + ); + return toolLoopResult("Your systolic averaged 128 mmHg."); + }, + ); + const transcript = await golden({ message: "How is my blood pressure?" }); + const types = transcript.frames.map((f) => (f as { type: string }).type); + const firstToken = types.indexOf("token"); + expect(firstToken).toBeGreaterThan(0); + expect(types.lastIndexOf("step")).toBeLessThan(firstToken); + expect(types.filter((t) => t === "step")).toHaveLength(6); + expect(JSON.stringify(transcript)).not.toContain("Ferritin"); + }); }); describe("coach chat golden transcripts — refusals before the stream", () => { diff --git a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts index 7968706e4..9bcfdfa39 100644 --- a/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts +++ b/src/app/api/insights/chat/__tests__/route-capability-gate.test.ts @@ -66,6 +66,7 @@ vi.mock("@/lib/ai/provider", () => ({ })); vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +// The rest of the module stays real: the step labels read its enums at load. vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({ ...(await importOriginal()), coachChatRequestSchema: { diff --git a/src/app/api/insights/chat/__tests__/route-list-query.test.ts b/src/app/api/insights/chat/__tests__/route-list-query.test.ts index 0bcfa1d7d..f22d84152 100644 --- a/src/app/api/insights/chat/__tests__/route-list-query.test.ts +++ b/src/app/api/insights/chat/__tests__/route-list-query.test.ts @@ -53,6 +53,7 @@ vi.mock("@/lib/ai/provider", () => ({ })); vi.mock("@/lib/ai/consent-guard", () => ({ assertConsentForChain: vi.fn() })); vi.mock("@/lib/ai/prompts/insight-generator", () => ({ PROMPT_VERSION: "x" })); +// The rest of the module stays real: the step labels read its enums at load. vi.mock("@/lib/ai/coach/types", async (importOriginal) => ({ ...(await importOriginal()), coachChatRequestSchema: { safeParse: () => ({ success: false }) }, diff --git a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx new file mode 100644 index 000000000..67ae6e88b --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx @@ -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( + {node}, + ); +} + +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: "Checking: 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: "Checking: 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: "Checking: 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("", () => { + it("renders nothing without steps", () => { + expect(render()).not.toContain( + "coach-turn-steps", + ); + }); + + it("while active: the running step in the header, with a stable accessible name", () => { + const html = render(); + expect(html).toContain('data-slot="coach-turn-steps-active"'); + expect(html).toContain("Checking: 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(); + 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(); + expect(html).toMatch( + /<\/span>/, + ); + }); + + it("uses theme tokens only, and every motion stops under reduced motion", () => { + const html = + render() + + render( + , + ); + 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("", () => { + it("one row per step: domain · window · count or reason, with its status", () => { + const html = render( + , + ); + 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>/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(); + const stored = render( + , + ); + const text = (h: string) => h.replace(//g, ""); + expect(text(stored)).toBe(text(live)); + }); +}); diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx index e066d4245..ae7b6adc6 100644 --- a/src/components/insights/coach-panel/turn-steps.tsx +++ b/src/components/insights/coach-panel/turn-steps.tsx @@ -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; +type TranslateCount = ( + baseKey: string, + count: number, + params?: Record, +) => string; + +/** `t(key)`, or `fallback` when the bundle does not carry the key. */ +function tOr( + t: Translate, + key: string, + fallback: string, + params?: Record, +) { + 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, +): 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 | 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 ( +