+ );
+}
+
// ── Horizontal category bars ───────────────────────────────────────────────
function CategoryBarChart({
From 11bafd68b595fcb02e6795dfc3b02dff0ffd19b4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Tue, 6 Oct 2026 20:47:17 +0200
Subject: [PATCH 2/4] feat(coach): one quiet status line, reply pills and
memory notes in the thread
The top of an answer is one line that says what the Coach is doing right
now (thinking, fetching a metric, summarising the readings, writing),
live from the activity frames, with the seconds so far. It never opens
by itself: the trail stays closed while the turn runs, when it asks,
when it stops early, after the answer and after a reload, and opens
only on a tap. Once the answer is in, the line settles into one summary
of lookups and seconds. A table read during the turn shows as a
one-line preview with a sparkline until the answer takes it over.
Clarifying choices, follow-ups and the answers to a fact or plan
proposal share one pattern: quiet pills in the answer's column whose
tap sends the label as the person's own message. The clarification
card and the in-thread plan card are gone. A fact the Coach kept is one
meta line with Undo, an assumption one line with Change. The remember
button on a message now keeps it in the Coach's memory.
---
messages/_pending/c.json | 163 ++++
.../destructive-control-guard.test.ts | 29 +-
.../success-affordance-guard.test.ts | 3 -
.../__tests__/assumption-line.test.tsx | 76 ++
.../__tests__/clarification-card.test.tsx | 112 ---
.../coach-conversation-deeplink.test.tsx | 11 +-
.../__tests__/coach-send-target.test.ts | 35 +
.../__tests__/interim-results.test.tsx | 143 +++
.../__tests__/memory-note.test.tsx | 126 +++
.../__tests__/message-thread.test.tsx | 7 +
.../__tests__/plan-proposal-card.test.tsx | 105 ---
.../__tests__/reply-decisions.test.ts | 208 +++++
.../__tests__/suggested-replies.test.tsx | 103 +++
.../__tests__/turn-activity.test.tsx | 556 ++++++++++++
.../coach-panel/__tests__/turn-steps.test.tsx | 378 --------
.../__tests__/use-coach-dialog.test.ts | 7 +
.../insights/coach-panel/assumption-line.tsx | 127 +++
.../insights/coach-panel/chat-bubble.tsx | 127 ++-
.../coach-panel/clarification-card.tsx | 106 ---
.../coach-panel/coach-conversation.tsx | 82 +-
.../insights/coach-panel/coach-input.tsx | 2 +-
.../insights/coach-panel/coach-results.tsx | 35 +-
.../insights/coach-panel/composer-focus.ts | 6 +-
.../insights/coach-panel/follow-up-chips.tsx | 66 +-
.../insights/coach-panel/interim-results.tsx | 191 ++++
.../insights/coach-panel/memory-note.tsx | 96 ++
.../insights/coach-panel/message-actions.tsx | 48 +-
.../insights/coach-panel/message-thread.tsx | 221 ++++-
.../coach-panel/plan-proposal-card.tsx | 218 -----
.../coach-panel/suggested-replies.tsx | 98 ++
.../insights/coach-panel/turn-activity.tsx | 842 ++++++++++++++++++
.../insights/coach-panel/turn-steps.tsx | 498 -----------
.../insights/coach-panel/use-coach-facts.ts | 89 ++
.../insights/coach-panel/use-coach.ts | 145 ++-
src/hooks/use-coach-plans.ts | 17 +-
src/lib/query-keys/coach.ts | 14 +
.../coach-series-long-window.test.ts | 2 +-
37 files changed, 3495 insertions(+), 1597 deletions(-)
create mode 100644 messages/_pending/c.json
create mode 100644 src/components/insights/coach-panel/__tests__/assumption-line.test.tsx
delete mode 100644 src/components/insights/coach-panel/__tests__/clarification-card.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/interim-results.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/memory-note.test.tsx
delete mode 100644 src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/reply-decisions.test.ts
create mode 100644 src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx
create mode 100644 src/components/insights/coach-panel/__tests__/turn-activity.test.tsx
delete mode 100644 src/components/insights/coach-panel/__tests__/turn-steps.test.tsx
create mode 100644 src/components/insights/coach-panel/assumption-line.tsx
delete mode 100644 src/components/insights/coach-panel/clarification-card.tsx
create mode 100644 src/components/insights/coach-panel/interim-results.tsx
create mode 100644 src/components/insights/coach-panel/memory-note.tsx
delete mode 100644 src/components/insights/coach-panel/plan-proposal-card.tsx
create mode 100644 src/components/insights/coach-panel/suggested-replies.tsx
create mode 100644 src/components/insights/coach-panel/turn-activity.tsx
delete mode 100644 src/components/insights/coach-panel/turn-steps.tsx
create mode 100644 src/components/insights/coach-panel/use-coach-facts.ts
diff --git a/messages/_pending/c.json b/messages/_pending/c.json
new file mode 100644
index 000000000..7969fb78b
--- /dev/null
+++ b/messages/_pending/c.json
@@ -0,0 +1,163 @@
+{
+ "en": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "The Coach already remembers this."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "In the Coach, the thinking depth from the Coach settings applies."
+ }
+ }
+ }
+ },
+ "de": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "Das weiß der Coach schon."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
+ }
+ }
+ }
+ },
+ "es": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "El Coach ya lo recuerda."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
+ }
+ }
+ }
+ },
+ "fr": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "Le Coach s’en souvient déjà."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "Dans le Coach, c’est la profondeur de réflexion des réglages du Coach qui s’applique."
+ }
+ }
+ }
+ },
+ "it": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "Il Coach lo ricorda già."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
+ }
+ }
+ }
+ },
+ "pl": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "Coach już to pamięta."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
+ }
+ }
+ }
+ },
+ "ko": {
+ "admin": {
+ "assistant": {
+ "reasoning": {
+ "description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다."
+ }
+ }
+ },
+ "insights": {
+ "coach": {
+ "rememberMessage": {
+ "alreadyKept": "코치가 이미 기억하고 있어요."
+ }
+ }
+ },
+ "settings": {
+ "ai": {
+ "reasoning": {
+ "coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
+ }
+ }
+ }
+ }
+}
diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts
index 34f750ae7..a429ecb1a 100644
--- a/src/__tests__/destructive-control-guard.test.ts
+++ b/src/__tests__/destructive-control-guard.test.ts
@@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [
},
{
file: "components/settings/coach-memory-section.tsx",
- destroys: "one remembered Coach fact, or every one of them",
+ destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
- confirm: ["ConfirmButton", "AlertDialog"],
+ confirm: ["AlertDialog"],
+ },
+ {
+ file: "components/insights/coach-panel/use-coach-facts.ts",
+ destroys: "one remembered Coach fact",
+ triggers: [
+ "components/settings/coach-memory-section.tsx",
+ "components/insights/coach-panel/memory-note.tsx",
+ ],
+ recovery: "tombstoned-no-restore",
+ confirm: ["ConfirmButton"],
+ unconfirmed: [
+ {
+ control: "Undo on the memory note under an answer — memory-note.tsx",
+ reason:
+ "Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.",
+ },
+ ],
},
{
file: "hooks/use-coach-reminders.ts",
@@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [
{
file: "hooks/use-coach-plans.ts",
destroys: "a Coach plan and the record of how it went",
- triggers: [
- "app/coach/plans/page.tsx",
- "components/insights/coach-panel/plan-proposal-card.tsx",
- ],
+ triggers: ["app/coach/plans/page.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
- control:
- "decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
+ control: "decline a PROPOSED plan — app/coach/plans/page.tsx",
reason:
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
},
diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts
index bd9e68c89..64fb05022 100644
--- a/src/__tests__/success-affordance-guard.test.ts
+++ b/src/__tests__/success-affordance-guard.test.ts
@@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
- "src/components/insights/coach-panel/plan-proposal-card.tsx": {
- "text-success": 1,
- },
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
"text-success": 1,
},
diff --git a/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx
new file mode 100644
index 000000000..26cdb3fca
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/assumption-line.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only
+ * under the latest answer and only with alternatives.
+ */
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
+
+import { CoachAssumptionLine, alternativesFor } from "../assumption-line";
+
+const WINDOW: CoachAssumption = {
+ kind: "window",
+ value: {
+ labelKey: "coach.step.window.last30days",
+ label: "last 30 days",
+ value: { window: "last30days" },
+ },
+ alternatives: [],
+};
+const CHANGE: CoachFollowUp = {
+ id: "f1",
+ kind: "change_assumption",
+ labelKey: "coach.followUp.changeAssumption",
+ label: "Last 90 days instead",
+ reuse: false,
+ origin: "server",
+ assumption: { kind: "window", value: { window: "last90days" } },
+};
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("names the assumption in one meta line", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Assumed: last 30 days.");
+ expect(html).toMatch(
+ /data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/,
+ );
+ expect(html).not.toContain("coach-assumption-change");
+ });
+
+ it("offers Change under the latest answer, closed until tapped", () => {
+ const html = render(
+ {}}
+ />,
+ );
+ expect(html).toContain(">Change");
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).not.toContain("Last 90 days instead");
+ });
+
+ it("matches alternatives to their assumption by kind", () => {
+ expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]);
+ expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual(
+ [],
+ );
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx b/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx
deleted file mode 100644
index e7d28a2f3..000000000
--- a/src/components/insights/coach-panel/__tests__/clarification-card.test.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-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),
- }),
-}));
-
-const focusComposer = vi.fn();
-vi.mock("../composer-focus", () => ({
- focusCoachComposer: () => focusComposer(),
-}));
-
-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>[] {
- if (Array.isArray(node)) return node.flatMap(choiceButtons);
- if (!isValidElement(node)) return [];
- const el = node as ReactElement>;
- const own = el.props["data-choice-id"] ? [el] : [];
- return [...own, ...choiceButtons(el.props.children as ReactNode)];
-}
-
-describe("", () => {
- it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
- const { html } = card(METRIC);
- // 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");
- });
-
- 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");
- expect(html).toContain(">Server label");
- 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);
- 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", () => {
- 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.");
- });
-});
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 21626c678..2db2b6126 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,16 +113,15 @@ 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.
+ it("asks no question in a card above the composer", () => {
+ // v1.41 — a clarifying question is a normal Coach message with reply
+ // pills under it; there is no card and no separate live region.
const html = render(
,
makeClient(),
);
- expect(html).toMatch(
- /
]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
- );
+ expect(html).not.toContain("coach-clarification-card");
+ expect(html).not.toContain("coach-clarification-live");
});
it("auto-opens the most-recent thread when none is pinned", () => {
diff --git a/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts b/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts
index 089d22b13..3f4ff9949 100644
--- a/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts
+++ b/src/components/insights/coach-panel/__tests__/coach-send-target.test.ts
@@ -100,4 +100,39 @@ describe("resolveCoachSendTarget", () => {
expect(target.url).toBe("/api/insights/chat");
},
);
+
+ it("carries a memory or plan decision on the tool route, never on the fenced one", () => {
+ const memory = JSON.parse(
+ resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "Yes, remember it",
+ memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
+ }).body,
+ );
+ expect(memory.memoryDecision).toEqual({
+ messageId: "a1",
+ proposalId: "p1",
+ accept: true,
+ });
+ const plan = JSON.parse(
+ resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "Not now",
+ planDecision: { messageId: "a1", planId: "plan1", accept: false },
+ }).body,
+ );
+ expect(plan.planDecision).toEqual({
+ messageId: "a1",
+ planId: "plan1",
+ accept: false,
+ });
+ const fenced = resolveCoachSendTarget({
+ conversationId: "c1",
+ message: "Yes",
+ fenced: true,
+ memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
+ });
+ expect(fenced.url).toBe("/api/insights/chat/fenced");
+ expect(JSON.parse(fenced.body).memoryDecision).toBeUndefined();
+ });
});
diff --git a/src/components/insights/coach-panel/__tests__/interim-results.test.tsx b/src/components/insights/coach-panel/__tests__/interim-results.test.tsx
new file mode 100644
index 000000000..5342e4353
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/interim-results.test.tsx
@@ -0,0 +1,143 @@
+/**
+ * v1.41 — the tables a turn has read so far, one quiet line each, before the
+ * first token; taken down when the turn is blocked.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { CoachResultTable } from "@/lib/ai/coach/types";
+
+vi.mock("../coach-results", () => ({ CoachResultView: () => null }));
+
+import {
+ CoachInterimResults,
+ interimLabel,
+ interimPreviews,
+ interimReadingCount,
+ sparklinePoints,
+} from "../interim-results";
+import {
+ upsertActivity,
+ upsertResult,
+ withoutWithheldResults,
+} from "../use-coach";
+
+function bp(ref: string): CoachResultTable {
+ return {
+ ref,
+ source: {
+ tool: "get_metric_table",
+ domain: "bp",
+ window: "last30days",
+ period: "current",
+ granularity: "day",
+ },
+ shape: "timeSeries",
+ titleKey: "coach.result.title.byDay",
+ title: "Blood pressure by day",
+ rowCount: 3,
+ chartKind: "line",
+ displayed: false,
+ columns: [
+ { key: "day", kind: "period", labelKey: "k", label: "Day" },
+ { key: "systolic", kind: "number", labelKey: "k", label: "Systolic" },
+ { key: "readings", kind: "count", labelKey: "k", label: "Readings" },
+ ],
+ rows: [
+ ["2026-09-01", 128, 2],
+ ["2026-09-02", 124, 3],
+ ["2026-09-03", 131, 1],
+ ],
+ truncated: false,
+ chart: { kind: "line", x: "day", series: ["systolic"] },
+ };
+}
+
+function render(node: React.ReactNode) {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("interim previews", () => {
+ it("one line per interim table: title, window, readings, a sparkline", () => {
+ const html = render(
+ ,
+ );
+ expect(html.match(/data-slot="coach-interim-result"/g)).toHaveLength(1);
+ expect(html).toContain("Blood pressure by day, last 30 days, 6 readings");
+ expect(html).toContain('stroke="var(--chart-1)"');
+ // Closed until tapped, and the arrival fades in only with motion allowed.
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain("motion-safe:animate-in");
+ });
+
+ it("shows at most three, and nothing without interim tables", () => {
+ const four = ["r1", "r2", "r3", "r4"];
+ expect(interimPreviews(four.map(bp), four)).toHaveLength(3);
+ expect(
+ render(),
+ ).toBe("");
+ });
+
+ it("counts readings from the count column, else rows", () => {
+ expect(interimReadingCount(bp("r1"))).toBe(6);
+ const noCount = { ...bp("r1"), columns: bp("r1").columns.slice(0, 2) };
+ expect(interimReadingCount(noCount)).toBe(3);
+ // The title is the server's, already in the request locale.
+ expect(interimLabel(bp("r1"), getServerTranslator("de").t, "de")).toBe(
+ "Blood pressure by day, letzte 30 Tage, 6 Werte",
+ );
+ });
+
+ it("draws the sparkline inside its box", () => {
+ const points = sparklinePoints([1, 3, 2], 48, 16).split(" ");
+ expect(points).toHaveLength(3);
+ for (const point of points) {
+ const [x, y] = point.split(",").map(Number);
+ expect(x).toBeGreaterThanOrEqual(0);
+ expect(x).toBeLessThanOrEqual(48);
+ expect(y).toBeGreaterThanOrEqual(0);
+ expect(y).toBeLessThanOrEqual(16);
+ }
+ });
+});
+
+describe("the stream's bookkeeping", () => {
+ it("replaces an interim table with its final frame by ref", () => {
+ const final = { ...bp("r1"), displayed: true };
+ const next = upsertResult([bp("r1"), bp("r2")], final);
+ expect(next).toHaveLength(2);
+ expect(next[0].displayed).toBe(true);
+ });
+
+ it("takes the interim tables down when the turn was blocked", () => {
+ expect(
+ withoutWithheldResults([bp("r1"), bp("r2"), bp("r3")], ["r1", "r3"]).map(
+ (r) => r.ref,
+ ),
+ ).toEqual(["r2"]);
+ });
+
+ it("keeps an entry's title when a later frame leaves it out", () => {
+ const first = {
+ id: "a1",
+ phase: "thinking" as const,
+ status: "running" as const,
+ round: 1,
+ labelKey: "k",
+ label: "Thinking…",
+ title: "Comparing months",
+ };
+ const done = { ...first, status: "done" as const, durationMs: 900 };
+ delete (done as { title?: string }).title;
+ const [entry] = upsertActivity([first], done);
+ expect(entry.title).toBe("Comparing months");
+ expect(entry.status).toBe("done");
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/memory-note.test.tsx b/src/components/insights/coach-panel/__tests__/memory-note.test.tsx
new file mode 100644
index 000000000..9994310e4
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/memory-note.test.tsx
@@ -0,0 +1,126 @@
+/**
+ * v1.41 — the one quiet line under an answer that says the Coach kept
+ * something, with Undo.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { isValidElement, type ReactElement, type ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+
+const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({
+ t: (key: string, params?: Record) =>
+ getServerTranslator(locale.current).t(key, params),
+ }),
+}));
+
+import { CoachMemoryNote, CoachMemoryNoteLine } from "../memory-note";
+
+function render(node: ReactNode) {
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+function find(
+ node: ReactNode,
+ slot: string,
+): ReactElement> | null {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const hit = find(child, slot);
+ if (hit) return hit;
+ }
+ return null;
+ }
+ if (!isValidElement(node)) return null;
+ const el = node as ReactElement>;
+ if (el.props["data-slot"] === slot) return el;
+ return find(el.props.children as ReactNode, slot);
+}
+
+describe("", () => {
+ it("says what was kept as one meta line, with Undo as a text button", () => {
+ locale.current = "en";
+ const html = render(
+ {}}
+ />,
+ );
+ expect(html).toContain('data-slot="coach-memory-note"');
+ expect(html).toContain("Remembered: You want to reach 75 kg by December.");
+ expect(html).toMatch(/class="text-muted-foreground[^"]*text-xs/);
+ expect(html).toContain(">Undo");
+ expect(html).not.toMatch(/\bbg-primary\b|text-muted-foreground\/\d/);
+ });
+
+ it("reads in German", () => {
+ locale.current = "de";
+ const html = render(
+ {}}
+ />,
+ );
+ locale.current = "en";
+ expect(html).toContain("Gemerkt: Ziel 75 kg.");
+ expect(html).toContain(">Rückgängig");
+ });
+
+ it("Undo forgets the fact: one tap, one call", () => {
+ const onUndo = vi.fn();
+ const tree = CoachMemoryNoteLine({ fact: "x", pending: false, onUndo });
+ const undo = find(tree, "coach-memory-note-undo");
+ expect(undo).not.toBeNull();
+ (undo?.props.onClick as () => void)();
+ expect(onUndo).toHaveBeenCalledTimes(1);
+ });
+
+ it("locks Undo while the forget is on its way", () => {
+ const tree = CoachMemoryNoteLine({
+ fact: "x",
+ pending: true,
+ onUndo: () => {},
+ });
+ expect(find(tree, "coach-memory-note-undo")?.props.disabled).toBe(true);
+ });
+});
+
+describe("", () => {
+ it("shows a saved fact from the live frame", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Remembered: Reach 75 kg by December.");
+ });
+
+ it("shows nothing for a proposal: the reply pills carry it", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+
+ it("shows nothing for a persisted note whose fact is not in the list", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toBe("");
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
index 2f17d264f..d393a8de1 100644
--- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
+++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx
@@ -100,6 +100,13 @@ function streaming(
results: [],
followUps: [],
clarification: null,
+ activity: [],
+ interimRefs: [],
+ memoryNote: null,
+ planProposal: null,
+ stop: null,
+ startedAt: null,
+ endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx b/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx
deleted file mode 100644
index 6a254a551..000000000
--- a/src/components/insights/coach-panel/__tests__/plan-proposal-card.test.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { describe, it, expect } from "vitest";
-import { renderToStaticMarkup } from "react-dom/server";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-
-import { I18nProvider } from "@/lib/i18n/context";
-import { queryKeys } from "@/lib/query-keys";
-import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
-import {
- PlanProposalCards,
- PROPOSAL_POLL_MS,
- PROPOSAL_POLL_MAX_ATTEMPTS,
- nextProposalPollInterval,
-} from "../plan-proposal-card";
-
-/**
- * The plan-proposal confirm cards at the thread tail. SSR string render
- * against a pre-seeded query cache (the `status:proposed` slot the component
- * reads), so the tests pin the render contract without a network layer:
- * only proposals born in the OPEN conversation render, the prose reads in
- * the card, and both actions are present as real buttons.
- */
-
-function plan(overrides: Partial = {}): CoachPlanDTO {
- return {
- id: "p1",
- metric: "WEIGHT",
- ifCue: "every morning",
- thenAction: "weigh in before breakfast",
- target: "steady trend by autumn",
- status: "proposed",
- reviewDate: null,
- sourceConversationId: "conv-1",
- createdAt: "2026-07-01T00:00:00.000Z",
- updatedAt: "2026-07-01T00:00:00.000Z",
- ...overrides,
- };
-}
-
-function render(conversationId: string, plans: CoachPlanDTO[]) {
- const client = new QueryClient();
- client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
- return renderToStaticMarkup(
-
-
-
-
- ,
- );
-}
-
-describe("", () => {
- it("renders nothing when the conversation has no open proposal", () => {
- expect(render("conv-1", [])).toBe("");
- });
-
- it("renders a confirm card for a proposal born in the open conversation", () => {
- const html = render("conv-1", [plan()]);
- expect(html).toContain('data-slot="coach-plan-proposal-card"');
- // The plan prose is content — visible in the card.
- expect(html).toContain("every morning");
- expect(html).toContain("weigh in before breakfast");
- expect(html).toContain("steady trend by autumn");
- expect(html).toContain("WEIGHT");
- // Both actions render as real buttons.
- expect(html).toContain('data-slot="coach-plan-proposal-accept"');
- expect(html).toContain('data-slot="coach-plan-proposal-decline"');
- });
-
- it("hides proposals born in a different conversation", () => {
- const html = render("conv-2", [plan()]);
- expect(html).toBe("");
- });
-
- it("skips a proposal whose prose is undecryptable (null fields)", () => {
- const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
- expect(html).toBe("");
- });
-
- it("renders one card per open proposal", () => {
- const html = render("conv-1", [
- plan(),
- plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
- ]);
- expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
- expect(html).toContain("after dinner");
- });
-});
-
-describe("nextProposalPollInterval", () => {
- it("polls at the slow interval below the attempt ceiling", () => {
- expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
- expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
- PROPOSAL_POLL_MS,
- );
- });
-
- it("stops polling once the attempt ceiling is reached", () => {
- // Watched red: asserting an interval at the cap fails — the ceiling is
- // live, not decorative.
- expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
- expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
- false,
- );
- });
-});
diff --git a/src/components/insights/coach-panel/__tests__/reply-decisions.test.ts b/src/components/insights/coach-panel/__tests__/reply-decisions.test.ts
new file mode 100644
index 000000000..01550531e
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/reply-decisions.test.ts
@@ -0,0 +1,208 @@
+/**
+ * v1.41 — what the latest answer waits for (a clarifying question, a fact
+ * proposal, a plan proposal) becomes reply pills, and a tap answers it.
+ */
+import { describe, expect, it, vi } from "vitest";
+
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import type { CoachMessageDTO, CoachProvenance } from "@/lib/ai/coach/types";
+
+vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: null }) }));
+
+import {
+ decisionReplies,
+ latestDecision,
+ type CoachReplyIntent,
+} from "../message-thread";
+import type { CoachStreamingMessage } from "../use-coach";
+
+const { t } = getServerTranslator("en");
+
+function assistant(id: string, metricSource: Partial) {
+ return {
+ id,
+ role: "assistant",
+ content: "…",
+ createdAt: "2026-10-06T10:00:00.000Z",
+ metricSource: { windows: [], metrics: [], ...metricSource },
+ providerType: "mock",
+ promptVersion: null,
+ tokensUsed: null,
+ model: null,
+ } as unknown as CoachMessageDTO;
+}
+const user = (id: string) =>
+ ({
+ id,
+ role: "user",
+ content: "Yes",
+ createdAt: "2026-10-06T10:01:00.000Z",
+ metricSource: null,
+ }) as unknown as CoachMessageDTO;
+
+const CLARIFY = {
+ kind: "metric" as const,
+ freeText: true,
+ assumption: "c2",
+ choices: [
+ {
+ id: "c1",
+ labelKey: "insights.coach.metric.pulse",
+ label: "Pulse",
+ value: { metric: "pulse" as const },
+ },
+ {
+ id: "c2",
+ labelKey: "insights.coach.metric.resting_hr",
+ label: "Resting HR",
+ value: { metric: "resting_hr" as const },
+ },
+ ],
+};
+
+function collect() {
+ const sent: CoachReplyIntent[] = [];
+ return { sent, onReply: (intent: CoachReplyIntent) => sent.push(intent) };
+}
+
+describe("latestDecision", () => {
+ it("is the latest answer's open question or proposal", () => {
+ const msgs = [
+ assistant("a1", {
+ memoryNote: {
+ proposal: true,
+ proposalId: "p1",
+ category: "medication",
+ },
+ }),
+ ];
+ expect(latestDecision(msgs, undefined)).toEqual({
+ messageId: "a1",
+ clarification: null,
+ memoryProposalId: "p1",
+ planId: null,
+ });
+ });
+
+ it("is gone once the person has answered", () => {
+ const msgs = [assistant("a1", { clarification: CLARIFY }), user("u2")];
+ expect(latestDecision(msgs, undefined)).toBeNull();
+ });
+
+ it("offers nothing while a turn runs, and nothing for a saved note", () => {
+ const msgs = [assistant("a1", { clarification: CLARIFY })];
+ expect(
+ latestDecision(msgs, { inProgress: true } as CoachStreamingMessage),
+ ).toBeNull();
+ expect(
+ latestDecision(
+ [
+ assistant("a1", {
+ memoryNote: { proposal: false, factId: "f1", category: "goal" },
+ }),
+ ],
+ undefined,
+ ),
+ ).toBeNull();
+ });
+
+ it("reads the just-streamed turn before its persisted copy lands", () => {
+ const streaming = {
+ inProgress: false,
+ messageId: "a9",
+ clarification: null,
+ memoryNote: null,
+ planProposal: {
+ planId: "plan1",
+ metric: "WEIGHT",
+ reviewInDays: 14,
+ ifCue: "after dinner",
+ thenAction: "walk",
+ },
+ } as unknown as CoachStreamingMessage;
+ expect(latestDecision([], streaming)?.planId).toBe("plan1");
+ });
+});
+
+describe("decisionReplies", () => {
+ it("puts the assumed choice first and sends it as a clarification", () => {
+ const { sent, onReply } = collect();
+ const replies = decisionReplies(
+ {
+ messageId: "a1",
+ clarification: CLARIFY,
+ memoryProposalId: null,
+ planId: null,
+ },
+ t,
+ onReply,
+ );
+ expect(replies.map((r) => r.label)).toEqual(["Resting HR", "Pulse"]);
+ replies[0].onSelect();
+ expect(sent[0]).toMatchObject({
+ kind: "clarification",
+ messageId: "a1",
+ choice: { id: "c2" },
+ label: "Resting HR",
+ });
+ });
+
+ it("confirms a fact proposal with Yes and declines it with No", () => {
+ const { sent, onReply } = collect();
+ const replies = decisionReplies(
+ {
+ messageId: "a1",
+ clarification: null,
+ memoryProposalId: "p1",
+ planId: null,
+ },
+ t,
+ onReply,
+ );
+ expect(replies.map((r) => r.label)).toEqual(["Yes, remember it", "No"]);
+ replies[0].onSelect();
+ replies[1].onSelect();
+ expect(sent).toEqual([
+ {
+ kind: "memory",
+ messageId: "a1",
+ proposalId: "p1",
+ accept: true,
+ label: "Yes, remember it",
+ },
+ {
+ kind: "memory",
+ messageId: "a1",
+ proposalId: "p1",
+ accept: false,
+ label: "No",
+ },
+ ]);
+ });
+
+ it("takes on or postpones a plan", () => {
+ const { sent, onReply } = collect();
+ const replies = decisionReplies(
+ {
+ messageId: "a1",
+ clarification: null,
+ memoryProposalId: null,
+ planId: "plan1",
+ },
+ getServerTranslator("de").t,
+ onReply,
+ );
+ expect(replies.map((r) => r.label)).toEqual([
+ "Plan übernehmen",
+ "Nicht jetzt",
+ ]);
+ replies[1].onSelect();
+ expect(sent[0]).toEqual({
+ kind: "plan",
+ messageId: "a1",
+ planId: "plan1",
+ accept: false,
+ label: "Nicht jetzt",
+ });
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx
new file mode 100644
index 000000000..de20a63ed
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/suggested-replies.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * v1.41 — the one quiet reply pattern: pills under the latest answer that
+ * send their label as the person's own message.
+ */
+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";
+
+vi.mock("@/lib/i18n/context", () => ({
+ useTranslations: () => ({
+ t: (key: string, params?: Record) =>
+ getServerTranslator("en").t(key, params),
+ }),
+}));
+
+const focusComposer = vi.fn();
+vi.mock("../composer-focus", () => ({
+ focusCoachComposer: () => focusComposer(),
+}));
+
+import { SuggestedReplies, type SuggestedReply } from "../suggested-replies";
+
+function pills(node: ReactNode): ReactElement>[] {
+ if (Array.isArray(node)) return node.flatMap(pills);
+ if (!isValidElement(node)) return [];
+ const el = node as ReactElement>;
+ const own = el.props["data-reply-id"] ? [el] : [];
+ return [...own, ...pills(el.props.children as ReactNode)];
+}
+
+function replies(onSelect: (id: string) => void): SuggestedReply[] {
+ return [
+ "Resting heart rate",
+ "Walking heart rate",
+ "Both",
+ "Neither",
+ "Fifth",
+ ].map((label, i) => ({
+ id: `c${i + 1}`,
+ label,
+ kind: "clarification",
+ data: { "data-choice-id": `c${i + 1}` },
+ onSelect: () => onSelect(`c${i + 1}`),
+ }));
+}
+
+describe("", () => {
+ it("renders at most four quiet outline pills in a labelled group", () => {
+ const tree = SuggestedReplies({
+ replies: replies(() => {}),
+ messageId: "m1",
+ disabled: false,
+ });
+ const html = tree ? renderToStaticMarkup(tree) : "";
+ expect(html).toContain('role="group"');
+ expect(html).toContain('aria-label="Suggested replies"');
+ expect(html).toContain('data-slot="coach-suggested-replies"');
+ expect(html).toContain('data-message-id="m1"');
+ expect(html).not.toContain("Fifth");
+ const rendered = pills(tree);
+ expect(rendered).toHaveLength(4);
+ for (const pill of rendered) {
+ expect(pill.props.variant).toBe("outline");
+ const cls = String(pill.props.className);
+ // The answer's text size, low contrast in the foreground, a pill.
+ expect(cls).toContain("text-sm");
+ expect(cls).toContain("font-normal");
+ expect(cls).toContain("text-foreground/80");
+ expect(cls).toContain("rounded-full");
+ expect(cls).toContain("min-h-11");
+ // Never an accent, never muted-with-alpha.
+ expect(cls).not.toMatch(/\bprimary\b|text-muted-foreground\/\d/);
+ }
+ });
+
+ it("sends the tapped reply and hands focus to the composer", () => {
+ focusComposer.mockClear();
+ const onSelect = vi.fn();
+ const tree = SuggestedReplies({
+ replies: replies(onSelect),
+ messageId: "m1",
+ disabled: false,
+ });
+ (pills(tree)[1].props.onClick as () => void)();
+ expect(onSelect).toHaveBeenCalledWith("c2");
+ expect(focusComposer).toHaveBeenCalledTimes(1);
+ });
+
+ it("is hidden while a turn runs, and when there is nothing to offer", () => {
+ expect(
+ SuggestedReplies({
+ replies: replies(() => {}),
+ messageId: "m1",
+ disabled: true,
+ }),
+ ).toBeNull();
+ expect(
+ SuggestedReplies({ replies: [], messageId: "m1", disabled: false }),
+ ).toBeNull();
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx
new file mode 100644
index 000000000..9a56084c8
--- /dev/null
+++ b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx
@@ -0,0 +1,556 @@
+import { describe, expect, it } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+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 { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
+
+import {
+ CoachTurnActivity,
+ CoachTurnStepList,
+ activityLineLabel,
+ activitySeconds,
+ activitySummary,
+ countLookups,
+ countSources,
+ currentActivity,
+ currentStep,
+ describeStep,
+ legacyAreaLabels,
+ stepLabel,
+} from "../turn-activity";
+
+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("Prüfe: 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,
+ );
+ });
+});
+
+const THINKING_RUNNING: CoachActivity = {
+ id: "a1",
+ phase: "thinking",
+ status: "running",
+ round: 1,
+ labelKey: "insights.coach.activity.thinking",
+ label: "Thinking…",
+};
+const FETCH_BP: CoachActivity = {
+ id: "a2",
+ phase: "fetch",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.fetching",
+ label: "Fetching blood pressure, last 90 days…",
+ stepRef: "s1",
+ durationMs: 900,
+};
+const FETCH_SLEEP: CoachActivity = {
+ id: "a3",
+ phase: "fetch",
+ status: "empty",
+ round: 1,
+ labelKey: "insights.coach.activity.fetching",
+ label: "Fetching sleep, last 30 days…",
+ stepRef: "s2",
+ durationMs: 1_400,
+};
+const DIGEST_RUNNING: CoachActivity = {
+ id: "a4",
+ phase: "digest",
+ status: "running",
+ round: 1,
+ labelKey: "insights.coach.activity.digestOther",
+ label: "Summarising 214 readings…",
+ count: 214,
+};
+const THINKING_DONE: CoachActivity = {
+ ...THINKING_RUNNING,
+ status: "done",
+ durationMs: 4_000,
+ title: "Comparing the two months",
+ text: "The second half of the month runs higher.",
+};
+const DIGEST_DONE: CoachActivity = {
+ ...DIGEST_RUNNING,
+ status: "done",
+ label: "214 readings from 2 areas",
+ durationMs: 600,
+};
+const ANSWER_DONE: CoachActivity = {
+ id: "a5",
+ phase: "answer",
+ status: "done",
+ round: 2,
+ labelKey: "insights.coach.activity.answer",
+ label: "Writing the answer…",
+ durationMs: 6_000,
+};
+const ASKING: CoachActivity = {
+ id: "a6",
+ phase: "asking",
+ status: "done",
+ round: 1,
+ labelKey: "insights.coach.activity.asking",
+ label: "Asking you…",
+ durationMs: 1_000,
+};
+const STOPPED: CoachActivity = {
+ id: "a7",
+ phase: "stop",
+ status: "done",
+ round: 6,
+ labelKey: "insights.coach.activity.stop.time",
+ label: "Time limit reached, answering with what it has",
+ stop: "time",
+};
+const SETTLED = [
+ THINKING_DONE,
+ FETCH_BP,
+ FETCH_SLEEP,
+ DIGEST_DONE,
+ ANSWER_DONE,
+];
+
+describe("the running line", () => {
+ it("names the latest running entry, else the latest one", () => {
+ expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
+ "a4",
+ );
+ expect(currentActivity([THINKING_DONE, FETCH_BP])?.id).toBe("a2");
+ expect(currentActivity([])).toBeNull();
+ });
+
+ it("shows a reasoning title once one arrives, the catalog label before", () => {
+ expect(activityLineLabel(THINKING_RUNNING)).toBe("Thinking…");
+ expect(
+ activityLineLabel({ ...THINKING_RUNNING, title: "Checking the trend" }),
+ ).toBe("Checking the trend");
+ // Model text never replaces a fetch's catalog label, and a finished
+ // entry no longer trails off.
+ expect(activityLineLabel({ ...FETCH_BP, title: "x" })).toBe(
+ "Fetching blood pressure, last 90 days",
+ );
+ expect(
+ activityLineLabel({ ...FETCH_BP, status: "running", title: "x" }),
+ ).toBe("Fetching blood pressure, last 90 days…");
+ });
+
+ it("walks thinking → fetching → summarising, one quiet line", () => {
+ const frames = [
+ [THINKING_RUNNING],
+ [
+ { ...THINKING_RUNNING, status: "done" as const },
+ { ...FETCH_BP, status: "running" as const },
+ ],
+ [THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
+ ];
+ const lines = frames.map((activity) =>
+ render(
+ ,
+ ),
+ );
+ expect(lines[0]).toContain(">Thinking…<");
+ expect(lines[1]).toContain(">Fetching blood pressure, last 90 days…<");
+ expect(lines[2]).toContain(">Summarising 214 readings…<");
+ for (const html of lines) {
+ expect(html.match(/data-slot="coach-turn-steps-toggle"/g)).toHaveLength(
+ 1,
+ );
+ expect(html).toContain('data-state="running"');
+ expect(html).toContain('aria-label="Show what the Coach looked at"');
+ // The seconds so far sit on the right of the line.
+ expect(html).toMatch(/data-slot="coach-turn-steps-seconds"[^>]*>1[23]s);
+ }
+ });
+
+ it("says Thinking… before the first frame arrives", () => {
+ const html = render();
+ expect(html).toContain(">Thinking…<");
+ expect(html).toContain('aria-expanded="false"');
+ });
+
+ it("falls back to the step label on a server that sends only steps", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Checking: Lab results, last 12 months");
+ });
+});
+
+describe("nothing opens by itself", () => {
+ const states: Array<[string, React.ReactNode]> = [
+ [
+ "before any frame",
+ ,
+ ],
+ [
+ "running",
+ ,
+ ],
+ [
+ "asking",
+ ,
+ ],
+ [
+ "stopped",
+ ,
+ ],
+ [
+ "settled",
+ }
+ />,
+ ],
+ [
+ "reloaded (metadata only)",
+ meta),
+ ),
+ )}
+ steps={[BP, SLEEP]}
+ active={false}
+ conversationId="c1"
+ messageId="m1"
+ />,
+ ],
+ [
+ "legacy steps",
+ ,
+ ],
+ ];
+ for (const [name, node] of states) {
+ it(`is closed when ${name}`, () => {
+ const html = render(node);
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).not.toContain('aria-expanded="true"');
+ expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
+ expect(html).not.toContain('data-slot="coach-turn-step-detail"');
+ expect(html).not.toContain("The second half of the month runs higher.");
+ });
+ }
+});
+
+describe("after the answer", () => {
+ const { t } = translators("en");
+
+ it("collapses to one calm summary of lookups and seconds", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-state="done"');
+ expect(html).toContain(
+ 'data-slot="coach-turn-steps-done" class="min-w-0 truncate">Thought it through, 2 lookups, 12 s<',
+ );
+ // No spinner, no running seconds, no stable-name override once settled.
+ expect(html).not.toContain("coach-turn-steps-spinner");
+ expect(html).not.toContain("coach-turn-steps-seconds");
+ expect(html).not.toContain('aria-label="Show what the Coach looked at"');
+ });
+
+ it("counts fetches as lookups and runs a round's fetches side by side", () => {
+ expect(countLookups(SETTLED)).toBe(2);
+ // 4 s thinking + max(0.9, 1.4) s fetching + 0.6 s digest + 6 s answer.
+ expect(activitySeconds(SETTLED)).toBe(12);
+ expect(activitySeconds([THINKING_RUNNING])).toBeNull();
+ });
+
+ it("uses the live turn's clock when the trail has no durations", () => {
+ expect(
+ activitySummary({
+ activity: [{ ...ANSWER_DONE, durationMs: undefined }],
+ startedAt: 1_000,
+ endedAt: 19_000,
+ t,
+ locale: "en",
+ }),
+ ).toBe("Thought it through, 18 s");
+ });
+
+ it("reads in German", () => {
+ const html = render(
+ ,
+ "de",
+ );
+ expect(html).toContain("Nachgedacht, 2 Abfragen, 12 s");
+ });
+
+ it("an older message still folds into its sources", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain("Looked at 2 sources");
+ expect(
+ countSources([BP, { ...BP, id: "s9", period: "previous" }, SLEEP]),
+ ).toBe(2);
+ });
+
+ it("an answer saved before steps folds into its areas", () => {
+ expect(
+ render(
+ ,
+ ),
+ ).toContain("Looked at 2 areas");
+ expect(
+ render(
+ ,
+ "de",
+ ),
+ ).toContain("1 Bereich angesehen");
+ });
+
+ it("renders nothing for a settled message with nothing to show", () => {
+ expect(
+ render(),
+ ).not.toContain("coach-turn-steps");
+ });
+
+ it("carries a polite status region that starts empty", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toMatch(
+ /<\/span>/,
+ );
+ });
+});
+
+describe("currentStep", () => {
+ 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();
+ });
+});
+
+describe("tokens and motion", () => {
+ 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}`);
+ }
+ }
+ expect(html).toContain("animate-spin motion-reduce:animate-none");
+ // A 44 px tap target on phones.
+ expect(html).toContain("min-h-11");
+ });
+});
+
+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"');
+ let text = html.replace(
+ /, <\/span>/g,
+ "",
+ );
+ for (let prev = ""; prev !== text;) {
+ prev = text;
+ text = text.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");
+ });
+});
+
+describe("legacyAreaLabels", () => {
+ const { t } = translators("en");
+
+ it("names the known areas and drops unknown tokens and general", () => {
+ expect(
+ legacyAreaLabels(
+ ["bp", "bloodPressure" as never, "general", "sleep", "bp"],
+ t,
+ ),
+ ).toEqual(["Blood pressure", "Sleep"]);
+ });
+});
diff --git a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx b/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx
deleted file mode 100644
index 0bda1cd08..000000000
--- a/src/components/insights/coach-panel/__tests__/turn-steps.test.tsx
+++ /dev/null
@@ -1,378 +0,0 @@
-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,
- CoachTurnStepsPanel,
- countSources,
- legacyAreaLabels,
- 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("Prüfe: 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("counts distinct sources: a second read of the same domain is not a new source", () => {
- const bpPrevious: CoachStep = {
- ...BP,
- id: "s4",
- period: "previous",
- count: 120,
- };
- const snapshot: CoachStep = {
- id: "s5",
- tool: "snapshot",
- labelKey: "coach.step.snapshot",
- label: "Overview",
- status: "done",
- };
- expect(countSources([BP, bpPrevious, SLEEP])).toBe(2);
- expect(countSources([BP, snapshot, snapshot])).toBe(2);
- const html = render(
- ,
- );
- expect(html).toContain("Looked at 2 sources");
- });
-
- 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"');
- let text = html.replace(
- /, <\/span>/g,
- "",
- );
- // Strip tags until nothing changes, so no fragment can survive one pass.
- for (let prev = ""; prev !== text;) {
- prev = text;
- text = text.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(/
}
- />,
- );
- const steps = html.indexOf('data-slot="coach-turn-steps-list"');
- const method = html.indexOf('data-slot="coach-method-line"');
- const dataUsed = html.indexOf('data-slot="coach-data-used"');
- expect(steps).toBeGreaterThan(-1);
- expect(method).toBeGreaterThan(steps);
- expect(dataUsed).toBeGreaterThan(method);
- expect(html).toContain(METHOD.text);
- });
-
- it("holds the method back while the turn runs", () => {
- const html = render(
- }
- />,
- );
- expect(html).not.toContain("coach-method-line");
- expect(html).not.toContain("coach-data-used");
- });
-
- it("lists an older message's areas, without counts", () => {
- const html = render(
- ,
- );
- expect(html).toContain('data-slot="coach-turn-areas"');
- expect((html.match(/data-slot="coach-turn-area"/g) ?? []).length).toBe(2);
- expect(html).not.toMatch(/\d+ readings/);
- });
-
- it("shows a header for an answer that has only a method", () => {
- const html = render(
- ,
- );
- expect(html).toContain("What the Coach looked at");
- });
-});
-
-describe("legacyAreaLabels", () => {
- const { t } = translators("en");
-
- it("names the known areas and drops unknown tokens and general", () => {
- expect(
- legacyAreaLabels(
- ["bp", "bloodPressure" as never, "general", "sleep", "bp"],
- t,
- ),
- ).toEqual(["Blood pressure", "Sleep"]);
- });
-
- it("folds an older message into a count of areas", () => {
- const html = render(
- ,
- );
- expect(html).toContain("Looked at 2 areas");
- expect(
- render(, "de"),
- ).toContain("1 Bereich angesehen");
- });
-});
-
-describe("a step row copied out of the list", () => {
- it("reads 'domain · window' without the screen reader's comma", () => {
- const html = render(
- ,
- "de",
- );
- // What a selection copies: every text node except the unselectable ones.
- // Reads this test's own static markup; not a sanitiser for input.
- const copied = html
- .split(/]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/)
- .join("")
- .split(/<[^>]*>/)
- .join("")
- .replace(/\s+/g, " ")
- .trim();
- expect(copied).toBe("Gewicht · letzte 90 Tage");
- // The comma is still there for a screen reader.
- expect(html).toMatch(/class="sr-only select-none">, <\/span>/);
- });
-});
diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
index add56be66..bbb4bcaf6 100644
--- a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
+++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts
@@ -55,6 +55,13 @@ function streaming(
results: [],
followUps: [],
clarification: null,
+ activity: [],
+ interimRefs: [],
+ memoryNote: null,
+ planProposal: null,
+ stop: null,
+ startedAt: null,
+ endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
diff --git a/src/components/insights/coach-panel/assumption-line.tsx b/src/components/insights/coach-panel/assumption-line.tsx
new file mode 100644
index 000000000..89284f10b
--- /dev/null
+++ b/src/components/insights/coach-panel/assumption-line.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+/**
+ * v1.41 — what an answer assumed instead of asking, in one quiet line under
+ * it: "Assumed: last 30 days. Change".
+ *
+ * Meta text, the value in the foreground. "Change" is a text button of the
+ * same size, offered only under the latest answer and only when the server
+ * sent alternatives for that assumption (`change_assumption` follow-ups).
+ * A tap shows them as reply pills right below; a pill sends its label as
+ * the person's message and the server answers from the alternative, without
+ * asking again. Nothing opens by itself.
+ */
+import { useId, useState } from "react";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import { COACH_ASSUMPTION_KEYS } from "@/lib/ai/coach/dialog-keys";
+import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
+
+import { SuggestedReplies } from "./suggested-replies";
+
+/** The alternatives the server offers for one assumption. */
+export function alternativesFor(
+ assumption: CoachAssumption,
+ followUps: readonly CoachFollowUp[],
+): CoachFollowUp[] {
+ return followUps.filter(
+ (chip) =>
+ chip.kind === "change_assumption" &&
+ chip.assumption?.kind === assumption.kind,
+ );
+}
+
+/** The catalog value in the reader's language, else the server's. */
+function optionLabel(
+ t: (key: string) => string,
+ option: CoachAssumption["value"],
+): string {
+ const local = t(option.labelKey);
+ return local === option.labelKey ? option.label : local;
+}
+
+export interface CoachAssumptionLineProps {
+ assumptions: CoachAssumption[];
+ /** The latest answer's `change_assumption` chips; empty elsewhere. */
+ changes: CoachFollowUp[];
+ messageId: string | null;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ onChange?: (followUp: CoachFollowUp, messageId: string) => void;
+}
+
+export function CoachAssumptionLine({
+ assumptions,
+ changes,
+ messageId,
+ disabled,
+ onChange,
+}: CoachAssumptionLineProps) {
+ const { t } = useTranslations();
+ const [openKind, setOpenKind] = useState(null);
+ const baseId = useId();
+ if (assumptions.length === 0) return null;
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx
index f54000029..e35376530 100644
--- a/src/components/insights/coach-panel/chat-bubble.tsx
+++ b/src/components/insights/coach-panel/chat-bubble.tsx
@@ -19,12 +19,22 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
-import { CoachTurnSteps } from "./turn-steps";
-import { CoachResults, countResultsInSection } from "./coach-results";
+import { CoachTurnActivity } from "./turn-activity";
+import { CoachInterimResults } from "./interim-results";
+import {
+ CoachResults,
+ countResultsInSection,
+ liveWithProvenance,
+} from "./coach-results";
import { CoachFollowUpChips } from "./follow-up-chips";
+import { SuggestedReplies, type SuggestedReply } from "./suggested-replies";
+import { CoachMemoryNote } from "./memory-note";
+import { CoachAssumptionLine } from "./assumption-line";
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
import type {
+ CoachActivity,
CoachFollowUp,
+ CoachMemoryNote as CoachMemoryNoteFrame,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
@@ -316,6 +326,30 @@ interface ChatBubbleProps {
/** True while another turn is in flight; the chips step aside. */
followUpsDisabled?: boolean;
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
+ /**
+ * v1.41 — the reply pills the latest answer offers instead of its chips:
+ * a clarifying question's choices, or the two answers to a fact or plan
+ * proposal. Built by the thread; one array per offer.
+ */
+ replies?: ReplyOffer | null;
+ /**
+ * v1.41 — the live trail from the streaming hook. Persisted messages carry
+ * its metadata on `metricSource.activity`; the bubble falls back to that.
+ */
+ activity?: CoachActivity[];
+ /** v1.41 — the refs of the tables that arrived while the turn ran. */
+ interimRefs?: string[];
+ /** v1.41 — when the live turn started and ended on this device. */
+ startedAt?: number | null;
+ endedAt?: number | null;
+ /** v1.41 — the live `memoryNote` frame, with the fact's words. */
+ memoryNote?: CoachMemoryNoteFrame | null;
+}
+
+/** v1.41 — the reply pills one assistant message offers. */
+export interface ReplyOffer {
+ messageId: string;
+ replies: SuggestedReply[];
}
/** The chips one assistant message offers. */
@@ -380,6 +414,12 @@ export function areChatBubblePropsEqual(
sameFollowUpOffer(prev.followUps, next.followUps) &&
prev.followUpsDisabled === next.followUpsDisabled &&
prev.onFollowUp === next.onFollowUp &&
+ prev.replies === next.replies &&
+ prev.activity === next.activity &&
+ prev.interimRefs === next.interimRefs &&
+ prev.startedAt === next.startedAt &&
+ prev.endedAt === next.endedAt &&
+ prev.memoryNote === next.memoryNote &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
);
@@ -407,6 +447,12 @@ function ChatBubbleImpl({
followUps,
followUpsDisabled,
onFollowUp,
+ replies,
+ activity,
+ interimRefs,
+ startedAt,
+ endedAt,
+ memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -543,7 +589,9 @@ function ChatBubbleImpl({
// grounded token is present (provider-agnostic — reads the inline token).
// v1.39.4 — the result tables this answer references and the ones it
// only used; the metadata is enough to place and count them.
- const resultMetas = results?.length ? results : (metricSource?.results ?? []);
+ const resultMetas = results?.length
+ ? liveWithProvenance(results, metricSource?.results ?? [])
+ : (metricSource?.results ?? []);
const shownDomains = new Set(
resultMetas
.filter((meta) => meta.displayed)
@@ -558,6 +606,7 @@ function ChatBubbleImpl({
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
: [];
const settled = !inProgress && !errorCode && providerType !== "refusal";
+ const memoryMeta = memoryNote ?? metricSource?.memoryNote ?? null;
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
@@ -596,15 +645,22 @@ function ChatBubbleImpl({
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
- {/* What the Coach read on this turn, live from the step frames and
- restored from the provenance on reload; an older message lists
- the areas it drew on. Opened, it also says how the answer was
- worked out and holds the tables the answer read. */}
- 0 ? (
- {/* v1.19.1 (C3) — the live turn shows the classic typing animation
- (three pulsing dots) while it is still thinking with no prose
- yet, restoring the writing/typing indicator the maintainer
- prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
- The dots render inside the prose bubble below; the disclosure is
- retired. Persisted bubbles never stream, so history stays settled. */}
- {/* The prose bubble. While the live turn is still thinking with no
- prose the bubble carries the typing animation; once tokens land
- it swaps to the streamed prose without a layout jump. */}
- {(content || safeError || inProgress) && (
+ {/* v1.41 — the tables read so far, one line each, while the turn
+ still runs. They give way to the answer's own charts. */}
+ {inProgress && results && interimRefs && interimRefs.length > 0 ? (
+
+ ) : null}
+ {/* The prose bubble, once there is prose or an error. While the turn
+ runs without prose the status line above says what is happening;
+ the bubble arrives with the first token. */}
+ {(content || safeError) && (
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
@@ -644,8 +700,6 @@ function ChatBubbleImpl({
keeps the bubble and the error stays a caption. */}
{content ? (
- ) : inProgress ? (
-
) : safeError ? (
{safeError}
) : (
@@ -705,6 +759,23 @@ function ChatBubbleImpl({
section="displayed"
/>
)}
+ {/* v1.41 — what the answer assumed instead of asking, and what the
+ Coach kept from this turn: one quiet line each. */}
+ {settled && (metricSource?.assumptions?.length ?? 0) > 0 ? (
+
+ ) : null}
+ {settled && memoryMeta ? (
+
+ ) : null}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -753,10 +824,20 @@ function ChatBubbleImpl({
model={usage?.model ?? model}
/>
)}
- {/* The follow-up chips of the latest answer, in its own column. */}
- {followUps && onFollowUp ? (
+ {/* The replies the latest answer offers, in its own column: a
+ question's choices or a proposal's two answers when it has them,
+ else its follow-up chips. One pattern for all of them. */}
+ {replies && replies.replies.length > 0 ? (
+
+ ) : followUps && onFollowUp ? (
chip.kind !== "change_assumption",
+ )}
messageId={followUps.messageId}
disabled={!!followUpsDisabled}
onSelect={onFollowUp}
diff --git a/src/components/insights/coach-panel/clarification-card.tsx b/src/components/insights/coach-panel/clarification-card.tsx
deleted file mode 100644
index 2bc5a374c..000000000
--- a/src/components/insights/coach-panel/clarification-card.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client";
-
-/**
- * v1.39.4 — the choices for a clarifying question, above the composer.
- * 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 }`.
- *
- * 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";
-
-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,
-} from "@/lib/ai/coach/types";
-
-import { focusCoachComposer } from "./composer-focus";
-
-export interface CoachClarificationCardProps {
- clarification: CoachClarification;
- /** The assistant message that asked. */
- messageId: string;
- /** True while a turn is in flight. */
- disabled: boolean;
- onChoose: (choice: CoachClarificationChoice) => void;
-}
-
-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 (
-
-
-
-
- {t(COACH_CLARIFY_UI_KEYS.cardLabel)}
-
-
- {choices.length > 0 ? (
-
- {choices.map((choice) => (
-
- ))}
-
- ) : null}
- {hint ? (
-
{hint}
- ) : null}
-
- );
-}
diff --git a/src/components/insights/coach-panel/coach-conversation.tsx b/src/components/insights/coach-panel/coach-conversation.tsx
index 131f88d11..a81c1e6ff 100644
--- a/src/components/insights/coach-panel/coach-conversation.tsx
+++ b/src/components/insights/coach-panel/coach-conversation.tsx
@@ -14,14 +14,9 @@ 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 {
- CoachClarificationChoice,
- CoachFollowUp,
- CoachScope,
-} from "@/lib/ai/coach/types";
+import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -51,9 +46,9 @@ import { HistoryRail } from "./history-rail";
import {
MessageThread,
latestClarification,
+ type CoachReplyIntent,
type InterleavedThreadItem,
} from "./message-thread";
-import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
@@ -662,18 +657,50 @@ export function CoachConversation({
});
}
- // v1.39.4 — a choice on the clarification card.
- function handleClarificationChoice(choice: CoachClarificationChoice) {
- if (send.isStreaming || !openClarification) return;
- if (currentConversationId === null) return;
- void send.send({
+ // v1.41 — a reply pill under the latest answer: a clarifying question's
+ // choice, or an answer to a fact or plan proposal. Its label goes out as
+ // the person's own message; the request says which offer it answers, and
+ // the server resolves the rest from what it stored on that message.
+ function handleReply(intent: CoachReplyIntent) {
+ if (send.isStreaming || fenced || currentConversationId === null) return;
+ const sent = send.send({
conversationId: currentConversationId,
- message: choice.label,
- clarification: {
- messageId: openClarification.messageId,
- choiceId: choice.id,
- },
+ message: intent.label,
+ ...(intent.kind === "clarification"
+ ? {
+ clarification: {
+ messageId: intent.messageId,
+ choiceId: intent.choice.id,
+ },
+ }
+ : intent.kind === "memory"
+ ? {
+ memoryDecision: {
+ messageId: intent.messageId,
+ proposalId: intent.proposalId,
+ accept: intent.accept,
+ },
+ }
+ : {
+ planDecision: {
+ messageId: intent.messageId,
+ planId: intent.planId,
+ accept: intent.accept,
+ },
+ }),
});
+ // A decision changes what the Coach remembers or which plans run; the
+ // lists elsewhere read it fresh once the turn is through.
+ if (intent.kind !== "clarification") {
+ void sent.then(() =>
+ queryClient.invalidateQueries({
+ queryKey:
+ intent.kind === "memory"
+ ? queryKeys.coachFacts()
+ : queryKeys.coachPlansAll(),
+ }),
+ );
+ }
}
async function handleSubmit(value: string) {
@@ -908,25 +935,6 @@ 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. */}
-
}
composer={composerStack}
diff --git a/src/components/insights/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx
index 49fc18139..a6813f540 100644
--- a/src/components/insights/coach-panel/coach-input.tsx
+++ b/src/components/insights/coach-panel/coach-input.tsx
@@ -372,7 +372,7 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
// Read-only rather than disabled while a reply streams: a disabled
- // field drops focus, and a follow-up chip or a clarification choice
+ // field drops focus, and a tapped reply pill
// hands focus here right as the turn starts.
readOnly={disabled}
aria-disabled={disabled || undefined}
diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx
index b0a855961..b1ad56296 100644
--- a/src/components/insights/coach-panel/coach-results.tsx
+++ b/src/components/insights/coach-panel/coach-results.tsx
@@ -115,6 +115,32 @@ function useReusedLine(result: CoachResultMeta): string | null {
: t(COACH_RESULT_UI_KEYS.reusedFromUndated);
}
+/** `displayed` per ref, from a message's result metadata. */
+function displayedByRef(
+ metas: ReadonlyArray>,
+): Map {
+ return new Map(metas.map((meta) => [meta.ref, meta.displayed]));
+}
+
+/**
+ * The live tables with `displayed` taken from the provenance where it names
+ * them: an interim table is sent before the answer decides.
+ */
+export function liveWithProvenance<
+ T extends Pick,
+>(
+ live: readonly T[],
+ metas: ReadonlyArray>,
+): T[] {
+ const displayed = displayedByRef(metas);
+ return live.map((table) => {
+ const flag = displayed.get(table.ref);
+ return flag === undefined || flag === table.displayed
+ ? table
+ : { ...table, displayed: flag };
+ });
+}
+
/** How many tables sit in a section, from the metadata alone. */
export function countResultsInSection(
metas: ReadonlyArray>,
@@ -147,8 +173,11 @@ function sectionItems(args: {
const inSection = (displayed: boolean) =>
args.section === "displayed" ? displayed : !displayed;
if (args.live && args.live.length > 0) {
+ // v1.41 — a table that arrived while the turn still ran learns whether
+ // the answer points at it from the provenance, which comes later.
+ const displayed = displayedByRef(args.metas);
return args.live
- .filter((table) => inSection(table.displayed))
+ .filter((table) => inSection(displayed.get(table.ref) ?? table.displayed))
.map((table) => ({ ref: table.ref, table }));
}
if (!args.fetched) return [];
@@ -174,7 +203,7 @@ export function CoachResults({
const { t } = useTranslations();
const hasLive = (live?.length ?? 0) > 0;
const expected = hasLive
- ? countResultsInSection(live ?? [], section)
+ ? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
: countResultsInSection(metas, section);
const { ref, results, isError, refetch } = useCoachMessageResults({
conversationId,
@@ -236,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
-function CoachResultView({
+export function CoachResultView({
result,
chartFirst,
}: {
diff --git a/src/components/insights/coach-panel/composer-focus.ts b/src/components/insights/coach-panel/composer-focus.ts
index afcecf39d..afb0e0211 100644
--- a/src/components/insights/coach-panel/composer-focus.ts
+++ b/src/components/insights/coach-panel/composer-focus.ts
@@ -1,9 +1,9 @@
/**
* 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
+ * A reply pill (a follow-up, a clarifying choice, a memory or plan
+ * decision) sends a message and then goes away (pills hide while the turn
+ * runs), 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).
*/
diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx
index 64e5561c6..d525c13b2 100644
--- a/src/components/insights/coach-panel/follow-up-chips.tsx
+++ b/src/components/insights/coach-panel/follow-up-chips.tsx
@@ -2,24 +2,21 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
- * They sit inside the answer's own column, on the bubble's left edge and at
- * the answer's text size, so they read as part of the reply rather than as
- * thread chrome. A tap sends the chip's label as the message with
- * `followUp: { messageId, id }`; the server resolves what the chip asks for.
+ * v1.41 — a thin adapter onto `SuggestedReplies`, the one pattern every
+ * tappable reply in the thread uses (follow-ups, clarifying choices, memory
+ * and plan decisions).
*
+ * A tap sends the chip's label as the message with
+ * `followUp: { messageId, id }`; the server resolves what the chip asks for.
* The label is the server's rendering in the request locale. It is not
* 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. The tapped chip goes away with the rest, so focus
- * moves to the composer.
+ * in the label, and the key alone does not carry it.
*/
-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";
+import { SuggestedReplies } from "./suggested-replies";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
@@ -40,36 +37,21 @@ export function CoachFollowUpChips({
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
- const chips = followUps.slice(0, MAX_CHIPS);
- if (disabled || chips.length === 0) return null;
- return (
-
- {chips.map((chip) => (
-
- ))}
-
- );
+ return SuggestedReplies({
+ slot: "coach-follow-up-chips",
+ label: t(COACH_FOLLOW_UP_UI_KEYS.groupLabel),
+ messageId,
+ disabled,
+ max: MAX_CHIPS,
+ replies: followUps.map((chip) => ({
+ id: chip.id,
+ label: chip.label,
+ kind: "followUp",
+ data: {
+ "data-follow-up-id": chip.id,
+ "data-follow-up-kind": chip.kind,
+ },
+ onSelect: () => onSelect(chip, messageId),
+ })),
+ });
}
diff --git a/src/components/insights/coach-panel/interim-results.tsx b/src/components/insights/coach-panel/interim-results.tsx
new file mode 100644
index 000000000..49064737f
--- /dev/null
+++ b/src/components/insights/coach-panel/interim-results.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+/**
+ * v1.41 — the tables a turn has read so far, while it still runs.
+ *
+ * Under the status line, one quiet line per table as soon as the call that
+ * read it settles: its title, window and how many readings it holds, with a
+ * small sparkline in the first chart colour and no axes. At most three. A
+ * tap opens the full chart (or table) right there; nothing opens by itself.
+ * Once the answer settles the previews go: the tables the answer points at
+ * become its charts, the rest sit under "Data used" in the closed trail.
+ */
+import { useId, useState } from "react";
+import { ChevronRight } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { useTranslations } from "@/lib/i18n/context";
+import type { Locale } from "@/lib/i18n/config";
+import {
+ coachWindowLabelKey,
+ interimLabelKey,
+} from "@/lib/ai/coach/dialog-keys";
+import type { CoachResultTable } from "@/lib/ai/coach/types";
+
+import { CoachResultView } from "./coach-results";
+
+/** At most this many previews show, whatever the turn has read. */
+export const MAX_INTERIM_PREVIEWS = 3;
+
+type Translate = (
+ key: string,
+ params?: Record,
+) => string;
+
+/** The interim tables to preview, in the order they arrived. */
+export function interimPreviews(
+ results: readonly CoachResultTable[],
+ interimRefs: readonly string[],
+): CoachResultTable[] {
+ const refs = new Set(interimRefs);
+ return results
+ .filter((result) => refs.has(result.ref))
+ .slice(0, MAX_INTERIM_PREVIEWS);
+}
+
+/**
+ * How many readings a table holds: the sum of its count column when it has
+ * one (readings per day or week), else its rows.
+ */
+export function interimReadingCount(result: CoachResultTable): number {
+ const countIndex = result.columns.findIndex((c) => c.kind === "count");
+ if (countIndex === -1) return result.rowCount;
+ let sum = 0;
+ for (const row of result.rows) {
+ const cell = row[countIndex];
+ if (typeof cell === "number") sum += cell;
+ }
+ return sum > 0 ? sum : result.rowCount;
+}
+
+/** "Blood pressure by day, last 30 days, 214 readings". */
+export function interimLabel(
+ result: CoachResultTable,
+ t: Translate,
+ locale: Locale,
+): string {
+ const count = interimReadingCount(result);
+ return t(interimLabelKey(count, locale), {
+ title: result.title,
+ window: t(coachWindowLabelKey(result.source.window)),
+ count,
+ });
+}
+
+/** The values the sparkline draws: the first charted series, in row order. */
+export function sparklineValues(result: CoachResultTable): number[] {
+ const spec = result.chart;
+ const key =
+ spec?.kind === "line" || spec?.kind === "bar"
+ ? spec.series[0]
+ : spec?.kind === "compare"
+ ? spec.a
+ : result.columns.find((c) => c.kind === "number")?.key;
+ const index = result.columns.findIndex((c) => c.key === key);
+ if (index === -1) return [];
+ return result.rows
+ .map((row) => row[index])
+ .filter((cell): cell is number => typeof cell === "number");
+}
+
+/** An SVG polyline path through the values, inside `width × height`. */
+export function sparklinePoints(
+ values: readonly number[],
+ width: number,
+ height: number,
+): string {
+ if (values.length === 0) return "";
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = max - min || 1;
+ const step = values.length > 1 ? width / (values.length - 1) : 0;
+ return values
+ .map((value, i) => {
+ const x = values.length > 1 ? i * step : width / 2;
+ const y = height - 1 - ((value - min) / span) * (height - 2);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ })
+ .join(" ");
+}
+
+function Sparkline({ values }: { values: number[] }) {
+ if (values.length < 2) return null;
+ return (
+
+ );
+}
+
+function InterimPreview({ result }: { result: CoachResultTable }) {
+ const { t, locale } = useTranslations();
+ const detailId = useId();
+ const [open, setOpen] = useState(false);
+ return (
+
+ );
+}
diff --git a/src/components/insights/coach-panel/memory-note.tsx b/src/components/insights/coach-panel/memory-note.tsx
new file mode 100644
index 000000000..3338492de
--- /dev/null
+++ b/src/components/insights/coach-panel/memory-note.tsx
@@ -0,0 +1,96 @@
+"use client";
+
+/**
+ * v1.41 — the one quiet line under an answer that says the Coach kept
+ * something: "Remembered: you want to reach 75 kg by December. Undo".
+ *
+ * Meta text at the meta size, with "Undo" as a text button of the same size.
+ * Undo forgets the fact (a soft delete) and the line goes. A fact the Coach
+ * only proposes (a health fact, which is never kept without a tap) has no
+ * line: the answer ends with the question and the reply pills carry
+ * "Yes, remember it" / "No".
+ *
+ * The live turn has the fact's words from the `memoryNote` frame. A
+ * persisted message carries only the fact's id, so its words come from the
+ * memory list; a fact that has since been forgotten or edited away leaves
+ * no line behind.
+ */
+import { useState } from "react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { useTranslations } from "@/lib/i18n/context";
+import { COACH_MEMORY_KEYS } from "@/lib/ai/coach/memory/shared";
+import type { CoachMemoryNoteMeta } from "@/lib/ai/coach/types";
+
+import { useCoachFacts, useForgetCoachFact } from "./use-coach-facts";
+
+export interface CoachMemoryNoteLineProps {
+ fact: string;
+ pending: boolean;
+ onUndo: () => void;
+}
+
+/** The line itself, without data: what the tests render. */
+export function CoachMemoryNoteLine({
+ fact,
+ pending,
+ onUndo,
+}: CoachMemoryNoteLineProps) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
+
+export function CoachMemoryNote({
+ note,
+ liveFact,
+}: {
+ /** `metricSource.memoryNote`, or the live frame's metadata. */
+ note: CoachMemoryNoteMeta;
+ /** The fact's words from the live frame, when the turn just streamed. */
+ liveFact?: string | null;
+}) {
+ const { t } = useTranslations();
+ const [undone, setUndone] = useState(false);
+ const saved = !note.proposal && !!note.factId;
+ // The list is read only for a persisted note without its words.
+ const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
+ const forget = useForgetCoachFact({
+ onSuccess: () => setUndone(true),
+ onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
+ });
+ if (!saved || undone || !note.factId) return null;
+ const fact =
+ liveFact ?? facts.data?.find((f) => f.id === note.factId)?.text ?? null;
+ if (!fact) return null;
+ return (
+ {
+ if (note.factId && !forget.isPending) forget.mutate(note.factId);
+ }}
+ />
+ );
+}
diff --git a/src/components/insights/coach-panel/message-actions.tsx b/src/components/insights/coach-panel/message-actions.tsx
index 5748241af..eba8cb999 100644
--- a/src/components/insights/coach-panel/message-actions.tsx
+++ b/src/components/insights/coach-panel/message-actions.tsx
@@ -41,7 +41,10 @@ import type { Formatters } from "@/lib/format-locale";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
-import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
+import {
+ COACH_MEMORY_KEYS,
+ REMEMBER_FACT_MAX_CHARS,
+} from "@/lib/ai/coach/memory/shared";
import {
COACH_ICON_BUTTON,
@@ -271,37 +274,38 @@ function MessageTime({ iso }: { iso: string }) {
}
/**
- * Stores the user's message in the self-context (Settings → AI) on one tap,
- * so it rides every future system prompt. The server picks the field from
- * the text itself. The icon confirms in place and a toast says where it
- * went; the button never unmounts, so keyboard focus stays on it.
+ * Keeps the user's message in the Coach's memory on one tap
+ * (`POST /api/insights/coach/facts` with the message id; the server reads the
+ * text and files it). It then shows in Settings → Coach with "from you", and
+ * reaches every later turn. v1.41 — it used to write the self-context; the
+ * Coach's memory is now the one place for what the Coach knows. The icon
+ * confirms in place and a toast says so; the button never unmounts, so
+ * keyboard focus stays on it.
*/
-function RememberMessageButton({ content }: { content: string }) {
+function RememberMessageButton({ messageId }: { messageId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
const remember = useMutation({
mutationFn: async () =>
- apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
- answer: content,
+ apiPost<{ created: boolean }>("/api/insights/coach/facts", {
+ messageId,
}),
onSuccess: (data) => {
- if (data.adopted) {
- void queryClient.invalidateQueries({
- queryKey: queryKeys.coachAboutMe(),
- });
- }
- setSettled(data.adopted ? "adopted" : "duplicate");
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.coachFacts(),
+ });
+ setSettled(data.created ? "adopted" : "duplicate");
// A new entry is a write (success); an existing one is an honest
// non-event (neutral), the same mapping `toastWrittenOutcome` uses.
// Called directly: importing the outcome module here regroups the
// shared chunks and costs ~16 KB gz across the client bundle.
- (data.adopted ? toast.success : toast.info)(
+ (data.created ? toast.success : toast.info)(
t(
- data.adopted
- ? "insights.coach.rememberMessage.done"
- : "insights.coach.rememberMessage.duplicate",
+ data.created
+ ? COACH_MEMORY_KEYS.confirmed
+ : "insights.coach.rememberMessage.alreadyKept",
),
);
},
@@ -400,8 +404,8 @@ export function UserMessageActions({
messageId,
createdAt,
}: UserMessageActionsProps) {
- // Only a persisted message that fits the self-context field can be kept.
- const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
+ // Only a persisted message short enough to be one fact can be kept.
+ const canRemember = !!messageId && content.length <= REMEMBER_FACT_MAX_CHARS;
return (
);
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx
index 3d2981f04..cab74b884 100644
--- a/src/components/insights/coach-panel/message-thread.tsx
+++ b/src/components/insights/coach-panel/message-thread.tsx
@@ -14,8 +14,8 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
-import { PlanProposalCards } from "./plan-proposal-card";
-import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
+import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
+import type { SuggestedReply } from "./suggested-replies";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { focusCoachComposer } from "./composer-focus";
@@ -32,9 +32,14 @@ import type {
} from "./use-coach";
import type {
CoachClarification,
+ CoachClarificationChoice,
CoachFollowUp,
CoachMessageDTO,
} from "@/lib/ai/coach/types";
+import {
+ COACH_MEMORY_KEYS,
+ COACH_PLAN_KEYS,
+} from "@/lib/ai/coach/memory/shared";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -99,6 +104,159 @@ export interface MessageThreadProps {
* v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
*/
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
+ /**
+ * v1.41 — a reply pill was tapped: a clarifying question's choice, or an
+ * answer to a fact or plan proposal. Omitted → those pills are not shown.
+ */
+ onReply?: (intent: CoachReplyIntent) => void;
+}
+
+/** v1.41 — what a tapped reply pill answers, and how. */
+export type CoachReplyIntent =
+ | {
+ kind: "clarification";
+ messageId: string;
+ choice: CoachClarificationChoice;
+ label: string;
+ }
+ | {
+ kind: "memory";
+ messageId: string;
+ proposalId: string;
+ accept: boolean;
+ label: string;
+ }
+ | {
+ kind: "plan";
+ messageId: string;
+ planId: string;
+ accept: boolean;
+ label: string;
+ };
+
+/**
+ * v1.41 — the decision the latest answer waits for, if any: a clarifying
+ * question's choices, a fact proposal or a plan proposal. Like the chips, only
+ * the latest assistant turn offers one, and none while a turn is in flight.
+ */
+export interface LatestDecision {
+ messageId: string;
+ clarification: CoachClarification | null;
+ memoryProposalId: string | null;
+ planId: string | null;
+}
+
+export function latestDecision(
+ messages: CoachMessageDTO[],
+ streaming: CoachStreamingMessage | undefined,
+): LatestDecision | null {
+ if (streaming?.inProgress) return null;
+ let found: LatestDecision | null = null;
+ if (streaming?.messageId) {
+ const persisted = messages.find((m) => m.id === streaming.messageId);
+ const note = streaming.memoryNote ?? persisted?.metricSource?.memoryNote;
+ const plan =
+ streaming.planProposal ?? persisted?.metricSource?.planProposal;
+ found = {
+ messageId: streaming.messageId,
+ clarification:
+ streaming.clarification ??
+ persisted?.metricSource?.clarification ??
+ null,
+ memoryProposalId:
+ note?.proposal && note.proposalId ? note.proposalId : null,
+ planId: plan?.planId ?? null,
+ };
+ const last = messages[messages.length - 1];
+ // The streamed turn is the latest only until something follows it.
+ if (persisted && last && last.id !== streaming.messageId) found = null;
+ } else {
+ const last = messages[messages.length - 1];
+ if (last?.role === "assistant") {
+ const note = last.metricSource?.memoryNote;
+ found = {
+ messageId: last.id,
+ clarification: last.metricSource?.clarification ?? null,
+ memoryProposalId:
+ note?.proposal && note.proposalId ? note.proposalId : null,
+ planId: last.metricSource?.planProposal?.planId ?? null,
+ };
+ }
+ }
+ if (
+ !found ||
+ (!found.clarification && !found.memoryProposalId && !found.planId)
+ ) {
+ return null;
+ }
+ return found;
+}
+
+/**
+ * The pills of a decision, in the order they are offered: a question's
+ * choices with the assumed one first, then a fact proposal's two answers,
+ * then a plan proposal's. Labels in the reader's language.
+ */
+export function decisionReplies(
+ decision: LatestDecision,
+ t: (key: string) => string,
+ onReply: (intent: CoachReplyIntent) => void,
+): SuggestedReply[] {
+ const { messageId } = decision;
+ const out: SuggestedReply[] = [];
+ const choices = decision.clarification?.choices ?? [];
+ const assumed = decision.clarification?.assumption;
+ const ordered = assumed
+ ? [
+ ...choices.filter((c) => c.id === assumed),
+ ...choices.filter((c) => c.id !== assumed),
+ ]
+ : choices;
+ for (const choice of ordered.slice(0, 4)) {
+ const local = t(choice.labelKey);
+ const label = local === choice.labelKey ? choice.label : local;
+ out.push({
+ id: `clarify-${choice.id}`,
+ label,
+ kind: "clarification",
+ data: { "data-choice-id": choice.id },
+ onSelect: () =>
+ onReply({ kind: "clarification", messageId, choice, label }),
+ });
+ }
+ const proposalId = decision.memoryProposalId;
+ if (proposalId) {
+ for (const accept of [true, false]) {
+ const label = t(
+ accept ? COACH_MEMORY_KEYS.accept : COACH_MEMORY_KEYS.decline,
+ );
+ out.push({
+ id: `memory-${accept ? "accept" : "decline"}`,
+ label,
+ kind: "memory",
+ data: { "data-decision": accept ? "accept" : "decline" },
+ onSelect: () =>
+ onReply({ kind: "memory", messageId, proposalId, accept, label }),
+ });
+ }
+ }
+ const planId = decision.planId;
+ if (planId) {
+ for (const accept of [true, false]) {
+ const label = t(
+ accept ? COACH_PLAN_KEYS.accept : COACH_PLAN_KEYS.decline,
+ );
+ out.push({
+ id: `plan-${accept ? "accept" : "decline"}`,
+ label,
+ kind: "plan",
+ data: { "data-decision": accept ? "accept" : "decline" },
+ onSelect: () =>
+ onReply({ kind: "plan", messageId, planId, accept, label }),
+ });
+ }
+ }
+ return out;
}
/**
@@ -275,6 +433,7 @@ export function MessageThread({
interleaved,
onRegenerate,
onFollowUp,
+ onReply,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef(null);
@@ -354,14 +513,32 @@ export function MessageThread({
: null,
[chipsMessageId, chipsArray],
);
- const chipsFor = (messageId: string | null | undefined) =>
- onFollowUp && chips && messageId === chips.messageId
+ // v1.41 — a decision the latest answer waits for replaces its chips with
+ // the decision's own pills. There is none while a turn streams, so the
+ // fresh array per render never re-renders a bubble per token.
+ const decision = onReply ? latestDecision(messages, streaming) : null;
+ const replyOffer: ReplyOffer | null =
+ decision && onReply
+ ? {
+ messageId: decision.messageId,
+ replies: decisionReplies(decision, t, onReply),
+ }
+ : null;
+ const chipsFor = (messageId: string | null | undefined) => ({
+ ...(onFollowUp && chips && messageId === chips.messageId
? {
followUps: chips,
followUpsDisabled: !!streaming?.inProgress,
onFollowUp,
}
- : {};
+ : {}),
+ ...(replyOffer && messageId === replyOffer.messageId
+ ? {
+ replies: replyOffer,
+ followUpsDisabled: !!streaming?.inProgress,
+ }
+ : {}),
+ });
const hasThread =
messages.length > 0 ||
@@ -507,7 +684,13 @@ export function MessageThread({
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
- if (m.role === "assistant" && onRegenerate) {
+ // v1.41 — a reply to a memory or plan tap is answered without a
+ // model (`decision`); there is nothing to try again.
+ if (
+ m.role === "assistant" &&
+ onRegenerate &&
+ m.providerType !== "decision"
+ ) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
@@ -532,6 +715,19 @@ export function MessageThread({
streaming.results.length > 0
? { results: streaming.results }
: {})}
+ // v1.41 — and its live trail (with the titles and texts
+ // the persisted copy only has behind `…/trail`), its clock
+ // and the words of the fact it kept.
+ {...(m.id === streaming?.messageId
+ ? {
+ ...(streaming.activity.length > 0
+ ? { activity: streaming.activity }
+ : {}),
+ startedAt: streaming.startedAt,
+ endedAt: streaming.endedAt,
+ memoryNote: streaming.memoryNote,
+ }
+ : {})}
{...(m.role === "assistant" ? chipsFor(m.id) : {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
@@ -585,6 +781,11 @@ export function MessageThread({
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
+ activity={streaming.activity}
+ interimRefs={streaming.interimRefs}
+ startedAt={streaming.startedAt}
+ endedAt={streaming.endedAt}
+ memoryNote={streaming.memoryNote}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
@@ -602,14 +803,6 @@ export function MessageThread({
{placement.tail.map((i) => (
{i.node}
))}
- {/* Plan-proposal confirm cards for THIS conversation. The extractor
- runs after the turn (memory-refresh worker), so the block appears at
- the thread tail once the proposal lands rather than under a specific
- bubble; the component itself slow-polls and renders nothing while
- the conversation has no open proposal. */}
- {conversation ? (
-
- ) : null}
diff --git a/src/components/insights/coach-panel/plan-proposal-card.tsx b/src/components/insights/coach-panel/plan-proposal-card.tsx
deleted file mode 100644
index 9341f9fa9..000000000
--- a/src/components/insights/coach-panel/plan-proposal-card.tsx
+++ /dev/null
@@ -1,218 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { toast } from "sonner";
-import { Check, Loader2, Target, X } from "lucide-react";
-
-import { cn } from "@/lib/utils";
-import { useTranslations } from "@/lib/i18n/context";
-import {
- useCoachPlans,
- useCoachPlanMutations,
- type CoachPlanDTO,
-} from "@/hooks/use-coach-plans";
-
-/**
- * v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
- * of the open conversation's thread.
- *
- * The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
- * `status: "proposed"`; nothing becomes a standing plan until the user
- * confirms it here (or on `/coach/plans`). Because the proposal lands
- * asynchronously, the block slow-polls the proposed list while the thread is
- * mounted (TanStack pauses the interval in background tabs) and only shows
- * proposals whose `sourceConversationId` is the OPEN conversation — a
- * proposal born in an older thread stays on the management page instead of
- * popping into an unrelated chat.
- *
- * Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
- * memory block from the next turn on). Decline → DELETE (soft-delete; the
- * proposal never activates and stops counting against the per-user cap).
- * A settled card collapses to a one-line confirmation for the session so the
- * refetch dropping the row from the proposed list cannot double-prompt.
- */
-
-/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
-export const PROPOSAL_POLL_MS = 20_000;
-
-/**
- * Attempt ceiling for the proposal poll. The extractor runs right after the
- * turn and lands its proposal within a couple of minutes; 15 polls (~5 min
- * of coverage) catch it with margin, and every settled response counts an
- * attempt. Without the cap an open thread polled the proposed list every
- * 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval`
- * (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and
- * the next mount of a fresh conversation restarts the budget.
- */
-export const PROPOSAL_POLL_MAX_ATTEMPTS = 15;
-
-/**
- * Decide whether the proposal query schedules its next poll. Pure so the
- * ceiling is unit-testable: returns the slow interval until the attempt cap
- * is reached, then `false` (poll off).
- */
-export function nextProposalPollInterval(
- dataUpdateCount: number,
-): number | false {
- if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false;
- return PROPOSAL_POLL_MS;
-}
-
-type SettledOutcome = "accepted" | "declined";
-
-export function PlanProposalCards({
- conversationId,
-}: {
- conversationId: string;
-}) {
- const { t } = useTranslations();
- const [settled, setSettled] = useState<
- Array<{ id: string; outcome: SettledOutcome }>
- >([]);
-
- const query = useCoachPlans({
- filter: { status: "proposed" },
- refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount),
- });
-
- // Quiet on error by design: the thread is the Coach's surface, and a failed
- // enhancement read must never interrupt the conversation with an error card.
- const proposals = (query.data ?? []).filter(
- (p) =>
- p.sourceConversationId === conversationId &&
- p.ifCue !== null &&
- p.thenAction !== null &&
- !settled.some((s) => s.id === p.id),
- );
-
- if (proposals.length === 0 && settled.length === 0) return null;
-
- return (
-
-
- {t("coach.plans.proposalTitle")}
-
- {/* The plan prose is the user's own committed intention — content,
- never meta, so it reads in foreground per the text-colour rules. */}
-
- {t("coach.plans.ifThen", {
- cue: plan.ifCue ?? "",
- action: plan.thenAction ?? "",
- })}
-
- {plan.target ? (
-
- {t("coach.plans.targetPrefix", { target: plan.target })}
-
- ) : null}
-
- {plan.metric}
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/components/insights/coach-panel/suggested-replies.tsx b/src/components/insights/coach-panel/suggested-replies.tsx
new file mode 100644
index 000000000..ada286355
--- /dev/null
+++ b/src/components/insights/coach-panel/suggested-replies.tsx
@@ -0,0 +1,98 @@
+"use client";
+
+/**
+ * v1.41 — the one quiet way the Coach offers a reply: pills under the latest
+ * answer, in the answer's own column, at the answer's text size.
+ *
+ * Every reply the person can give by tap uses it: the follow-ups after an
+ * answer, the choices of a clarifying question (the question itself is the
+ * answer above, its assumed choice the first pill), "Yes, remember it" /
+ * "No" under a fact proposal and "Take on this plan" / "Not now" under a
+ * plan proposal. A tap sends the pill's label as the person's own message,
+ * so it shows in the thread like anything they typed; the request carries
+ * which offer it answered, and the server resolves it from what it stored.
+ *
+ * Outline pills in the foreground at reduced contrast, never an accent
+ * colour, never a card. Hidden while a turn runs, so a pill can never answer
+ * a reply that is about to stop being the latest. The tapped pill 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_SUGGESTED_REPLIES_KEYS } from "@/lib/ai/coach/dialog-keys";
+
+import { focusCoachComposer } from "./composer-focus";
+
+/** At most this many pills render, whatever the offer carries. */
+export const MAX_SUGGESTED_REPLIES = 4;
+
+export interface SuggestedReply {
+ /** Stable within the offer; the React key. */
+ id: string;
+ /** What the pill says, and the message a tap sends. */
+ label: string;
+ /** What the offer is (`followUp`, `clarification`, `memory`, `plan`). */
+ kind: string;
+ /** Extra `data-*` attributes, for tests and the e2e specs. */
+ data?: Record<`data-${string}`, string>;
+ onSelect: () => void;
+}
+
+export interface SuggestedRepliesProps {
+ replies: SuggestedReply[];
+ /** The assistant message that offered them. */
+ messageId: string;
+ /** True while a turn is in flight. */
+ disabled: boolean;
+ /** At most this many; defaults to {@link MAX_SUGGESTED_REPLIES}. */
+ max?: number;
+ /** The group's accessible name; defaults to "Suggested replies". */
+ label?: string;
+ /** The group's `data-slot`. */
+ slot?: string;
+}
+
+export function SuggestedReplies({
+ replies,
+ messageId,
+ disabled,
+ max = MAX_SUGGESTED_REPLIES,
+ label,
+ slot = "coach-suggested-replies",
+}: SuggestedRepliesProps) {
+ const { t } = useTranslations();
+ const shown = replies.slice(0, max);
+ if (disabled || shown.length === 0) return null;
+ return (
+
+ {shown.map((reply) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/insights/coach-panel/turn-activity.tsx b/src/components/insights/coach-panel/turn-activity.tsx
new file mode 100644
index 000000000..157906a69
--- /dev/null
+++ b/src/components/insights/coach-panel/turn-activity.tsx
@@ -0,0 +1,842 @@
+"use client";
+
+/**
+ * v1.41 — the live trail of a Coach turn, one quiet line at the top of the
+ * answer.
+ *
+ * While the turn runs the line says what the Coach is doing right now, from
+ * the `activity` frames: "Thinking…", "Fetching blood pressure, last 90
+ * days…", "Summarising 214 readings…", "Writing the answer…", with the
+ * seconds so far on the right. A reasoning title replaces the thinking label
+ * when one arrives. Once the turn ends the same line settles into one calm
+ * summary: "Thought it through, 3 lookups, 12 s".
+ *
+ * Nothing opens by itself. The trail starts closed in every state of a turn
+ * (running, asking, stopped, failed, settled, reloaded) and only a tap on the
+ * line opens it; it then stays open until the person closes it, and keeps
+ * growing while the turn runs. Open, it lists one entry per phase with its
+ * status, the reasoning text of a round behind its own disclosure (closed
+ * too), the method line and the tables the answer used without pointing at
+ * them.
+ *
+ * Labels are catalog text the server rendered; titles and texts are screened
+ * model text the owner alone receives. A persisted message carries only the
+ * metadata, so its titles and texts are read from `…/trail` the first time
+ * the person opens the trail. A message saved before v1.41 has no activity
+ * and falls back to the step list (`metricSource.steps`), and one saved
+ * before steps existed to the areas it drew on.
+ *
+ * Screen readers hear the line through a polite status region, throttled to
+ * one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the
+ * summary once when the turn ends. A reloaded conversation announces nothing.
+ */
+import { useEffect, useId, useRef, useState, type ReactNode } from "react";
+import { useQuery } from "@tanstack/react-query";
+import {
+ Check,
+ ChevronRight,
+ Loader2,
+ Minus,
+ TriangleAlert,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { apiGet } from "@/lib/api/api-fetch";
+import { queryKeys } from "@/lib/query-keys";
+import { useTranslations } from "@/lib/i18n/context";
+import type { Locale } from "@/lib/i18n/config";
+import {
+ COACH_STEP_REASON_KEYS,
+ COACH_STEP_UI_KEYS,
+ coachDomainLabelKey,
+ coachGranularityLabelKey,
+ coachPeriodLabelKey,
+ coachWindowLabelKey,
+} from "@/lib/ai/coach/dialog-keys";
+import {
+ COACH_ACTIVITY_KEYS,
+ activitySummaryKey,
+} from "@/lib/ai/coach/activity/contract";
+import type {
+ CoachActivity,
+ CoachMethod,
+ CoachProvenanceMetric,
+ CoachStep,
+ CoachTrail,
+} from "@/lib/ai/coach/types";
+
+import { CoachMethodLine } from "./method-line";
+
+/** The shortest gap between two screen-reader announcements. */
+export const ANNOUNCE_THROTTLE_MS = 1500;
+
+/** "12s": the seconds on the line and beside a finished round. */
+const ELAPSED_KEY = "insights.coach.thinkingElapsed";
+
+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;
+}
+
+// ── Steps (messages saved before v1.41, and the fetch rows) ────────────────
+
+/**
+ * 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 };
+}
+
+/**
+ * How many sources the turn looked at: one per domain (a step without one,
+ * the snapshot, counts by its tool). A second read of the same domain, for
+ * the period before or a retry, is not a new source.
+ */
+export function countSources(steps: readonly CoachStep[]): number {
+ return new Set(steps.map((step) => step.domain ?? step.tool)).size;
+}
+
+/**
+ * The step a legacy running line 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 areas an older message drew on, in the order its provenance lists
+ * them, by their display names. A token without a name in the bundle is
+ * dropped, and so is `general`, which names no area of the record.
+ */
+export function legacyAreaLabels(
+ areas: readonly CoachProvenanceMetric[] | undefined,
+ t: Translate,
+): string[] {
+ const out: string[] = [];
+ for (const area of areas ?? []) {
+ if (area === "general") continue;
+ const key = `insights.coach.metric.${area}`;
+ const label = t(key);
+ if (label === key || out.includes(label)) continue;
+ out.push(label);
+ }
+ return out;
+}
+
+// ── Activity ───────────────────────────────────────────────────────────────
+
+/**
+ * The entry the running line names: the latest one still running, else the
+ * latest one overall. Null when the turn has sent no activity yet.
+ */
+export function currentActivity(
+ activity: readonly CoachActivity[],
+): CoachActivity | null {
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
+ if (activity[i].status === "running") return activity[i];
+ }
+ return activity.at(-1) ?? null;
+}
+
+/**
+ * What an entry is called on the line: a screened reasoning title for a
+ * thinking round once one has arrived, else the server's catalog label.
+ */
+export function activityLineLabel(entry: CoachActivity): string {
+ if (
+ (entry.phase === "thinking" || entry.phase === "checkpoint") &&
+ entry.title
+ ) {
+ return entry.title;
+ }
+ // A finished entry no longer trails off: "Writing the answer", not
+ // "Writing the answer…".
+ return entry.status === "running"
+ ? entry.label
+ : entry.label.replace(/(?:…|\.\.\.)\s*$/u, "");
+}
+
+/** The lookups a turn made: one per fetch. */
+export function countLookups(activity: readonly CoachActivity[]): number {
+ return activity.filter((entry) => entry.phase === "fetch").length;
+}
+
+/**
+ * The seconds a turn took, from its trail: the phases of a round run one
+ * after the other, except its fetches, which run side by side (the longest
+ * counts). Null when the trail carries no durations.
+ */
+export function activitySeconds(
+ activity: readonly CoachActivity[],
+): number | null {
+ const fetchByRound = new Map();
+ let sequential = 0;
+ let any = false;
+ for (const entry of activity) {
+ if (entry.durationMs === undefined) continue;
+ any = true;
+ if (entry.phase === "fetch") {
+ fetchByRound.set(
+ entry.round,
+ Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs),
+ );
+ } else {
+ sequential += entry.durationMs;
+ }
+ }
+ if (!any) return null;
+ let total = sequential;
+ for (const ms of fetchByRound.values()) total += ms;
+ return Math.max(1, Math.round(total / 1000));
+}
+
+/** Whole seconds between two instants, at least one. */
+export function elapsedSeconds(from: number, to: number): number {
+ return Math.max(1, Math.round((to - from) / 1000));
+}
+
+/**
+ * The settled line: "Thought it through, N lookups, S s". The seconds come
+ * from the trail, else from the clock of the turn that just streamed. Null
+ * when neither is known (a message saved before v1.41).
+ */
+export function activitySummary(args: {
+ activity: readonly CoachActivity[];
+ startedAt?: number | null;
+ endedAt?: number | null;
+ t: Translate;
+ locale: Locale;
+}): string | null {
+ const { activity, startedAt, endedAt, t, locale } = args;
+ if (activity.length === 0) return null;
+ const seconds =
+ activitySeconds(activity) ??
+ (startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null);
+ if (seconds === null) return null;
+ const lookups = countLookups(activity);
+ return t(activitySummaryKey(lookups, locale), { lookups, seconds });
+}
+
+/** A tick per second while `running`; the current time otherwise. */
+function useNow(running: boolean): number {
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (!running) return;
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [running]);
+ return now;
+}
+
+/**
+ * The status region's text: the running line as it changes, throttled, and
+ * the summary once when the turn ends. Nothing is announced for a trail that
+ * was already settled when it mounted.
+ */
+function useLineAnnouncement(text: string, active: boolean): string {
+ const [mountedActive] = useState(active);
+ const lastAt = useRef(0);
+ const timer = useRef | null>(null);
+ const said = useRef(null);
+ const [announcement, setAnnouncement] = useState("");
+
+ useEffect(() => {
+ // A reloaded, settled trail stays quiet.
+ if (!mountedActive) return;
+ if (!text || text === said.current) return;
+ said.current = text;
+ 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();
+ setAnnouncement(text);
+ }, wait);
+ }, [text, mountedActive]);
+
+ useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current);
+ },
+ [],
+ );
+
+ return announcement;
+}
+
+/** The owner-only titles and texts of a persisted message's trail. */
+function useTrailText(args: {
+ conversationId: string | null | undefined;
+ messageId: string | null | undefined;
+ enabled: boolean;
+}): CoachTrail | null {
+ const { conversationId, messageId, enabled } = args;
+ const query = useQuery({
+ queryKey: queryKeys.coachMessageTrail(
+ conversationId ?? "",
+ messageId ?? "",
+ ),
+ queryFn: async (): Promise => {
+ const data = await apiGet<
+ { trail?: CoachTrail | null } | CoachTrail | null
+ >(
+ `/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/trail`,
+ );
+ if (!data) return null;
+ if ("entries" in data) return data;
+ return data.trail ?? null;
+ },
+ enabled: enabled && !!conversationId && !!messageId,
+ // The trail is the stored snapshot of a finished turn; it never changes.
+ staleTime: Infinity,
+ retry: false,
+ });
+ return query.data ?? null;
+}
+
+// ── Rendering ──────────────────────────────────────────────────────────────
+
+type EntryStatus = CoachActivity["status"];
+
+function StatusIcon({
+ status,
+ active,
+}: {
+ status: EntryStatus;
+ active: boolean;
+}) {
+ const className = "mt-0.5 size-3 shrink-0";
+ if (status === "running" && active) {
+ return (
+
+ );
+ }
+ if (status === "done" || status === "running") {
+ return (
+
+ );
+ }
+ if (status === "failed") {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+/** The rows of a legacy step list: one per step, in the order they started. */
+export function CoachTurnStepList({
+ id,
+ steps,
+ active,
+}: {
+ id?: string;
+ steps: CoachStep[];
+ active: boolean;
+}) {
+ const { t, tCount } = useTranslations();
+ return (
+
+ {steps.map((step) => {
+ const { title, meta } = describeStep(step, t, tCount);
+ return (
+
+
+
+ {title}
+
+
+
+ );
+ })}
+
+ );
+}
+
+function StepMeta({ parts }: { parts: string[] }) {
+ return (
+ <>
+ {parts.map((part, i) => (
+
+ ·
+ {/* The comma is for a screen reader only. `select-none` keeps it
+ out of a copied row. */}
+ ,
+ {part}
+
+ ))}
+ >
+ );
+}
+
+/**
+ * One trail entry. A fetch backed by a step reads like a step row (domain,
+ * window, count); everything else shows its label and, once finished, how
+ * long it took. A thinking round with reasoning text, and the memory entry
+ * with the things it recalled, open their detail on their own tap.
+ */
+function ActivityEntry({
+ entry,
+ step,
+ active,
+ detail,
+}: {
+ entry: CoachActivity;
+ step: CoachStep | undefined;
+ active: boolean;
+ detail: { text?: string; recalled?: string[] } | null;
+}) {
+ const { t, tCount } = useTranslations();
+ const detailId = useId();
+ const [open, setOpen] = useState(false);
+ let title: string;
+ let meta: string[] = [];
+ if (entry.phase === "fetch" && step) {
+ ({ title, meta } = describeStep(step, t, tCount));
+ } else {
+ title = activityLineLabel(entry);
+ if (entry.status !== "running" && entry.durationMs !== undefined) {
+ meta = [
+ t(ELAPSED_KEY, {
+ seconds: Math.max(1, Math.round(entry.durationMs / 1000)),
+ }),
+ ];
+ }
+ }
+ const hasDetail =
+ !!detail?.text ||
+ (detail?.recalled !== undefined && detail.recalled.length > 0);
+ return (
+
+ );
+}
+
+export interface CoachTurnActivityProps {
+ /** The live trail, or `metricSource.activity` of a persisted message. */
+ activity: CoachActivity[];
+ /** The steps the fetch entries point at; a legacy message's whole list. */
+ steps: CoachStep[];
+ /** True while the turn is still running. */
+ active: boolean;
+ /** When the live turn started and ended on this device (epoch ms). */
+ startedAt?: number | null;
+ endedAt?: number | null;
+ /**
+ * The provenance metrics, for a message saved before steps existed. Read
+ * only when there is neither activity nor steps.
+ */
+ areas?: readonly CoachProvenanceMetric[];
+ /** How the answer was worked out; shown at the end of the open trail. */
+ method?: CoachMethod | null;
+ /**
+ * The tables the answer read without pointing at them, shown last in the
+ * open trail. Pass it only when there is something to show.
+ */
+ dataUsed?: ReactNode;
+ /** The persisted message, for reading its stored titles and texts. */
+ conversationId?: string | null;
+ messageId?: string | null;
+}
+
+export function CoachTurnActivity({
+ activity,
+ steps,
+ active,
+ startedAt,
+ endedAt,
+ areas,
+ method,
+ dataUsed,
+ conversationId,
+ messageId,
+}: CoachTurnActivityProps) {
+ const { t, tCount, locale } = useTranslations();
+ const panelId = useId();
+ // Closed in every state; only the person's tap opens it, and it stays as
+ // they left it (also across the swap to the persisted copy, which keeps
+ // this component).
+ const [open, setOpen] = useState(false);
+ const now = useNow(active);
+
+ // Only a persisted message whose live entries carry no text asks the
+ // server for it, and only once the trail is open.
+ const needsStoredText =
+ !active && activity.length > 0 && !activity.some((a) => a.title || a.text);
+ const trail = useTrailText({
+ conversationId,
+ messageId,
+ enabled: open && needsStoredText,
+ });
+
+ const areaLabels =
+ activity.length === 0 && steps.length === 0
+ ? legacyAreaLabels(areas, t)
+ : [];
+ const hasMethod = !active && !!method?.text;
+ const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
+
+ let line: string;
+ if (active) {
+ const entry = currentActivity(activity);
+ const step = entry ? null : currentStep(steps);
+ line = entry
+ ? activityLineLabel(entry)
+ : step
+ ? stepLabel(step, t)
+ : t(COACH_ACTIVITY_KEYS.thinking);
+ } else {
+ line =
+ activitySummary({ activity, startedAt, endedAt, t, locale }) ??
+ (steps.length > 0
+ ? tCount("coach.step.headerDone", countSources(steps))
+ : areaLabels.length > 0
+ ? tCount("insights.coach.answer.areasDone", areaLabels.length)
+ : t(COACH_STEP_UI_KEYS.listLabel));
+ }
+ const announcement = useLineAnnouncement(line, active);
+
+ // A settled message with nothing to show has no line at all.
+ if (
+ !active &&
+ activity.length === 0 &&
+ steps.length === 0 &&
+ areaLabels.length === 0 &&
+ !hasMethod &&
+ !hasDataUsed
+ ) {
+ return null;
+ }
+
+ const seconds =
+ active && startedAt
+ ? elapsedSeconds(startedAt, Math.max(now, startedAt))
+ : null;
+
+ return (
+
+
+ {open && (
+
+ )}
+
+ {announcement}
+
+
+ );
+}
diff --git a/src/components/insights/coach-panel/turn-steps.tsx b/src/components/insights/coach-panel/turn-steps.tsx
deleted file mode 100644
index 9e62b0d01..000000000
--- a/src/components/insights/coach-panel/turn-steps.tsx
+++ /dev/null
@@ -1,498 +0,0 @@
-"use client";
-
-/**
- * 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.
- *
- * 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.
- *
- * The open list also says how the answer was worked out (the method line)
- * and holds the tables the answer read without pointing at them. An older
- * message saved before steps existed folds into "Looked at 3 areas" and
- * lists the areas from its provenance, without counts; a metric token the
- * bundle has no name for is left out rather than shown as a raw key.
- *
- * 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, type ReactNode } 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 {
- CoachMethod,
- CoachProvenanceMetric,
- CoachStep,
-} from "@/lib/ai/coach/types";
-
-import { CoachMethodLine } from "./method-line";
-
-export interface CoachTurnStepsProps {
- steps: CoachStep[];
- /** True while the turn is still running. */
- active: boolean;
-}
-
-export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
- /**
- * The provenance metrics, for a message saved before steps existed. Read
- * only when `steps` is empty.
- */
- areas?: readonly CoachProvenanceMetric[];
- /** How the answer was worked out; shown at the end of the open list. */
- method?: CoachMethod | null;
- /**
- * The tables the answer read without pointing at them, shown last in the
- * open list. Pass it only when there is something to show.
- */
- dataUsed?: ReactNode;
-}
-
-/** 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(" · ");
-}
-
-/**
- * How many sources the turn looked at: one per domain (a step without one,
- * the snapshot, counts by its tool). A second read of the same domain, for
- * the period before or a retry, is not a new source.
- */
-export function countSources(steps: readonly CoachStep[]): number {
- return new Set(steps.map((step) => step.domain ?? step.tool)).size;
-}
-
-/**
- * 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 (
-
- );
- }
- if (step.status === "done") {
- return (
-
- );
- }
- if (step.status === "failed") {
- return (
-
- );
- }
- return (
-
- );
-}
-
-/**
- * The areas an older message drew on, in the order its provenance lists
- * them, by their display names. A token without a name in the bundle is
- * dropped, and so is `general`, which names no area of the record.
- */
-export function legacyAreaLabels(
- areas: readonly CoachProvenanceMetric[] | undefined,
- t: Translate,
-): string[] {
- const out: string[] = [];
- for (const area of areas ?? []) {
- if (area === "general") continue;
- const key = `insights.coach.metric.${area}`;
- const label = t(key);
- if (label === key || out.includes(label)) continue;
- out.push(label);
- }
- return out;
-}
-
-/** 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 (
-
- {steps.map((step) => {
- const { title, meta } = describeStep(step, t, tCount);
- return (
-
-
-
- {title}
- {meta.map((part, i) => (
-
- ·
- {/* The comma is for a screen reader only. `select-none`
- keeps it out of a copied row, which otherwise read
- "Weight · ,all time". */}
- ,
- {part}
-
- ))}
-
-
- );
- })}
-
- );
-}
-
-/**
- * The open list: the steps (or an older message's areas), then the method
- * line, then the tables the answer read. One left rule holds them together.
- */
-export function CoachTurnStepsPanel({
- id,
- steps,
- active,
- areaLabels,
- method,
- dataUsed,
-}: CoachTurnStepsProps & {
- id?: string;
- areaLabels: string[];
- method?: CoachMethod | null;
- dataUsed?: ReactNode;
-}) {
- const { t } = useTranslations();
- return (
-
{t("insights.coach.frame.saving")}
diff --git a/src/components/insights/coach-panel/coach-reasoning-field.tsx b/src/components/insights/coach-panel/coach-reasoning-field.tsx
new file mode 100644
index 000000000..8ea3a5038
--- /dev/null
+++ b/src/components/insights/coach-panel/coach-reasoning-field.tsx
@@ -0,0 +1,196 @@
+"use client";
+
+/**
+ * v1.41 — how hard the Coach thinks before it answers: one select, Off / Low
+ * / Medium / High, Medium by default.
+ *
+ * The person's own choice lives in `coachPrefsJson.reasoning`. What the
+ * server resolved from it rides `/api/auth/me` as `coachReasoning`: whether
+ * the operator allows reasoning at all, the highest level they allow, and
+ * whether "off" is really off on the active provider. The field renders from
+ * that and never recomputes it:
+ * - the operator switched reasoning off: the select is locked and one
+ * sentence says so;
+ * - a level above the operator's cap is offered but disabled, with
+ * "(limited by the admin)";
+ * - where the provider cannot switch thinking off, "Off" reads "Minimal".
+ * A server older than v1.41 sends no block; the field then reads as allowed,
+ * uncapped and really off.
+ *
+ * Two homes, one field: the Coach's quick settings (saves on change) and
+ * Settings → Coach (part of that card's draft and save).
+ */
+import { useQueryClient } from "@tanstack/react-query";
+
+import { Label } from "@/components/ui/label";
+import { NativeSelect } from "@/components/ui/native-select";
+import { useTranslations } from "@/lib/i18n/context";
+import { useCoachReasoning } from "@/hooks/use-ai-capability";
+import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
+import { queryKeys } from "@/lib/query-keys";
+import {
+ DEFAULT_REASONING_LEVEL,
+ REASONING_LEVELS,
+ REASONING_LEVEL_LABEL_KEYS,
+ REASONING_SETTING_KEYS,
+ isReasoningLevel,
+ type ReasoningLevel,
+} from "@/lib/ai/reasoning/levels";
+import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
+import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
+
+export type { CoachReasoningState };
+
+/**
+ * The block as the field reads it: the server's answer, or for a server that
+ * does not publish one (older than v1.41) "allowed, uncapped, really off" at
+ * the default level.
+ */
+export function reasoningStateOrDefault(
+ state: CoachReasoningState | null | undefined,
+): CoachReasoningState {
+ return (
+ state ?? {
+ level: DEFAULT_REASONING_LEVEL,
+ preference: DEFAULT_REASONING_LEVEL,
+ maxLevel: "high",
+ available: true,
+ offIsReal: true,
+ source: "user",
+ }
+ );
+}
+
+/** True when the operator switched reasoning off: the field is locked. */
+export function reasoningLocked(state: CoachReasoningState): boolean {
+ return state.maxLevel === "off" || state.source === "admin_off";
+}
+
+export interface ReasoningOption {
+ value: ReasoningLevel;
+ label: string;
+ disabled: boolean;
+}
+
+/** The four options in order, labelled and capped for this account. */
+export function reasoningOptions(
+ state: CoachReasoningState,
+ t: (key: string) => string,
+): ReasoningOption[] {
+ const locked = reasoningLocked(state);
+ const cap = REASONING_LEVELS.indexOf(state.maxLevel);
+ return REASONING_LEVELS.map((value, index) => {
+ const base = t(
+ value === "off" && !state.offIsReal
+ ? REASONING_LEVEL_LABEL_KEYS.minimal
+ : REASONING_LEVEL_LABEL_KEYS[value],
+ );
+ // Above the cap is "(limited by the admin)"; while reasoning is off the
+ // one sentence under the field says it, not every option.
+ const capped = !locked && index > cap;
+ return {
+ value,
+ label: capped ? `${base} ${t(REASONING_SETTING_KEYS.capped)}` : base,
+ disabled: capped,
+ };
+ });
+}
+
+/**
+ * The level the select shows: the person's choice, lowered to the cap when
+ * it is above it (that is what runs), and the resolved level while the
+ * operator has reasoning off.
+ */
+export function shownReasoningLevel(
+ choice: ReasoningLevel,
+ state: CoachReasoningState,
+): ReasoningLevel {
+ if (reasoningLocked(state)) return state.level;
+ return REASONING_LEVELS.indexOf(choice) >
+ REASONING_LEVELS.indexOf(state.maxLevel)
+ ? state.maxLevel
+ : choice;
+}
+
+export interface CoachReasoningSelectProps {
+ id: string;
+ value: ReasoningLevel;
+ state: CoachReasoningState;
+ /** Locks the select while a write is in flight. */
+ busy?: boolean;
+ onChange: (next: ReasoningLevel) => void;
+}
+
+/** The field itself, without data: what the tests render. */
+export function CoachReasoningSelect({
+ id,
+ value,
+ state,
+ busy = false,
+ onChange,
+}: CoachReasoningSelectProps) {
+ const { t } = useTranslations();
+ const options = reasoningOptions(state, t);
+ const locked = reasoningLocked(state);
+ return (
+
+
+ {
+ const next = e.target.value;
+ if (isReasoningLevel(next) && next !== value) onChange(next);
+ }}
+ >
+ {options.map((option) => (
+
+ ))}
+
+
+ );
+}
+
+/** The quick-settings field: reads the saved choice and saves on change. */
+export function CoachReasoningField({ id }: { id: string }) {
+ const queryClient = useQueryClient();
+ const reasoning = useCoachReasoning();
+ const prefs = useCoachPrefs();
+ const save = useSaveCoachPrefs({
+ // The resolved block on `/me` follows the saved choice.
+ onSuccess: () =>
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }),
+ });
+ if (!prefs.data) return null;
+ const current = prefs.data;
+ const state = reasoningStateOrDefault(reasoning);
+ return (
+ save.mutate({ ...current, reasoning })}
+ />
+ );
+}
diff --git a/src/components/insights/coach-panel/coach-settings-body.tsx b/src/components/insights/coach-panel/coach-settings-body.tsx
index 2a844cbb9..31b8da580 100644
--- a/src/components/insights/coach-panel/coach-settings-body.tsx
+++ b/src/components/insights/coach-panel/coach-settings-body.tsx
@@ -2,27 +2,63 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
-import { Cpu } from "lucide-react";
+import { Brain, ChevronRight, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
+import { COACH_MEMORY_LIST_KEYS } from "@/lib/ai/coach/memory/shared";
+
import { CoachModelPicker } from "./coach-model-picker";
+import { CoachReasoningField } from "./coach-reasoning-field";
+import { useCoachFacts } from "./use-coach-facts";
import { SourcesRail } from "./sources-rail";
/**
* The content of the Coach's quick settings (see `coach-settings-overlay`):
- * the model choice, "What I can see" (the existing sources rail, wired to the
- * saved Coach preferences: data areas and how far back the Coach looks) and
- * a way on to the full AI settings.
+ * the model choice and how hard the Coach thinks, a way to what it remembers,
+ * "What I can see" (the existing sources rail, wired to the saved Coach
+ * preferences: data areas and how far back the Coach looks) and a way on to
+ * the full AI settings.
*
* `focusData` lands focus on the "What I can see" section when the body
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
* in the overlay because the body loads on demand and may arrive after the
- * popover or sheet has already placed focus.
+ * popover or sheet has already placed focus. The section draws no focus
+ * ring: it is where the link lands, not a control, and a ring drawn on every
+ * deep link reads as a highlight nobody asked for.
*/
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
+/** Where the memory list lives: Settings → Coach, its memory card. */
+export const COACH_MEMORY_HREF = "/settings/coach#coach-memory";
+
+/** "Memory (8)": a link to the list, with how much it holds. */
+function CoachMemoryLink() {
+ const { t } = useTranslations();
+ const facts = useCoachFacts();
+ const count = facts.data?.length ?? 0;
+ return (
+
+
+
+ {t(COACH_MEMORY_LIST_KEYS.link, { count })}
+
+
+
+ );
+}
+
export function CoachSettingsBody({
focusData = false,
}: {
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
{t("insights.coach.frame.modelSection")}
+
+
diff --git a/src/components/settings/__tests__/coach-links-without-coach.test.tsx b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
index 4a2ecb8da..5025a769a 100644
--- a/src/components/settings/__tests__/coach-links-without-coach.test.tsx
+++ b/src/components/settings/__tests__/coach-links-without-coach.test.tsx
@@ -15,6 +15,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: coachState.available ? null : "no_provider",
onDeviceAllowed: false,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
diff --git a/src/components/settings/__tests__/coach-memory-section.test.tsx b/src/components/settings/__tests__/coach-memory-section.test.tsx
index c5ca68404..d93d4b891 100644
--- a/src/components/settings/__tests__/coach-memory-section.test.tsx
+++ b/src/components/settings/__tests__/coach-memory-section.test.tsx
@@ -25,10 +25,17 @@ import { CoachMemorySection } from "../coach-memory-section";
interface SeedFact {
id: string;
- category: "preference" | "condition" | "goal" | "constraint" | "context";
+ category:
+ | "preference"
+ | "condition"
+ | "goal"
+ | "constraint"
+ | "context"
+ | "medication";
text: string;
confidence: number;
createdAt: string;
+ source?: "user" | "coach" | "extracted" | "pattern";
}
const NOW = "2026-06-04T12:00:00.000Z";
@@ -152,4 +159,45 @@ describe("Settings — CoachMemorySection", () => {
expect(withFacts).toContain("rolling summary");
expect(empty).toContain("rolling summary");
});
+
+ it("says where each fact came from, and offers to edit it", () => {
+ const html = render([
+ { ...buildFacts()[0], source: "user" },
+ { ...buildFacts()[1], source: "coach" },
+ { ...buildFacts()[2], source: "extracted" },
+ {
+ id: "fact-med-1",
+ category: "medication",
+ text: "Takes ramipril in the morning",
+ confidence: 1,
+ createdAt: NOW,
+ source: "user",
+ },
+ ]);
+ expect(
+ html.match(/data-slot="settings-coach-memory-source"/g),
+ ).toHaveLength(4);
+ expect(html).toContain("from you");
+ expect(html).toContain("from the Coach");
+ expect(html.match(/data-slot="settings-coach-memory-edit"/g)).toHaveLength(
+ 4,
+ );
+ expect(html).toMatch(
+ /data-slot="settings-coach-memory-edit"[^>]*aria-label="Edit"/,
+ );
+ // Medications get their own group.
+ expect(html).toContain(
+ 'data-testid="settings-coach-memory-group-medication"',
+ );
+ expect(html).toContain("Medications");
+ // The quick settings land here.
+ expect(html).toMatch(/]*id="coach-memory"/);
+ });
+
+ it("lists goals first", () => {
+ const html = render(buildFacts());
+ expect(html.indexOf("group-goal")).toBeLessThan(
+ html.indexOf("group-preference"),
+ );
+ });
});
diff --git a/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
index a1d4158a6..23d8317c5 100644
--- a/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
+++ b/src/components/settings/__tests__/coach-prefs-sources-summary.test.tsx
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: false,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
diff --git a/src/components/settings/__tests__/coach-section.test.tsx b/src/components/settings/__tests__/coach-section.test.tsx
index 20c9689d2..157b489e1 100644
--- a/src/components/settings/__tests__/coach-section.test.tsx
+++ b/src/components/settings/__tests__/coach-section.test.tsx
@@ -36,6 +36,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: true,
}),
+ // v1.41 — the thinking depth reads the resolved block; none here.
+ useCoachReasoning: () => null,
}));
const authState: {
diff --git a/src/components/settings/ai/reasoning-effort-field.tsx b/src/components/settings/ai/reasoning-effort-field.tsx
index fee0f4e3f..c6698b641 100644
--- a/src/components/settings/ai/reasoning-effort-field.tsx
+++ b/src/components/settings/ai/reasoning-effort-field.tsx
@@ -4,6 +4,8 @@
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
* Same Label + NativeSelect + hint layout as the model field above it.
* The empty value is Default: nothing is sent and the model decides.
+ * v1.41 — the Coach reads its own thinking depth from the Coach settings;
+ * a line under the hint says so.
* ──────────────────────────────────────────────────────────────── */
import { Label } from "@/components/ui/label";
@@ -56,6 +58,14 @@ export function ReasoningEffortField({