Merge remote-tracking branch 'origin/feat/coach-answers' into release/v1.40.2

# Conflicts:
#	messages/de.json
#	messages/en.json
#	messages/es.json
#	messages/fr.json
#	messages/it.json
#	messages/ko.json
#	messages/pl.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-05 07:16:55 +02:00
commit 0d6bac8c5c
28 files changed
+1833 -1140

No files matched your search

@@ -0,0 +1,292 @@
/**
* The one action row under a Coach message, and the follow-up chips that
* live in the answer's column.
*
* - Assistant: copy, read aloud, try again, then the time, in one left
* group; the tokens and model in the right group of the same row. No
* feedback thumbs, no evidence disclosure.
* - User: copy, remember, time in one row; remember is an icon.
* - The time reads "14:32" today, "yesterday 14:32", or the date further
* back.
* - The chips of the latest answer render inside its column, after the
* action row, at the answer's text size; the memo comparator sees a new
* offer.
*/
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 { CoachFollowUp } from "@/lib/ai/coach/types";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: null, isAuthenticated: true, isLoading: false }),
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: undefined }),
useMutation: () => ({ mutate: vi.fn(), isPending: false, isError: false }),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("@/hooks/use-coach-message-results", () => ({
useCoachMessageResults: () => ({
ref: () => {},
results: undefined,
isLoading: false,
isError: false,
refetch: () => {},
}),
}));
// SSR resolves the clipboard and speech capabilities to "absent"; claim
// both so the full row renders and its order can be read.
vi.mock("../read-aloud", async (importOriginal) => {
const actual = await importOriginal<typeof import("../read-aloud")>();
return {
...actual,
useClipboardSupported: () => true,
ReadAloudButton: () => (
<button type="button" data-slot="coach-read-aloud" aria-label="Read" />
),
};
});
import { ChatBubble, areChatBubblePropsEqual } from "../chat-bubble";
import { answerMetaText, messageTimeText } from "../message-actions";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
/** The markup of the element carrying `slot`, up to its matching close. */
function slot(html: string, name: string): string {
const start = html.indexOf(`data-slot="${name}"`);
if (start < 0) return "";
const open = html.lastIndexOf("<", start);
const tag = html.slice(open + 1).match(/^[a-z]+/)![0];
let depth = 0;
const re = new RegExp(`<${tag}[\\s>]|</${tag}>`, "g");
re.lastIndex = open;
for (let m = re.exec(html); m; m = re.exec(html)) {
depth += m[0].startsWith("</") ? -1 : 1;
if (depth === 0) return html.slice(open, m.index + m[0].length);
}
return html.slice(open);
}
const CHIPS: CoachFollowUp[] = [
{
id: "f1",
kind: "previous_period",
labelKey: "coach.followUp.previousPeriod",
label: "Compare with the period before",
reuse: false,
origin: "server",
},
];
const ANSWER = {
role: "assistant" as const,
content: "Your blood pressure held steady.",
metricSource: null,
providerType: "openai",
messageId: "m2",
createdAt: new Date().toISOString(),
tokensUsed: 1234,
model: "gpt-4o-mini",
onRegenerate: () => {},
};
describe("assistant action row", () => {
const html = render(<ChatBubble {...ANSWER} />);
const row = slot(html, "coach-answer-actions");
const left = slot(row, "coach-answer-actions-left");
it("lists copy, read aloud, try again and the time on the left, in that order", () => {
const order = [
"coach-copy-message",
"coach-read-aloud",
"coach-try-again",
"coach-message-time",
].map((name) => left.indexOf(`data-slot="${name}"`));
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it("puts the tokens and the model on the right of the same row", () => {
const meta = slot(row, "coach-answer-meta");
expect(meta).toContain("1,234 tokens · gpt-4o-mini");
expect(meta).toContain('title="gpt-4o-mini"');
expect(meta).toContain("truncate");
expect(left).not.toContain("coach-answer-meta");
expect(row).toContain("justify-between");
});
it("has no thumbs, no evidence disclosure and no separate token line", () => {
expect(html).not.toContain("coach-message-feedback");
expect(html).not.toContain("coach-evidence");
expect(html).not.toContain("coach-token-footer");
});
it("shows the time as text, the full date for screen readers", () => {
const time = slot(left, "coach-message-time");
expect(time).toMatch(/^<time[^>]*dateTime="/);
expect(time).toContain("Message sent");
expect(time).toMatch(/<span aria-hidden="true">[^<]*\d:\d{2}[^<]*<\/span>/);
});
it("keeps an empty right group when the answer has no count", () => {
const bare = render(
<ChatBubble {...ANSWER} tokensUsed={null} model={null} />,
);
expect(slot(bare, "coach-answer-meta")).toMatch(
/<p data-slot="coach-answer-meta"[^>]*><\/p>/,
);
});
it("is absent on a refusal and while the answer is in flight", () => {
expect(
render(<ChatBubble {...ANSWER} providerType="refusal" />),
).not.toContain("coach-answer-actions");
expect(
render(<ChatBubble {...ANSWER} inProgress streaming />),
).not.toContain("coach-answer-actions");
});
});
describe("user action row", () => {
const html = render(
<ChatBubble
role="user"
content="I am allergic to peanuts"
messageId="m1"
createdAt={new Date().toISOString()}
/>,
);
const row = slot(html, "coach-user-actions");
it("holds copy, remember and the time in one row, flush right", () => {
const order = [
"coach-copy-message",
"coach-remember-message",
"coach-message-time",
].map((name) => row.indexOf(`data-slot="${name}"`));
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(row).toContain("justify-end");
});
it("renders remember as a labelled icon button, not a text button", () => {
const button = slot(row, "coach-remember-message");
expect(button).toContain('aria-label="Remember"');
expect(button).toContain('data-state="idle"');
expect(button).toContain("<svg");
expect(button).not.toMatch(/>Remember</);
});
});
describe("follow-up chips in the answer column", () => {
const offer = { messageId: "m2", followUps: CHIPS };
it("render inside the column, after the action row", () => {
const html = render(
<ChatBubble {...ANSWER} followUps={offer} onFollowUp={() => {}} />,
);
const column = slot(html, "coach-answer-column");
const actions = column.indexOf('data-slot="coach-answer-actions"');
const chips = column.indexOf('data-slot="coach-follow-up-chips"');
expect(actions).toBeGreaterThan(-1);
expect(chips).toBeGreaterThan(actions);
// The answer's own text size, not the button's default.
expect(slot(column, "coach-follow-up-chips")).toMatch(
/class="[^"]*\btext-sm\b[^"]*font-normal/,
);
});
it("step aside while another turn runs", () => {
const html = render(
<ChatBubble
{...ANSWER}
followUps={offer}
followUpsDisabled
onFollowUp={() => {}}
/>,
);
expect(html).not.toContain("coach-follow-up-chips");
});
it("re-render the memoised bubble when the offer changes, not when only its wrapper does", () => {
const onFollowUp = () => {};
const base = { ...ANSWER, onFollowUp };
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: { ...offer } },
),
).toBe(true);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: null },
{ ...base, followUps: offer },
),
).toBe(false);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: { ...offer, followUps: [...CHIPS] } },
),
).toBe(false);
expect(
areChatBubblePropsEqual(
{ ...base, followUps: offer },
{ ...base, followUps: offer, followUpsDisabled: true },
),
).toBe(false);
});
});
describe("messageTimeText", () => {
const { t } = getServerTranslator("en");
// Day keys from the ISO date, the clock from its time: enough to pin the
// three tiers without a zone.
const formatters = {
dateShortSmart: (v: string | number | Date) =>
new Date(v).toISOString().slice(0, 10),
time: (v: string | number | Date) =>
new Date(v).toISOString().slice(11, 16),
};
const now = Date.parse("2026-10-04T12:00:00.000Z");
it("is the clock alone today", () => {
expect(
messageTimeText("2026-10-04T08:15:00.000Z", now, formatters, t),
).toBe("08:15");
});
it("says yesterday the day before", () => {
expect(
messageTimeText("2026-10-03T21:40:00.000Z", now, formatters, t),
).toBe("yesterday 21:40");
});
it("adds the date further back", () => {
expect(
messageTimeText("2026-09-28T07:05:00.000Z", now, formatters, t),
).toBe("2026-09-28, 07:05");
});
});
describe("answerMetaText", () => {
const { t } = getServerTranslator("en");
it("formats the count for the locale and adds the model", () => {
expect(answerMetaText(1234, "gpt-4o", "en-US", t)).toBe(
"1,234 tokens · gpt-4o",
);
expect(answerMetaText(1234, null, "de-DE", t)).toBe("1.234 tokens");
});
it("is nothing without a count", () => {
expect(answerMetaText(null, "gpt-4o", "en-US", t)).toBeNull();
});
});
@@ -154,7 +154,7 @@ const baseConversation: CoachConversationDetailDTO = {
};
describe("<MessageThread>", () => {
it("dates a table copied from an earlier answer by that answer, and widens the reply for it", () => {
it("dates a table copied from an earlier answer by that answer, in the full-width answer column", () => {
const reused: CoachResultTable = {
ref: "r1",
source: {
@@ -211,7 +211,7 @@ describe("<MessageThread>", () => {
// m2 was written on 10 May; the copy in m3 names that day.
expect(html).toMatch(/From an earlier answer \([^)]*10[^)]*\)/);
expect(html).toMatch(
/<div class="flex max-w-\[calc\(80%-2\.625rem\)\] flex-col gap-2 w-full">/,
/data-slot="coach-answer-column" class="[^"]*\bw-full\b[^"]*"/,
);
} finally {
storedResults.value = undefined;
@@ -248,12 +248,13 @@ describe("<MessageThread>", () => {
);
});
it("renders source chips below an assistant bubble that carries provenance", () => {
it("folds an older message's provenance into the areas header, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).toContain('data-metric="bp"');
expect(html).toContain("last 30 days");
expect(html).toContain("n=26");
expect(html).toContain('data-slot="coach-turn-steps-done"');
expect(html).toContain("Looked at 1 area");
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain("insights.coach.metric.");
expect(html).not.toContain("n=26");
});
it("renders an in-flight streaming bubble alongside persisted history", () => {
@@ -447,11 +448,7 @@ describe("<MessageThread>", () => {
);
});
it("renders the evidence-block disclosure when keyValues is non-empty (EN)", () => {
// v1.4.22 — the Coach surfaces load-bearing numbers in a
// collapsible "What I'm looking at" disclosure under the assistant
// bubble. Verify the structure renders correctly and the entries
// show label / value / unit / window.
it("shows no evidence disclosure and no raw key values under an answer", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
@@ -468,63 +465,30 @@ describe("<MessageThread>", () => {
unit: "mmHg",
window: "last7days",
},
{
label: "avg30 systolic",
value: "134",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain("What I&#x27;m looking at");
expect(html).toContain('data-slot="coach-evidence-list"');
const rows = (html.match(/data-slot="coach-evidence-row"/g) ?? []).length;
expect(rows).toBe(2);
// v1.4.25 W5 — per-row source labels were dropped. The values +
// units + window framing stay; the redundant `kv.label` prefix
// (e.g. "avg7 systolic:") does not appear.
expect(html).not.toContain("avg7 systolic");
expect(html).not.toContain("avg30 systolic");
expect(html).toContain("138 mmHg");
expect(html).toContain("(last7days)");
expect(html).toContain("134 mmHg");
// Disclosure is collapsed by default (no `open` attribute).
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain("138 mmHg");
// The areas the answer drew on stay reachable from the header.
expect(html).toContain("Looked at 1 area");
});
it("renders the evidence-block label in German under 'de' locale", () => {
const withKeyValues: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
{
...baseConversation.messages[1],
metricSource: {
windows: ["last30days"],
metrics: ["bp"],
keyValues: [
{
label: "avg30 systolisch",
value: "138",
unit: "mmHg",
window: "last30days",
},
],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />, "de");
expect(html).toContain("Worauf bezieht sich das?");
it("names the areas header in German under 'de'", () => {
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain("1 Bereich angesehen");
expect(html).not.toContain("Worauf bezieht sich das?");
});
it("renders entries without unit or window cleanly", () => {
const withKeyValues: CoachConversationDetailDTO = {
it("leaves out a metric token the bundle cannot name", () => {
const unknown: CoachConversationDetailDTO = {
...baseConversation,
messages: [
baseConversation.messages[0],
@@ -532,38 +496,15 @@ describe("<MessageThread>", () => {
...baseConversation.messages[1],
metricSource: {
windows: [],
metrics: ["compliance"],
keyValues: [{ label: "30-day adherence", value: "96" }],
// A token an older seed wrote; it names nothing.
metrics: ["bloodPressure" as never, "general"],
},
},
],
};
const html = render(<MessageThread conversation={withKeyValues} />);
// v1.4.25 W5 — `kv.label` no longer renders; only the value
// bubbles up. The row still mounts so the value is visible.
expect(html).not.toContain("30-day adherence");
expect(html).toContain('data-slot="coach-evidence-row"');
expect(html).toMatch(/<strong[^>]*>\s*96\s*<\/strong>/);
});
it("folds the source chips into the collapsed disclosure (no key-values)", () => {
// v1.12.0 — the provenance block is now a single collapsed
// disclosure. baseConversation.messages[1].metricSource carries
// metrics + windows but no `keyValues`, so the disclosure renders
// (collapsed, with the chips inside) but the key-value list does
// not. The chips no longer paint outside / above the disclosure.
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-evidence"');
expect(html).toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-evidence-list"');
// Collapsed by default.
expect(html).not.toMatch(/<details[^>]*\bopen\b/);
// The chips render inside the `<details>` shell, not as a sibling
// above it.
const detailsIdx = html.indexOf('data-slot="coach-evidence"');
const chipsIdx = html.indexOf('data-slot="coach-source-chips"');
expect(detailsIdx).toBeGreaterThanOrEqual(0);
expect(chipsIdx).toBeGreaterThan(detailsIdx);
const html = render(<MessageThread conversation={unknown} />);
expect(html).not.toContain('data-slot="coach-turn-steps"');
expect(html).not.toContain("bloodPressure");
});
it("renders no disclosure when there is no provenance at all", () => {
@@ -579,7 +520,7 @@ describe("<MessageThread>", () => {
};
const html = render(<MessageThread conversation={noProvenance} />);
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain('data-slot="coach-turn-steps"');
});
it("renders the user bubble with the self-hosted avatar when one is set (v1.5.5)", () => {
@@ -689,40 +630,53 @@ describe("<MessageThread>", () => {
expect(html).not.toContain('data-slot="coach-remember-message"');
});
it("reveals the remember control on bubble hover/focus at sm+, keeps it visible on touch", () => {
it("reveals the user action row on bubble hover/focus at sm+, keeps it visible on touch", () => {
const html = render(<MessageThread conversation={baseConversation} />);
const button = html.match(
/<button[^>]*data-slot="coach-remember-message"[^>]*>/,
const row = html.match(
/<div[^>]*data-slot="coach-user-actions"[^>]*>/,
)?.[0];
expect(button).toBeTruthy();
expect(row).toBeTruthy();
// Hidden only behind BOTH gates — `sm:` viewport AND a
// hover-capable pointer — so touch devices (no hover media) keep
// the control always visible.
expect(button).toContain("sm:[@media(hover:hover)]:opacity-0");
expect(button).toContain(
// the row always visible.
expect(row).toContain("sm:[@media(hover:hover)]:opacity-0");
expect(row).toContain(
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
);
expect(button).toContain(
expect(row).toContain(
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
);
// The bubble column is the named hover/focus group.
expect(html).toContain("group/user-bubble");
});
it("hands focus to the settled confirmation instead of dropping it to body", () => {
// The settled branch unmounts the button the user just activated;
// the status paragraph takes the focus (`tabIndex={-1}` +
// programmatic focus on settle). SSR cannot exercise the focus
// call, so the wiring is pinned structurally.
it("puts remember in the user's action row, not on a line of its own", () => {
const html = render(<MessageThread conversation={baseConversation} />);
const row = html.match(
/<div[^>]*data-slot="coach-user-actions"[^>]*>([\s\S]*?)<\/div>/,
)?.[1];
expect(row).toBeTruthy();
const copy = row!.indexOf('data-slot="coach-remember-message"');
const time = row!.indexOf('data-slot="coach-message-time"');
expect(copy).toBeGreaterThan(-1);
expect(time).toBeGreaterThan(copy);
expect(html).not.toContain('data-slot="coach-remember-message-done"');
});
it("keeps the remember button mounted once it has settled, so focus stays on it", () => {
// A disabled or unmounted button drops keyboard focus to <body>; the
// settled button only turns inert. SSR cannot click, so the wiring is
// pinned structurally.
const src = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/chat-bubble.tsx",
"src/components/insights/coach-panel/message-actions.tsx",
),
"utf8",
);
expect(src).toContain("statusRef.current?.focus()");
expect(src).toMatch(/ref=\{statusRef\}\s*\n\s*tabIndex=\{-1\}/);
expect(src).toContain("aria-disabled={inert || undefined}");
expect(src).not.toMatch(
/data-slot="coach-remember-message"[\s\S]{0,400}(?<![-\w])disabled=\{/,
);
});
// v1.4.25 W5 — distinct daily-limit vs provider-rate-limit copy.
@@ -0,0 +1,64 @@
/**
* The jump-to-latest button: invisible and out of reach at the end of the
* thread, visible and reachable away from it, and without a transition
* under reduced motion. The thread drives `visible` from its scroll state.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { ScrollToBottomButton } from "../scroll-to-bottom-button";
function render(visible: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ScrollToBottomButton visible={visible} onClick={() => {}} />
</I18nProvider>,
);
}
describe("<ScrollToBottomButton>", () => {
it("is hidden, inert and out of the tab order at the end", () => {
const html = render(false);
expect(html).toContain('data-visible="false"');
expect(html).toContain('aria-hidden="true"');
expect(html).toContain('tabindex="-1"');
expect(html).toMatch(/class="[^"]*\bopacity-0\b/);
expect(html).toMatch(/class="[^"]*\spointer-events-none\s/);
});
it("is visible, labelled and focusable away from the end", () => {
const html = render(true);
const button = html.match(/<button[^>]*>/)![0];
expect(button).toContain('data-visible="true"');
expect(button).toContain('aria-label="Jump to the latest message"');
expect(button).not.toContain("aria-hidden");
expect(button).not.toContain("tabindex");
expect(html).toMatch(/class="[^"]*\bopacity-100\b/);
expect(button).not.toMatch(/[\s"]pointer-events-none[\s"]/);
});
it("fades without motion when the reader asks for less", () => {
expect(render(true)).toContain("motion-reduce:transition-none");
});
it("is wired to the thread's pinned state and returns focus to the composer", () => {
// SSR cannot scroll; the wiring is pinned structurally.
const src = readFileSync(
join(
process.cwd(),
"src/components/insights/coach-panel/message-thread.tsx",
),
"utf8",
);
expect(src).toContain(
"<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />",
);
expect(src).toMatch(
/scrollToLatest = useCallback\([\s\S]*?wasPinnedRef\.current = true;[\s\S]*?focusCoachComposer\(\);/,
);
});
});
@@ -1,120 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SourceChips } from "../source-chips";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function classesForSlot(html: string, slot: string): string[] {
const elements =
html.match(new RegExp(`<[^>]+data-slot="${slot}"[^>]*>`, "g")) ?? [];
return elements.map(
(element) => element.match(/\bclass="([^"]*)"/)?.[1] ?? "",
);
}
describe("<SourceChips>", () => {
it("renders nothing when provenance is null", () => {
const html = render(<SourceChips provenance={null} />);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders nothing when both windows + metrics are empty", () => {
const html = render(
<SourceChips provenance={{ windows: [], metrics: [] }} />,
);
expect(html).not.toContain('data-slot="coach-source-chips"');
});
it("renders one chip per metric, paired with the primary window", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last30days"],
metrics: ["bp", "pulse"],
counts: { bp: 12, pulse: 14 },
}}
/>,
);
expect(html).toContain('data-slot="coach-source-chips"');
const matches = html.match(/data-slot="coach-source-chip"/g) ?? [];
expect(matches.length).toBe(2);
expect(html).toContain('data-metric="bp"');
expect(html).toContain('data-metric="pulse"');
expect(html).toContain("Blood pressure");
expect(html).toContain("Pulse");
expect(html).toContain("last 30 days");
});
it("renders the sample-count suffix when counts are supplied", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
expect(html).toContain("n=7");
});
it("uses full semantic text color on source labels", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 7 },
}}
/>,
);
const classNames = [
...classesForSlot(html, "coach-source-chip"),
...classesForSlot(html, "coach-source-window"),
...classesForSlot(html, "coach-source-count"),
];
expect(classNames).toHaveLength(3);
for (const className of classNames) {
expect(className.split(/\s+/)).toContain("text-info");
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
}
});
it("does not render n=0 chips for zero counts", () => {
const html = render(
<SourceChips
provenance={{
windows: ["last7days"],
metrics: ["bp"],
counts: { bp: 0 },
}}
/>,
);
expect(html).not.toContain("n=0");
});
it("uses German labels when locale is 'de'", () => {
const html = render(
<SourceChips provenance={{ windows: ["last30days"], metrics: ["bp"] }} />,
"de",
);
expect(html).toContain("Blutdruck");
expect(html).toContain("letzte 30 Tage");
});
it("renders a metric chip without window when windows are empty", () => {
const html = render(
<SourceChips provenance={{ windows: [], metrics: ["general"] }} />,
);
expect(html).toContain('data-metric="general"');
expect(html).toContain("General");
expect(html).not.toContain("last 30 days");
});
});
@@ -10,7 +10,9 @@ import type { CoachStep } from "@/lib/ai/coach/types";
import {
CoachTurnStepList,
CoachTurnSteps,
CoachTurnStepsPanel,
countSources,
legacyAreaLabels,
currentStep,
describeStep,
nextAnnouncement,
@@ -264,3 +266,86 @@ describe("<CoachTurnStepList>", () => {
expect(text(stored)).toBe(text(live));
});
});
describe("the open list: method and the tables the answer read", () => {
const METHOD = {
entries: [],
text: "Blood pressure, last 30 days, 9 readings, daily averages.",
};
it("ends with the method line and the data-used slot, after the steps", () => {
const html = render(
<CoachTurnStepsPanel
steps={[BP]}
active={false}
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used">tables</div>}
/>,
);
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(
<CoachTurnStepsPanel
steps={[LABS_RUNNING]}
active
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used" />}
/>,
);
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(
<CoachTurnStepsPanel
steps={[]}
active={false}
areaLabels={["Blood pressure", "Sleep"]}
/>,
);
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(
<CoachTurnSteps steps={[]} active={false} method={METHOD} />,
);
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(
<CoachTurnSteps steps={[]} active={false} areas={["bp", "sleep"]} />,
);
expect(html).toContain("Looked at 2 areas");
expect(
render(<CoachTurnSteps steps={[]} active={false} areas={["bp"]} />, "de"),
).toContain("1 Bereich angesehen");
});
});
@@ -1,63 +1,39 @@
"use client";
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
import { memo } from "react";
import Link from "next/link";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkPlus,
Bot,
Check,
ChevronRight,
Clock,
Copy,
Info,
Loader2,
RotateCcw,
Sparkles,
ThumbsDown,
ThumbsUp,
User,
} from "lucide-react";
import { Bot, Info, RotateCcw, Sparkles, User } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import {
parseChartTokens,
stripChartTokens,
tokenToMetric,
type ChartToken,
} from "@/lib/insights/chart-tokens";
import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic";
import { ProseBlocks } from "@/components/insights/prose-blocks";
import { SourceChips } from "./source-chips";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { MessageTokenFooter } from "./message-token-footer";
import { CoachTurnSteps } from "./turn-steps";
import { CoachResults, countResultsInSection } from "./coach-results";
import { CoachMethodLine } from "./method-line";
import {
COACH_ICON_BUTTON,
ReadAloudButton,
useClipboardSupported,
} from "./read-aloud";
import type { CoachProvenanceMetric } from "@/lib/ai/coach/types";
import { CoachFollowUpChips } from "./follow-up-chips";
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
import type {
CoachFollowUp,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
/**
* Per-message bubble renderer for the Coach thread, split out of
* `message-thread.tsx` (v1.28.26 file-size split — pure code motion, no
* behaviour change). Owns the user + assistant `ChatBubble` (evidence
* disclosure, accompanying charts, action row), the per-message action
* buttons (copy / feedback / try-again / timestamp / remember), the
* typing indicator, the chart-token grounding selector, and the
* `message-thread.tsx` (v1.28.26 file-size split). Owns the user + assistant
* `ChatBubble` (steps, accompanying charts, the action row from
* `message-actions.tsx`, and the follow-up chips under the latest answer),
* the typing indicator, the chart-token grounding selector, and the
* error-code → i18n resolver. `message-thread.tsx` re-exports the
* previously public names so external call sites are unchanged.
*/
@@ -263,122 +239,6 @@ function coachChartTitle(
return resolved === key ? metric.replace(/_/g, " ").toLowerCase() : resolved;
}
/**
* v1.22 — copy a message to the clipboard. Assistant prose is copied through
* `stripChartTokens` (the same text the bubble shows) so inline chart tokens
* are never pasted; user text is verbatim. A brief check-mark + toast confirm
* the copy. Hidden when the Clipboard API is unavailable (insecure context).
*/
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const supported = useClipboardSupported();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
// Absent on insecure-context (plain-HTTP) self-hosts where the Clipboard API
// is undefined — render nothing rather than error-toast on tap.
if (!supported) return null;
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-3.5" aria-hidden="true" />
) : (
<Copy className="size-3.5" aria-hidden="true" />
)}
</button>
);
}
/**
* v1.22 — "Try again": re-run the user turn that produced this assistant
* reply so the user can regenerate an unsatisfying answer. The thread hands
* down the preceding user message; the surface resubmits it as a fresh turn.
*/
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-3.5" aria-hidden="true" />
</button>
);
}
/**
* v1.22 (W5) — hover/tap timestamp for a persisted message bubble. A small,
* calm clock affordance; the locale-aware date + time surfaces in a tooltip
* on hover or keyboard focus (desktop) and on tap (mobile, toggled state).
* Pure CSS + one boolean — no portal, no Radix dependency, SSR-safe.
*/
function BubbleTimestamp({
iso,
align = "start",
}: {
iso: string;
align?: "start" | "end";
}) {
const { t } = useTranslations();
const formatters = useFormatters();
const [open, setOpen] = useState(false);
const label = formatters.dateTime(iso);
return (
<span className="relative inline-flex shrink-0">
<button
type="button"
data-slot="coach-bubble-timestamp"
aria-label={t("insights.coach.messageTimeLabel", { time: label })}
onClick={() => setOpen((o) => !o)}
onBlur={() => setOpen(false)}
className="peer text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded outline-none focus-visible:ring-2 sm:size-8"
>
<Clock className="size-3" aria-hidden="true" />
</button>
<span
role="tooltip"
className={cn(
"bg-popover text-popover-foreground border-border pointer-events-none absolute bottom-full z-50 mb-1",
"rounded-md border px-2 py-1 text-xs whitespace-nowrap shadow-md",
"opacity-0 transition-opacity duration-100 peer-hover:opacity-100 peer-focus-visible:opacity-100",
"motion-reduce:transition-none",
open && "opacity-100",
align === "end" ? "right-0" : "left-0",
)}
>
{label}
</span>
</span>
);
}
interface ChatBubbleProps {
role: "user" | "assistant";
content: string;
@@ -447,6 +307,29 @@ interface ChatBubbleProps {
* / refusal turns and when the surface supplies no regenerate handler.
*/
onRegenerate?: () => void;
/**
* The follow-up chips, handed only to the latest assistant turn: the
* offering message's id and its chips. They render in the answer's own
* column, after the action row.
*/
followUps?: FollowUpOffer | null;
/** True while another turn is in flight; the chips step aside. */
followUpsDisabled?: boolean;
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
}
/** The chips one assistant message offers. */
export interface FollowUpOffer {
messageId: string;
followUps: CoachFollowUp[];
}
function sameFollowUpOffer(
a: FollowUpOffer | null | undefined,
b: FollowUpOffer | null | undefined,
): boolean {
if (!a || !b) return !a === !b;
return a.messageId === b.messageId && a.followUps === b.followUps;
}
/**
@@ -465,6 +348,11 @@ interface ChatBubbleProps {
* thread), so it is compared by PRESENCE, not identity. Result: only the
* streaming bubble (whose `content`/`streaming` actually change) re-renders.
*
* The follow-up offer is compared by its message id and chip array (the
* thread builds a fresh wrapper object per render, the array itself is the
* provenance's or the stream's and stays put), and `onFollowUp` by identity:
* without these the memoised bubble would keep showing stale chips.
*
* Exported so the contract is unit-testable without standing up the thread.
*/
export function areChatBubblePropsEqual(
@@ -489,6 +377,9 @@ export function areChatBubblePropsEqual(
prev.results === next.results &&
prev.conversationId === next.conversationId &&
prev.usage === next.usage &&
sameFollowUpOffer(prev.followUps, next.followUps) &&
prev.followUpsDisabled === next.followUpsDisabled &&
prev.onFollowUp === next.onFollowUp &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
);
@@ -513,19 +404,12 @@ function ChatBubbleImpl({
model,
createdAt,
onRegenerate,
followUps,
followUpsDisabled,
onFollowUp,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
// v1.4.27 B7 / L3 — pair the evidence `<details>` and its disclosed
// list explicitly so screen-readers announce the panel relationship.
const evidencePanelId = useId();
// v1.4.27 MB3 / CF-32 — track the disclosure state in React so the
// summary can carry an accurate `aria-expanded`. Native `<details>`
// reflects its open state via the `open` attribute, but that does
// not surface as `aria-expanded` on the summary by default; screen
// readers still need the explicit attribute to announce the panel
// as expanded vs collapsed.
const [evidenceOpen, setEvidenceOpen] = useState(false);
if (role === "user") {
// v1.5.5 — pull the user's self-hosted avatar so the user
// bubble matches the Coach avatar in size and visual weight.
@@ -545,8 +429,7 @@ function ChatBubbleImpl({
// Budget the avatar column (size-8 + gap-2.5 ≈ 2.625rem) out
// of the 80% cap so the bubble + avatar together never
// overflow a comfortable width on a narrow phone.
// `group/user-bubble` scopes the remember control's
// hover/focus reveal (see `RememberUserMessage`).
// `group/user-bubble` scopes the action row's hover/focus reveal.
"group/user-bubble flex max-w-[calc(80%-2.625rem)] flex-col items-end gap-1",
)}
>
@@ -562,33 +445,14 @@ function ChatBubbleImpl({
is verbatim: no chart-token strip, no Learn linkify. */}
<ProseBlocks text={content} strip={false} linkify={false} />
</div>
{/* v1.22 — per-message action row: Copy, then the timestamp
trailing. Muted until the bubble is hovered / focused on pointer
devices; always visible on touch (no hover to reveal it). */}
<div
data-slot="coach-bubble-actions"
className={cn(
"flex items-center justify-end gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{createdAt && <BubbleTimestamp iso={createdAt} align="end" />}
</div>
{/* v1.16.8 — explicit remember control. Stating an allergy in
chat used to leave no durable trace unless a narrow
pattern pass happened to match the phrasing; this stores
the message into the editable self-context (Settings → AI)
on one tap, so it rides every future system prompt. Only
persisted messages get the control (an optimistic bubble
has no id yet), and only when the text fits the
self-context field cap. */}
{messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS && (
<RememberUserMessage content={content} />
)}
{/* One row under the question: copy, remember, time. Remember
stores the message in the self-context (Settings → AI); only a
persisted message that fits that field offers it. */}
<UserMessageActions
content={content}
messageId={messageId}
createdAt={createdAt}
/>
</div>
{avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
@@ -671,18 +535,7 @@ function ChatBubbleImpl({
? t("insights.coach.errorProvider")
: null;
const keyValues = metricSource?.keyValues ?? [];
// v1.12.0 — the provenance disclosure surfaces whenever there is any
// grounding to show: the source chips (metrics/windows) and/or the
// raw key-values. `SourceChips` itself returns null when the envelope
// carries neither metric nor window, so we mirror that condition here
// to decide whether the `<details>` shell renders at all.
const hasChips =
!!metricSource &&
((metricSource.metrics?.length ?? 0) > 0 ||
(metricSource.windows?.length ?? 0) > 0);
const method = metricSource?.method ?? null;
const hasEvidence = hasChips || keyValues.length > 0 || method !== null;
// v1.22 (W5) — Coach charts Phase 1. Render an allowlisted, provenance-
// grounded `metric:<TYPE>` chart under a SETTLED assistant turn. Skipped
@@ -696,7 +549,6 @@ function ChatBubbleImpl({
.filter((meta) => meta.displayed)
.map((meta) => meta.source.domain),
);
const hasResults = !inProgress && !errorCode && resultMetas.length > 0;
const dataUsedCount =
!inProgress && !errorCode
? countResultsInSection(resultMetas, "dataUsed")
@@ -705,7 +557,7 @@ function ChatBubbleImpl({
!streaming && !inProgress && !errorCode && providerType !== "refusal"
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
: [];
const hasProvenance = hasEvidence || dataUsedCount > 0;
const settled = !inProgress && !errorCode && providerType !== "refusal";
// 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
@@ -734,20 +586,36 @@ function ChatBubbleImpl({
<Sparkles className="text-background size-3.5" />
)}
</div>
{/* A reply with tables takes the column's full width, so a table or
chart is as wide as the thread allows instead of as wide as the
prose above it. */}
{/* The answer column. Below `sm` it takes the full width beside the
avatar (at 80 % the action row could not stay on one line at
390 px); from `sm` it keeps the 80 % measure. It is always that
wide, whatever the answer's length, so the action row and the
chips never move sideways as a reply streams in; the prose bubble
inside shrink-wraps. A table or chart takes the column's width. */}
<div
className={cn(
"flex max-w-[calc(80%-2.625rem)] flex-col gap-2",
hasResults && "w-full",
)}
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)]"
>
{/* v1.39.4 — what the Coach read on this turn: live from the step
frames, restored from the provenance on reload. */}
{/* 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. */}
<CoachTurnSteps
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
dataUsed={
dataUsedCount > 0 ? (
<CoachResults
conversationId={conversationId ?? null}
messageId={messageId ?? null}
metas={metricSource?.results ?? []}
live={results}
section="dataUsed"
/>
) : null
}
/>
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
@@ -760,8 +628,9 @@ function ChatBubbleImpl({
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
<div
data-slot="coach-answer-bubble"
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"border-border/60 bg-muted/40 text-foreground max-w-full",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed break-words whitespace-pre-wrap",
)}
@@ -850,114 +719,6 @@ function ChatBubbleImpl({
{t("insights.coach.errorNoProviderAction")}
</Link>
)}
{/* v1.12.0 — collapse the whole provenance block ("what was
included") behind one disclosure, collapsed by default. The
source chips used to render fully expanded above the
evidence `<details>`, so the grounding context was often
taller than the answer itself. Folding the chips + the raw
key-values into a single closed disclosure keeps the reply
the focus and lets the user expand the grounding on demand —
and removes the always-on chip row that duplicated what the
disclosure already names. The disclosure surfaces whenever
there is any provenance to show (chips and/or key-values). */}
{hasProvenance && (
<details
data-slot="coach-evidence"
open={evidenceOpen}
onToggle={(e) =>
setEvidenceOpen((e.target as HTMLDetailsElement).open)
}
// v1.4.27 F14 — always closed by default. The `open`
// attribute was previously tied to a per-user pref that
// surfaced raw values unconditionally; that pref is now
// retired and the disclosure is a true progressive-
// disclosure surface — the user clicks to expand.
//
// v1.4.27 MB3 / CF-32 — the `open` attribute is now
// controlled from local state so the summary's
// `aria-expanded` stays in lock-step. The native disclosure
// semantics (Enter / Space toggle) are preserved.
className={cn(
"border-border/50 bg-muted/30 group rounded-md border",
"px-2.5 py-1.5 text-xs",
)}
>
<summary
data-slot="coach-evidence-summary"
aria-controls={evidencePanelId}
aria-expanded={evidenceOpen}
className={cn(
"text-muted-foreground hover:text-foreground flex cursor-pointer",
"items-center gap-1.5 leading-relaxed",
"marker:hidden [&::-webkit-details-marker]:hidden",
"focus-visible:ring-ring/50 rounded outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className="size-3 transition-transform group-open:rotate-90"
/>
<span>{t("insights.coach.evidenceLabel")}</span>
</summary>
<div
id={evidencePanelId}
data-slot="coach-evidence-panel"
className="mt-2 flex flex-col gap-2"
>
{/* v1.12.0 — the source chips now live inside the
disclosure so they expand with the rest of the
grounding instead of always painting above the
answer. */}
{hasChips && metricSource && (
<SourceChips provenance={metricSource} />
)}
{keyValues.length > 0 && (
<ul
data-slot="coach-evidence-list"
className="text-foreground flex flex-col gap-1"
>
{keyValues.map((kv, idx) => (
<li
key={`${kv.label}-${idx}`}
data-slot="coach-evidence-row"
className="leading-relaxed"
>
{/* v1.4.25 W5 — `kv.label` (e.g. "avg7 systolic")
was rendered prefixed to every row, repeating
framing the disclosure heading already gives.
Drop the label and lead with the value; the
window stays as a parenthetical tail so the row
still answers "over what timeframe?". */}
<strong className="font-semibold">
{kv.value}
{kv.unit ? ` ${kv.unit}` : ""}
</strong>
{kv.window && (
<span className="text-muted-foreground">
{" "}
({kv.window})
</span>
)}
</li>
))}
</ul>
)}
{/* v1.39.4 — how the answer was worked out, beside the key
values it explains. */}
<CoachMethodLine method={method} />
{/* v1.39.4 — the tables the answer read but did not point at. */}
{dataUsedCount > 0 && (
<CoachResults
conversationId={conversationId ?? null}
messageId={messageId ?? null}
metas={metricSource?.results ?? []}
live={results}
section="dataUsed"
/>
)}
</div>
</details>
)}
{/* v1.18.1 (Workstream C) — one-tap cadence-suggestion action
card. Live from the streaming hook, or restored from the
persisted message provenance on reload. Not shown on
@@ -978,42 +739,29 @@ function ChatBubbleImpl({
suggestedAction ?? metricSource?.suggestedAction ?? null;
return action ? <SuggestedActionCard action={action} /> : null;
})()}
{/* v1.18.9 — quiet per-message token footer. The just-finished
streaming turn reads the `done.usage` envelope; a persisted /
reloaded turn reads the message's own `tokensUsed` + `model`.
Skipped on in-flight, errored, and refusal turns. */}
{!inProgress && !errorCode && providerType !== "refusal" && (
<MessageTokenFooter
{/* One row under the answer: copy, read aloud, try again and the
time on the left; the tokens and model it cost on the right.
Only on a settled reply (not while it streams, not on an error
or a refusal). */}
{settled && content && (
<AssistantMessageActions
content={content}
streaming={!!streaming}
createdAt={createdAt}
onRegenerate={onRegenerate}
tokens={usage?.totalTokens ?? tokensUsed}
model={usage?.model ?? model}
/>
)}
{/* v1.22 — per-message hover action row. Icon-only, muted until the
bubble is hovered / focused on pointer devices; always visible on
touch (no hover to reveal it). Copy + Read-aloud + Good / Bad
feedback + Try-again, with the timestamp trailing. Only settled
persisted assistant turns get the row (skipped for refusals,
errors, in-flight stream bubbles). */}
{!inProgress && !errorCode && providerType !== "refusal" && content && (
<div
data-slot="coach-bubble-actions"
className={cn(
"flex flex-wrap items-center gap-0.5",
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{messageId && <CoachMessageFeedback messageId={messageId} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <BubbleTimestamp iso={createdAt} />}
</div>
)}
{/* The follow-up chips of the latest answer, in its own column. */}
{followUps && onFollowUp ? (
<CoachFollowUpChips
followUps={followUps.followUps}
messageId={followUps.messageId}
disabled={!!followUpsDisabled}
onSelect={onFollowUp}
/>
) : null}
</div>
</div>
);
@@ -1028,107 +776,6 @@ function ChatBubbleImpl({
export const ChatBubble = memo(ChatBubbleImpl, areChatBubblePropsEqual);
ChatBubble.displayName = "ChatBubble";
/**
* v1.16.8 — per-message remember control under the user bubble.
*
* One tap stores the message text into the structured self-context via
* `POST /api/coach/about-me/adopt` (no `question` — the server matches
* the target field from the text itself: an allergy statement lands on
* the allergies field, a stated condition on conditions, everything
* else on the coach-focus slot). The stored text is visible and
* editable under Settings → AI and rides every future Coach system
* prompt. Settled states mirror `SelfContextAdoptOffer`: a short
* confirmation replaces the button after an adoption or a server-side
* dedupe; a failure surfaces a toast and the button stays tappable.
*/
function RememberUserMessage({ content }: { content: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
// On settle the button unmounts while it holds focus, which would
// drop keyboard focus to <body>. The confirmation paragraph takes
// the focus instead (`tabIndex={-1}` + programmatic focus) so the
// reading position survives the swap.
const statusRef = useRef<HTMLParagraphElement | null>(null);
useEffect(() => {
if (settled) statusRef.current?.focus();
}, [settled]);
const remember = useMutation({
mutationFn: async () => {
return apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
});
},
onSuccess: (data) => {
if (data.adopted) {
// The adoption changed the stored self-context — refresh the
// Settings → AI about-me read so it shows the new entry.
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
}
setSettled(data.adopted ? "adopted" : "duplicate");
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
if (settled) {
return (
<p
role="status"
ref={statusRef}
tabIndex={-1}
data-slot="coach-remember-message-done"
className="text-muted-foreground flex items-center gap-1 text-xs outline-none"
>
<Check className="text-success size-3" aria-hidden="true" />
{t(
settled === "adopted"
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
)}
</p>
);
}
return (
<button
type="button"
data-slot="coach-remember-message"
onClick={() => remember.mutate()}
disabled={remember.isPending}
className={cn(
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
"inline-flex min-h-11 items-center gap-1 rounded px-1.5 py-1 text-xs sm:min-h-9",
"outline-none focus-visible:ring-2 disabled:opacity-50",
// Calmer thread on pointer devices: the control stays invisible
// until its bubble is hovered or holds focus. Touch viewports
// (no hover media) keep it always visible — there is nothing to
// hover. `opacity-0` (not `invisible`) keeps it focusable so
// keyboard users can reach it; focus then reveals it.
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:focus-visible:opacity-100",
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
{remember.isPending ? (
<Loader2
className="size-3 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-3" aria-hidden="true" />
)}
{t("insights.coach.rememberMessage.action")}
</button>
);
}
/**
* v1.16.1 — classic chat typing indicator: three dots pulsing in
* sequence inside the assistant bubble, shown only between submit and
@@ -1163,90 +810,3 @@ export function TypingDots({ label }: { label: string }) {
</span>
);
}
interface CoachMessageFeedbackProps {
messageId: string;
}
function CoachMessageFeedback({ messageId }: CoachMessageFeedbackProps) {
const { t } = useTranslations();
const [submittedRating, setSubmittedRating] = useState<
"helpful" | "unhelpful" | null
>(null);
const submit = useMutation({
mutationFn: async (rating: "helpful" | "unhelpful") => {
try {
await apiPost(`/api/insights/chat/messages/${messageId}/feedback`, {
rating,
});
} catch (err) {
// Treat 409 (already_rated) as a successful no-op so the user
// never sees an error toast for double-clicking the same chip.
if (!(err instanceof ApiError && err.status === 409)) {
throw err;
}
}
return rating;
},
onSuccess: (rating) => setSubmittedRating(rating),
// v1.16.4 — a failed rating used to fail silently; the chips stayed
// tappable with no signal that nothing was recorded.
onError: () => {
toast.error(t("insights.coach.feedbackError"));
},
});
// v1.22 — icon-only feedback in the per-message action row. After a rating
// lands, the chosen thumb stays visible in its confirming colour (the other
// is dropped) with an `sr-only` thanks so the signal is legible without a
// text caption breaking the icon row.
if (submittedRating) {
return (
<span
data-slot="coach-message-feedback-thanks"
role="status"
className="inline-flex items-center"
>
<span className="sr-only">{t("insights.coach.feedbackThanks")}</span>
{submittedRating === "helpful" ? (
<ThumbsUp className="text-success size-3.5" aria-hidden="true" />
) : (
<ThumbsDown className="text-warning size-3.5" aria-hidden="true" />
)}
</span>
);
}
const helpfulLabel = t("insights.coach.feedbackHelpful");
const unhelpfulLabel = t("insights.coach.feedbackUnhelpful");
return (
<div
data-slot="coach-message-feedback"
className="inline-flex items-center"
>
<button
type="button"
data-slot="coach-message-feedback-helpful"
onClick={() => submit.mutate("helpful")}
disabled={submit.isPending}
aria-label={helpfulLabel}
title={helpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-success")}
>
<ThumbsUp className="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
data-slot="coach-message-feedback-unhelpful"
onClick={() => submit.mutate("unhelpful")}
disabled={submit.isPending}
aria-label={unhelpfulLabel}
title={unhelpfulLabel}
className={cn(COACH_ICON_BUTTON, "hover:text-warning")}
>
<ThumbsDown className="size-3.5" aria-hidden="true" />
</button>
</div>
);
}
@@ -2,7 +2,9 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
* A tap sends the chip's label as the message with
* 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.
*
* The label is the server's rendering in the request locale. It is not
@@ -46,7 +48,7 @@ export function CoachFollowUpChips({
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
className="flex flex-wrap gap-2"
className="flex max-w-full flex-wrap gap-2"
>
{chips.map((chip) => (
<Button
@@ -54,7 +56,10 @@ export function CoachFollowUpChips({
type="button"
variant="outline"
size="sm"
className="min-h-11"
// The answer's own scale (text-sm, regular weight); the 44 px tap
// floor below `sm`, 36 px beside a pointer. A long label wraps
// inside the pill instead of pushing past the column at 390 px.
className="h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal sm:min-h-9"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => {
@@ -0,0 +1,356 @@
"use client";
/**
* The one action row under a Coach message, for both roles.
*
* Assistant: a single line. Left, the icon actions in reading order (copy,
* read aloud, try again) followed by the time the answer was written; right,
* the tokens and model the answer cost, as meta. The right group truncates
* before the row would wrap, so the row is one line at 390 px too.
*
* User: copy, remember, time, flush with the bubble's right edge.
*
* On pointer devices the row stays quiet until its message is hovered or
* holds focus; on touch it is always visible (there is nothing to hover).
* `opacity-0` rather than `invisible` keeps every control focusable, and
* focus reveals the row.
*/
import { useCallback, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
BookmarkCheck,
BookmarkPlus,
Check,
Copy,
Loader2,
RotateCcw,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { resolveIntlLocale } from "@/lib/format-locale";
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_ICON_BUTTON,
ReadAloudButton,
useClipboardSupported,
} from "./read-aloud";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
const DAY_MS = 24 * 60 * 60 * 1000;
/** The quiet-until-hover reveal, scoped to the message's own group. */
const REVEAL = {
assistant: cn(
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/assistant-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/assistant-bubble:opacity-100",
),
user: cn(
"sm:[@media(hover:hover)]:opacity-0",
"sm:[@media(hover:hover)]:group-hover/user-bubble:opacity-100",
"sm:[@media(hover:hover)]:group-focus-within/user-bubble:opacity-100",
),
} as const;
/**
* The visible time of a message: the clock alone today, "yesterday 14:32"
* the day before, and the short date with the clock further back. Days are
* compared in the reader's display zone (the formatters carry it). Pure, so
* the three tiers are pinned by a unit test.
*/
export function messageTimeText(
iso: string,
now: number,
formatters: Pick<Formatters, "dateShortSmart" | "time">,
t: Translate,
): string {
const day = formatters.dateShortSmart(iso);
const time = formatters.time(iso);
if (day === formatters.dateShortSmart(new Date(now))) return time;
if (day === formatters.dateShortSmart(new Date(now - DAY_MS))) {
return t("insights.coach.answer.timeYesterday", { time });
}
return t("insights.coach.answer.timeEarlier", { date: day, time });
}
/**
* The answer's cost as one meta string: tokens with the model when both are
* known, tokens alone otherwise, nothing without a count. The count is the
* server's (`done.usage` live, `tokensUsed` on reload), formatted for the
* reader's locale.
*/
export function answerMetaText(
tokens: number | null | undefined,
model: string | null | undefined,
locale: string,
t: Translate,
): string | null {
if (tokens == null) return null;
let count: string;
try {
count = new Intl.NumberFormat(locale).format(tokens);
} catch {
count = String(tokens);
}
return model
? t("insights.coach.tokensUsedWithModel", { count, model })
: t("insights.coach.tokensUsed", { count });
}
function CopyMessageButton({
content,
strip,
}: {
content: string;
strip: boolean;
}) {
const { t } = useTranslations();
const supported = useClipboardSupported();
const [copied, setCopied] = useState(false);
const handle = useCallback(async () => {
// Assistant prose is copied as the bubble shows it, without chart
// tokens; user text is verbatim.
const text = strip ? stripChartTokens(content) : content;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t("insights.coach.copyMessageError"));
}
}, [content, strip, t]);
// Absent where the Clipboard API is (plain-HTTP self-hosts) rather than
// error-toasting on tap.
if (!supported) return null;
const label = t("insights.coach.copyMessage");
return (
<button
type="button"
data-slot="coach-copy-message"
onClick={handle}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{copied ? (
<Check className="text-success size-4" aria-hidden="true" />
) : (
<Copy className="size-4" aria-hidden="true" />
)}
</button>
);
}
/** Re-runs the question that produced this answer. */
function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) {
const { t } = useTranslations();
const label = t("insights.coach.regenerate");
return (
<button
type="button"
data-slot="coach-try-again"
onClick={onRegenerate}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
<RotateCcw className="size-4" aria-hidden="true" />
</button>
);
}
/**
* When the message was written, as text. The visible run is short ("14:32");
* the full date and time ride the `title` and the screen-reader text.
*/
function MessageTime({ iso }: { iso: string }) {
const { t } = useTranslations();
const formatters = useFormatters();
// "Today" is fixed when the row mounts; a thread left open past midnight
// keeps its labels until the next render from fresh data.
const [now] = useState(Date.now);
const full = formatters.dateTime(iso);
return (
<time
dateTime={iso}
title={full}
data-slot="coach-message-time"
className="text-muted-foreground shrink-0 px-1 text-xs whitespace-nowrap tabular-nums"
>
<span aria-hidden="true">{messageTimeText(iso, now, formatters, t)}</span>
<span className="sr-only">
{t("insights.coach.messageTimeLabel", { time: full })}
</span>
</time>
);
}
/**
* 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.
*/
function RememberMessageButton({ content }: { content: 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,
}),
onSuccess: (data) => {
if (data.adopted) {
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
}
setSettled(data.adopted ? "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)(
t(
data.adopted
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
),
);
},
onError: () => {
toast.error(t("insights.coach.rememberMessage.failed"));
},
});
const label = settled
? t("insights.coach.answer.rememberDone")
: t("insights.coach.rememberMessage.action");
const inert = settled !== null || remember.isPending;
return (
<button
type="button"
data-slot="coach-remember-message"
data-state={settled ?? (remember.isPending ? "pending" : "idle")}
onClick={() => {
if (!inert) remember.mutate();
}}
// aria-disabled, not disabled: a disabled button drops keyboard focus.
aria-disabled={inert || undefined}
aria-busy={remember.isPending || undefined}
aria-label={label}
title={label}
className={COACH_ICON_BUTTON}
>
{remember.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : settled ? (
// Green only for the entry this tap wrote; "already there" stays
// neutral.
<BookmarkCheck
className={cn("size-4", settled === "adopted" && "text-success")}
aria-hidden="true"
/>
) : (
<BookmarkPlus className="size-4" aria-hidden="true" />
)}
</button>
);
}
export interface AssistantMessageActionsProps {
content: string;
/** Read aloud and try again wait until the answer has settled. */
streaming: boolean;
createdAt?: string;
onRegenerate?: () => void;
tokens?: number | null;
model?: string | null;
}
export function AssistantMessageActions({
content,
streaming,
createdAt,
onRegenerate,
tokens,
model,
}: AssistantMessageActionsProps) {
const { t, locale } = useTranslations();
const meta = answerMetaText(tokens, model, resolveIntlLocale(locale), t);
return (
<div
data-slot="coach-answer-actions"
className={cn(
"flex w-full min-w-0 items-center justify-between gap-2 self-stretch",
REVEAL.assistant,
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<div
data-slot="coach-answer-actions-left"
className="flex shrink-0 items-center gap-0.5"
>
<CopyMessageButton content={content} strip />
{!streaming && <ReadAloudButton content={content} />}
{!streaming && onRegenerate && (
<TryAgainButton onRegenerate={onRegenerate} />
)}
{createdAt && <MessageTime iso={createdAt} />}
</div>
<p
data-slot="coach-answer-meta"
title={model ?? undefined}
className="text-muted-foreground min-w-0 truncate text-right text-xs tabular-nums"
>
{meta}
</p>
</div>
);
}
export interface UserMessageActionsProps {
content: string;
/** Present once the message is persisted; an optimistic copy has none. */
messageId?: string;
createdAt?: string;
}
export function UserMessageActions({
content,
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;
return (
<div
data-slot="coach-user-actions"
className={cn(
"flex items-center justify-end gap-0.5",
REVEAL.user,
"transition-opacity duration-150 motion-reduce:transition-none",
)}
>
<CopyMessageButton content={content} strip={false} />
{canRemember && <RememberMessageButton content={content} />}
{createdAt && <MessageTime iso={createdAt} />}
</div>
);
}
@@ -1,6 +1,13 @@
"use client";
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -8,9 +15,10 @@ import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble } from "./chat-bubble";
import { CoachFollowUpChips } from "./follow-up-chips";
import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { focusCoachComposer } from "./composer-focus";
import {
EMPTY_LIVE_TURN_KEYS,
messageRenderKey,
@@ -50,6 +58,9 @@ export {
* list grows OR the streaming-content length changes. The user can
* scroll up to read history; we suppress auto-scroll until the next
* "new message" tick if they're not already pinned to the bottom.
* Away from the end, a round button fades in over the thread's foot and
* brings the reader back (`ScrollToBottomButton`); a reply streaming in
* meanwhile never pulls the view down by itself.
*
* Visual identity: user bubbles right-aligned, Dracula purple accent;
* assistant bubbles left-aligned with the gradient sparkle avatar
@@ -268,14 +279,10 @@ export function MessageThread({
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
const wasPinnedRef = useRef(true);
// v1.4.27 F14 — the evidence disclosure used to honour an opt-in
// `coachPrefs.showEvidenceByDefault` flag that surfaced the raw
// measurement values unconditionally. The flag created an UX trap
// (the maintainer 2026-05-15: "literal metric values exposed under every
// bubble") so the disclosure is now collapsed by default for every
// reply. The user expands by click; the pref is retired in the
// settings sheet so nothing flips it back on.
// The same pinned state as a render value, for the jump-to-latest button.
// The ref stays the source for the auto-scroll (it must not wait for a
// render); the state follows it at most once per frame.
const [pinned, setPinned] = useState(true);
const messages: CoachMessageDTO[] = useMemo(
() => conversation?.messages ?? [],
@@ -334,18 +341,63 @@ export function MessageThread({
optimisticActive && optimisticUser ? optimisticUser.content : null,
);
const chips = latestFollowUps(messages, streaming);
// The chips go to the bubble of the message that offered them, so they
// sit in that answer's column. One object per offer, so the memoised
// bubble sees the same value until the offer itself changes.
const latest = latestFollowUps(messages, streaming);
const chipsMessageId = latest?.messageId ?? null;
const chipsArray = latest?.followUps ?? null;
const chips: FollowUpOffer | null = useMemo(
() =>
chipsMessageId && chipsArray
? { messageId: chipsMessageId, followUps: chipsArray }
: null,
[chipsMessageId, chipsArray],
);
const chipsFor = (messageId: string | null | undefined) =>
onFollowUp && chips && messageId === chips.messageId
? {
followUps: chips,
followUpsDisabled: !!streaming?.inProgress,
onFollowUp,
}
: {};
const hasThread =
messages.length > 0 ||
streamingActive ||
optimisticActive ||
interleavedItems.length > 0;
// Track scroll position so we don't yank the viewport when the user
// is browsing earlier turns.
// is browsing earlier turns. Re-attached when the thread mounts (the
// empty state renders no scroller).
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
let frame = 0;
const onScroll = () => {
wasPinnedRef.current = isPinnedToBottom(el);
if (frame) return;
frame = window.requestAnimationFrame(() => {
frame = 0;
setPinned(isPinnedToBottom(el));
});
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
return () => {
el.removeEventListener("scroll", onScroll);
if (frame) window.cancelAnimationFrame(frame);
};
}, [hasThread]);
const scrollToLatest = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
wasPinnedRef.current = true;
setPinned(true);
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
focusCoachComposer();
}, []);
// Auto-scroll on new messages OR streaming-content growth, but only
@@ -389,12 +441,7 @@ export function MessageThread({
return () => vv.removeEventListener("resize", handleResize);
}, []);
if (
messages.length === 0 &&
!streamingActive &&
!optimisticActive &&
interleavedItems.length === 0
) {
if (!hasThread) {
return (
<div
data-slot="coach-message-thread"
@@ -417,150 +464,152 @@ export function MessageThread({
return (
<CoachMessageDatesProvider dates={messageDates}>
{/* The positioning frame for the jump-to-latest button. It takes the
thread's place in the flex column; the scroller inside stays the
one scroll container. */}
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
// component-scoped via Tailwind arbitrary variants so globals.css
// stays untouched. Replaces the default boxy/angular track.
COACH_SCROLLBAR,
)}
data-slot="coach-message-thread-frame"
className="relative flex min-h-0 flex-1 flex-col"
>
{[
...messages.map((m, idx) => {
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// 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) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
<div
ref={scrollerRef}
data-slot="coach-message-thread"
className={cn(
// v1.18.6.1 — `min-h-0 flex-1` (not `h-full`) so the scroll region
// resolves its height from the flex parent rather than a 100%-of-auto
// chain that let the thread grow instead of scroll.
// v1.18.7 — calmer vertical rhythm (gap-6) and more generous top/
// bottom breathing room so the conversation reads like a document,
// not a chat log packed against the chrome.
"flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-4 py-6 sm:px-6",
// v1.18.6 (CCH-01) / v1.18.7 — on the wide page surface an edge-to-
// edge thread sprawled the prose past a readable measure. Centre the
// content on a narrower, calmer Claude/ChatGPT-like column
// (`max-w-2xl`) via an `mx-auto` inner gutter; the scrollbar still
// rides the surface edge. The drawer surface is already narrow, so
// the cap only bites on the wide page surface.
"[&>*]:mx-auto [&>*]:w-full [&>*]:max-w-2xl",
"scroll-smooth",
// v1.18.7 — thin, rounded, subtle scrollbar (WebKit + Firefox),
// component-scoped via Tailwind arbitrary variants so globals.css
// stays untouched. Replaces the default boxy/angular track.
COACH_SCROLLBAR,
)}
>
{[
...messages.map((m, idx) => {
// v1.16.5 — a guided question renders directly above the user
// message that answered it.
const guidedBefore = placement.before.get(m.id);
// v1.22 — "Try again": resolve the user message that produced this
// 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) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
break;
}
}
}
}
const bubble = (
<ChatBubble
role={m.role}
content={m.content}
metricSource={m.metricSource}
conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
// The turn that just streamed keeps its live tables, so the
// swap to the persisted copy does not refetch and remount them.
{...(m.id === streaming?.messageId &&
streaming.results.length > 0
? { results: streaming.results }
: {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
);
const key = messageRenderKey(liveKeys, m.id);
return (
<Fragment key={key}>
{guidedBefore?.node}
{m.role === "assistant" ? (
<AssistantTurn turnKey={key} live={false}>
{bubble}
</AssistantTurn>
) : (
bubble
)}
</Fragment>
);
}),
// v1.16.5 — guided question whose answer is still optimistic-only.
...placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
)),
// v1.4.25 W5 — the optimistic user bubble sits between the
// persisted history and the streaming assistant placeholder, so
// the visible order matches the person's mental model. The send
// hook drops it once the persisted twin lands.
optimisticActive && optimisticUser ? (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
) : null,
streamingActive && streaming ? (
// Same key, fragment and wrapper as its persisted copy above.
<Fragment key={liveKeys.current ?? "coach-turn-live"}>
{null}
<AssistantTurn
turnKey={liveKeys.current ?? "coach-turn-live"}
live
>
const bubble = (
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
usage={streaming.usage}
role={m.role}
content={m.content}
metricSource={m.metricSource}
conversationId={conversation?.id ?? null}
providerType={m.providerType}
messageId={m.id}
tokensUsed={m.tokensUsed}
model={m.model}
createdAt={m.createdAt}
// The turn that just streamed keeps its live tables, so the
// swap to the persisted copy does not refetch and remount them.
{...(m.id === streaming?.messageId &&
streaming.results.length > 0
? { results: streaming.results }
: {})}
{...(m.role === "assistant" ? chipsFor(m.id) : {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
? () => onRegenerate(precedingUserContent as string)
: undefined
}
/>
</AssistantTurn>
</Fragment>
) : null,
]}
{/* v1.39.4 — follow-up chips under the latest assistant turn only. */}
{onFollowUp && chips ? (
<CoachFollowUpChips
followUps={chips.followUps}
messageId={chips.messageId}
disabled={!!streaming?.inProgress}
onSelect={onFollowUp}
/>
) : null}
{/* v1.16.5 — thread tail: the current guided question and/or the
);
const key = messageRenderKey(liveKeys, m.id);
return (
<Fragment key={key}>
{guidedBefore?.node}
{m.role === "assistant" ? (
<AssistantTurn turnKey={key} live={false}>
{bubble}
</AssistantTurn>
) : (
bubble
)}
</Fragment>
);
}),
// v1.16.5 — guided question whose answer is still optimistic-only.
...placement.beforeOptimistic.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
)),
// v1.4.25 W5 — the optimistic user bubble sits between the
// persisted history and the streaming assistant placeholder, so
// the visible order matches the person's mental model. The send
// hook drops it once the persisted twin lands.
optimisticActive && optimisticUser ? (
<ChatBubble
key={optimisticUser.localId}
role="user"
content={optimisticUser.content}
/>
) : null,
streamingActive && streaming ? (
// Same key, fragment and wrapper as its persisted copy above.
<Fragment key={liveKeys.current ?? "coach-turn-live"}>
{null}
<AssistantTurn
turnKey={liveKeys.current ?? "coach-turn-live"}
live
>
<ChatBubble
role="assistant"
content={streaming.content}
metricSource={streaming.metricSource}
suggestion={streaming.suggestion}
suggestedAction={streaming.suggestedAction}
steps={streaming.steps}
results={streaming.results}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
errorCode={streaming.errorCode}
// v1.18.9 — live word-fade + the just-landed token footer.
streaming
usage={streaming.usage}
{...chipsFor(streaming.messageId)}
/>
</AssistantTurn>
</Fragment>
) : null,
]}
{/* v1.16.5 — thread tail: the current guided question and/or the
closing summary follow the last completed turn. */}
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* Plan-proposal confirm cards for THIS conversation. The extractor
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* 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 ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
{conversation ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
</div>
<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />
</div>
</CoachMessageDatesProvider>
);
@@ -1,37 +0,0 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.18.9 — quiet per-message token-usage caption under an assistant
* bubble. Server-authoritative: it renders the count the server reported
* (`done.usage.totalTokens` for the live turn, `CoachMessageDTO.tokensUsed`
* on reload), never a client-side recomputation. No cost — BYOK users pay
* the provider directly and a price table would only drift.
*
* Renders nothing when there is no count (user turns, refusals, in-flight
* bubbles, and pre-feature persisted rows all carry null).
*/
export interface MessageTokenFooterProps {
tokens: number | null | undefined;
/** Provider model name (e.g. `gpt-4o`); appended when present. */
model?: string | null;
}
export function MessageTokenFooter({ tokens, model }: MessageTokenFooterProps) {
const { t } = useTranslations();
if (tokens == null) return null;
const label = model
? t("insights.coach.tokensUsedWithModel", { count: tokens, model })
: t("insights.coach.tokensUsed", { count: tokens });
return (
<p
data-slot="coach-token-footer"
className="text-muted-foreground text-xs tabular-nums"
>
{label}
</p>
);
}
@@ -1,11 +1,10 @@
"use client";
/**
* v1.39.4 — how an answer was worked out, as one muted line inside the
* evidence disclosure: sources, windows, counts and aggregation, rendered
* v1.39.4 — how an answer was worked out, as one muted line at the end of
* the open steps list: sources, windows, counts and aggregation, rendered
* on the server in the request locale. Never a health value, so it sits in
* the meta tier beside the key values it explains rather than repeating
* them.
* the meta tier.
*/
import { useTranslations } from "@/lib/i18n/context";
import { COACH_METHOD_KEYS } from "@/lib/ai/coach/dialog-keys";
@@ -260,9 +260,9 @@ export function ReadAloudButton({ content }: { content: string }) {
className={COACH_ICON_BUTTON}
>
{speaking ? (
<VolumeX className="size-3.5" aria-hidden="true" />
<VolumeX className="size-4" aria-hidden="true" />
) : (
<Volume2 className="size-3.5" aria-hidden="true" />
<Volume2 className="size-4" aria-hidden="true" />
)}
</button>
);
@@ -0,0 +1,54 @@
"use client";
/**
* The round "jump to latest" button over the bottom of the Coach thread.
*
* It fades in once the reader has scrolled away from the end and fades out
* at the end. While hidden it stays mounted (so the fade has something to
* run on) but leaves the accessibility tree and the tab order and takes no
* pointer events. With reduced motion it switches without a transition.
*/
import { ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
export interface ScrollToBottomButtonProps {
visible: boolean;
onClick: () => void;
}
export function ScrollToBottomButton({
visible,
onClick,
}: ScrollToBottomButtonProps) {
const { t } = useTranslations();
const label = t("insights.coach.answer.scrollToBottom");
return (
<Button
type="button"
variant="outline"
size="icon"
data-slot="coach-scroll-to-bottom"
data-visible={visible ? "true" : "false"}
aria-label={label}
title={label}
aria-hidden={visible ? undefined : true}
tabIndex={visible ? undefined : -1}
onClick={onClick}
className={cn(
"absolute bottom-3 left-1/2 z-10 size-11 -translate-x-1/2 rounded-full shadow-md sm:size-9",
// Opaque in both themes: it floats over prose, which must not show
// through (the outline variant's dark fill is translucent).
"bg-background dark:bg-background dark:hover:bg-accent",
"transition-[opacity,translate] duration-200 motion-reduce:transition-none",
visible
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0",
)}
>
<ArrowDown className="size-4" aria-hidden="true" />
</Button>
);
}
@@ -1,110 +0,0 @@
"use client";
import { Link2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { CoachProvenance } from "@/lib/ai/coach/types";
/**
* v1.4.20 phase B2b — provenance chip row.
*
* Renders the labels-only `metricSourceJson` envelope that the backend
* attaches to every assistant message. We never render raw values here
* — only metric + window labels + sample counts so that the row
* remains queryable / analytics-safe and keeps PII out of any future
* server-side log capture.
*
* The chip click is a no-op for v1.4.20; a chart deeplink will land in
* B3 once the correlation row knows which window to focus.
*/
export interface SourceChipsProps {
provenance: CoachProvenance | null;
className?: string;
}
// v1.7.0 — every provenance metric token resolves to
// `insights.coach.metric.<token>`. The clustered taxonomy added ~26
// new tokens; the key is derived from the token rather than maintained
// in a hand-listed map. The resolver falls back to the raw token when
// a locale file lags, and `messages/en.json` carries one leaf per
// token (propagated to all locales by the locale-integrity guard).
function metricI18nKey(metric: CoachProvenance["metrics"][number]): string {
return `insights.coach.metric.${metric}`;
}
const WINDOW_KEYS: Record<CoachProvenance["windows"][number], string> = {
last7days: "insights.coach.window.last7days",
last30days: "insights.coach.window.last30days",
last90days: "insights.coach.window.last90days",
// v1.4.27 B7 / BL-P6-4 — year-in-review window. The i18n key
// resolves to a localised label; absent translations fall back
// through the resolver to the English source.
lastYear: "insights.coach.window.lastYear",
allTime: "insights.coach.window.allTime",
};
export function SourceChips({ provenance, className }: SourceChipsProps) {
const { t } = useTranslations();
if (!provenance) return null;
const { metrics, windows, counts } = provenance;
if (metrics.length === 0 && windows.length === 0) return null;
// We pair each metric with its first window (the assistant only ever
// surfaces a single window-per-metric in v1.4.20). When the provenance
// envelope has no windows (e.g. refusal path with `windows: []`), we
// still render the metric as a standalone chip so the user gets the
// "general" tag visibly.
const primaryWindowKey = windows[0] ?? null;
const chips = metrics.map((metric) => {
const metricLabel = t(metricI18nKey(metric));
const windowLabel = primaryWindowKey
? t(WINDOW_KEYS[primaryWindowKey])
: null;
const count =
counts && metric in counts
? counts[metric as keyof typeof counts]
: undefined;
return {
key: `${metric}-${primaryWindowKey ?? "none"}`,
metric,
metricLabel,
windowLabel,
count,
};
});
return (
<div
data-slot="coach-source-chips"
className={cn("flex flex-wrap gap-1.5", className)}
>
{chips.map((chip) => (
<span
key={chip.key}
data-slot="coach-source-chip"
data-metric={chip.metric}
className={cn(
"border-info/25 text-info",
"inline-flex items-center gap-1 rounded-full border bg-transparent",
"px-2 py-0.5 text-xs leading-none",
)}
>
<Link2 className="h-2.5 w-2.5" aria-hidden="true" />
<span className="font-medium">{chip.metricLabel}</span>
{chip.windowLabel && (
<span data-slot="coach-source-window" className="text-info">
· {chip.windowLabel}
</span>
)}
{typeof chip.count === "number" && chip.count > 0 && (
<span data-slot="coach-source-count" className="text-info">
· n={chip.count}
</span>
)}
</span>
))}
</div>
);
}
@@ -13,11 +13,17 @@
* 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 } from "react";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import {
Check,
ChevronRight,
@@ -36,7 +42,13 @@ import {
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachStep } from "@/lib/ai/coach/types";
import type {
CoachMethod,
CoachProvenanceMetric,
CoachStep,
} from "@/lib/ai/coach/types";
import { CoachMethodLine } from "./method-line";
export interface CoachTurnStepsProps {
steps: CoachStep[];
@@ -44,6 +56,21 @@ export interface CoachTurnStepsProps {
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;
@@ -238,6 +265,26 @@ function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
);
}
/**
* 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,
@@ -250,7 +297,7 @@ export function CoachTurnStepList({
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="border-border/60 mt-1.5 ml-1.5 flex flex-col gap-1 border-l pl-2.5"
className="flex flex-col gap-1"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
@@ -279,7 +326,66 @@ export function CoachTurnStepList({
);
}
export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
/**
* 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 (
<div
id={id}
data-slot="coach-turn-steps-panel"
className="border-border/60 mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
>
{steps.length > 0 ? (
<CoachTurnStepList steps={steps} active={active} />
) : areaLabels.length > 0 ? (
<ul
data-slot="coach-turn-areas"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{areaLabels.map((label) => (
<li
key={label}
data-slot="coach-turn-area"
className="text-foreground/80 flex items-start gap-1.5 leading-relaxed"
>
<Check
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3 shrink-0"
/>
<span className="min-w-0">{label}</span>
</li>
))}
</ul>
) : null}
{!active && <CoachMethodLine method={method ?? null} />}
{!active && dataUsed}
</div>
);
}
export function CoachTurnSteps({
steps,
active,
areas,
method,
dataUsed,
}: CoachTurnStepsDetailProps) {
const { t, tCount } = useTranslations();
const listId = useId();
// Closed by default; the reader's toggle holds until the turn ends, and
@@ -294,14 +400,31 @@ export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
);
if (steps.length === 0) return null;
const areaLabels = steps.length === 0 ? legacyAreaLabels(areas, t) : [];
const hasMethod = !active && !!method?.text;
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
if (
steps.length === 0 &&
areaLabels.length === 0 &&
!hasMethod &&
!hasDataUsed
) {
return null;
}
const open = openOverride ?? false;
const current = active ? currentStep(steps) : null;
const header = current
? null
: 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);
return (
<div
data-slot="coach-turn-steps"
className="flex max-w-full flex-col text-xs"
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
>
<button
type="button"
@@ -345,11 +468,20 @@ export function CoachTurnSteps({ steps, active }: CoachTurnStepsProps) {
</span>
) : (
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
{tCount("coach.step.headerDone", countSources(steps))}
{header}
</span>
)}
</button>
{open && <CoachTurnStepList id={listId} steps={steps} active={active} />}
{open && (
<CoachTurnStepsPanel
id={listId}
steps={steps}
active={active}
areaLabels={areaLabels}
method={method}
dataUsed={hasDataUsed ? dataUsed : null}
/>
)}
<span
role="status"
aria-live="polite"