Give each Coach answer one action row and keep its chips in its column

Under an answer: copy, read aloud, try again and the time as text on the
left; tokens and model on the right, on one line down to 390 px. The
thumbs leave the web (the feedback route stays for the native client),
and so does the "What I'm looking at" disclosure: the method line and
the tables the answer read move into the steps list, and an older
message without steps lists the areas it drew on, never a raw key.

Remember becomes a bookmark icon in the question's row and confirms in
place with a toast. Follow-up chips render inside the latest answer's
column at its text size. The answer column takes the full width below
`sm` and a fixed 80 % above, so nothing moves sideways as a reply
streams. A round button fades in when the thread is scrolled away from
the end and brings it back.
This commit is contained in:
Marc-André Bombeck committed 2026-10-04 20:02:48 +02:00
1 parent 1cb50ed21d
commit a91e1bf0a7
26 files changed
+1469 -1143

No files matched your search

+9 -8
View File
@@ -1047,23 +1047,24 @@ test.describe("axe-core authenticated route and state matrix", () => {
);
});
await test.step(`${theme} /coach open evidence disclosure and source chips`, async () => {
await test.step(`${theme} /coach open the areas an older answer looked at`, async () => {
await page.goto(`/coach?c=${A11Y_CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const evidence = page.locator('[data-slot="coach-evidence"]').first();
await expect(evidence).toBeVisible({ timeout: 15_000 });
await evidence.locator("summary").click();
await expect(evidence).toHaveAttribute("open", "");
const steps = page.locator('[data-slot="coach-turn-steps"]').first();
await expect(steps).toBeVisible({ timeout: 15_000 });
const toggle = steps.locator('[data-slot="coach-turn-steps-toggle"]');
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(
evidence.locator('[data-slot="coach-source-chips"]'),
steps.locator('[data-slot="coach-turn-areas"]'),
).toBeVisible();
blocking.push(
...(await scanPaintedState(
page,
theme,
"/coach open evidence disclosure and source chips",
evidence,
"/coach open the areas an older answer looked at",
steps,
)),
);
});
+4 -3
View File
@@ -550,10 +550,11 @@ test.describe("Coach dialog", () => {
await expect(figure).toBeVisible();
await expect(table).toBeHidden();
// The method line sits in the evidence disclosure.
await firstBubble.locator('[data-slot="coach-evidence-summary"]').click();
// The method line closes the open steps list.
await expect(
firstBubble.locator('[data-slot="coach-method-line"]'),
steps.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-method-line"]',
),
).toContainText(TURN_ONE_METHOD.text);
// 3. A follow-up chip sends which chip it was.
+10 -4
View File
@@ -289,7 +289,7 @@ test.describe("Coach result tables and charts", () => {
});
for (const theme of ["light", "dark"] as const) {
test(`theme=${theme}: chart first, table on toggle, data used in the evidence`, async ({
test(`theme=${theme}: chart first, table on toggle, data used in the steps list`, async ({
page,
}) => {
await useTheme(page, theme);
@@ -342,12 +342,18 @@ test.describe("Coach result tables and charts", () => {
await table.locator('[data-slot="coach-result-view-chart"]').click();
await expect(figure).toBeVisible();
// The table the answer only used waits in the evidence disclosure.
// The table the answer only used waits in the open steps list.
await expect(
bubble.locator('[data-slot="coach-result-table"][data-ref="r2"]'),
).toBeHidden();
await bubble.locator('[data-slot="coach-evidence-summary"]').click();
const dataUsed = bubble.locator('[data-slot="coach-data-used"]');
const stepsToggle = bubble.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(stepsToggle).toHaveAttribute("aria-expanded", "false");
await stepsToggle.click();
const dataUsed = bubble.locator(
'[data-slot="coach-turn-steps-panel"] [data-slot="coach-data-used"]',
);
await expect(dataUsed).toContainText("Data used (1)");
const workouts = dataUsed.locator(
'[data-slot="coach-result-table"][data-ref="r2"]',
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktuell",
"sourcesStale": "Veraltet",
"sourcesFooter": "Der Coach liest nur Daten, die du verbunden hast.",
"evidenceLabel": "Worauf bezieht sich das?",
"metric": {
"bp": "Blutdruck",
"weight": "Gewicht",
@@ -2956,10 +2955,6 @@
"settingsSave": "Speichern",
"settingsSaved": "Gespeichert",
"settingsCancel": "Abbrechen",
"feedbackHelpful": "Hilfreich",
"feedbackUnhelpful": "Nicht ganz",
"feedbackThanks": "Danke für das Signal.",
"feedbackError": "Feedback konnte nicht gespeichert werden",
"dailyLimitTitle": "Heute-Limit erreicht",
"dailyLimitBody": "Tageslimit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.",
"errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.",
"errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.",
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten."
"errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.",
"answer": {
"scrollToBottom": "Zur neuesten Nachricht",
"rememberDone": "Gemerkt",
"timeYesterday": "gestern {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 Bereich angesehen",
"areasDoneFew": "{count} Bereiche angesehen",
"areasDoneOther": "{count} Bereiche angesehen"
}
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Fresh",
"sourcesStale": "Stale",
"sourcesFooter": "The Coach reads only the data you've connected.",
"evidenceLabel": "What I'm looking at",
"metric": {
"bp": "Blood pressure",
"weight": "Weight",
@@ -2956,10 +2955,6 @@
"settingsSave": "Save",
"settingsSaved": "Saved",
"settingsCancel": "Cancel",
"feedbackHelpful": "Helpful",
"feedbackUnhelpful": "Not quite",
"feedbackThanks": "Thanks for the signal.",
"feedbackError": "Could not save feedback",
"dailyLimitTitle": "Daily limit reached",
"dailyLimitBody": "Daily limit reached. The budget refreshes at midnight UTC.",
"providerRateLimitTitle": "Provider rate-limited",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "The Coach is not available inside a record someone shared with you.",
"errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.",
"errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.",
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules."
"errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.",
"answer": {
"scrollToBottom": "Jump to the latest message",
"rememberDone": "Remembered",
"timeYesterday": "yesterday {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Looked at 1 area",
"areasDoneFew": "Looked at {count} areas",
"areasDoneOther": "Looked at {count} areas"
}
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Actuales",
"sourcesStale": "Desactualizados",
"sourcesFooter": "El coach solo lee los datos que has conectado.",
"evidenceLabel": "¿En qué se basa esto?",
"metric": {
"bp": "Presión arterial",
"weight": "Peso",
@@ -2956,10 +2955,6 @@
"settingsSave": "Guardar",
"settingsSaved": "Guardado",
"settingsCancel": "Cancelar",
"feedbackHelpful": "Útil",
"feedbackUnhelpful": "Poco útil",
"feedbackThanks": "Gracias por la indicación.",
"feedbackError": "No se pudo guardar el comentario",
"dailyLimitTitle": "Límite diario alcanzado",
"dailyLimitBody": "Límite diario alcanzado. El cupo se reinicia a medianoche (UTC).",
"providerRateLimitTitle": "Proveedor con límite de tasa",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.",
"errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.",
"errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.",
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos."
"errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.",
"answer": {
"scrollToBottom": "Ir al último mensaje",
"rememberDone": "Guardado",
"timeYesterday": "ayer {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 área consultada",
"areasDoneFew": "{count} áreas consultadas",
"areasDoneOther": "{count} áreas consultadas"
}
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "À jour",
"sourcesStale": "Obsolètes",
"sourcesFooter": "Le coach ne lit que les données que tu as connectées.",
"evidenceLabel": "Sur quoi est-ce fondé ?",
"metric": {
"bp": "Tension artérielle",
"weight": "Poids",
@@ -2956,10 +2955,6 @@
"settingsSave": "Enregistrer",
"settingsSaved": "Enregistré",
"settingsCancel": "Annuler",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Peu utile",
"feedbackThanks": "Merci pour l’indication.",
"feedbackError": "Impossible d'enregistrer le retour",
"dailyLimitTitle": "Limite quotidienne atteinte",
"dailyLimitBody": "Limite quotidienne atteinte. Le quota se réinitialise à minuit (UTC).",
"providerRateLimitTitle": "Fournisseur limité en débit",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.",
"errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.",
"errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.",
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules."
"errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.",
"answer": {
"scrollToBottom": "Aller au dernier message",
"rememberDone": "Mémorisé",
"timeYesterday": "hier {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 domaine consulté",
"areasDoneFew": "{count} domaines consultés",
"areasDoneOther": "{count} domaines consultés"
}
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aggiornate",
"sourcesStale": "Datate",
"sourcesFooter": "Il coach legge solo i dati che hai collegato.",
"evidenceLabel": "Su cosa si basa?",
"metric": {
"bp": "Pressione arteriosa",
"weight": "Peso",
@@ -2956,10 +2955,6 @@
"settingsSave": "Salva",
"settingsSaved": "Salvato",
"settingsCancel": "Annulla",
"feedbackHelpful": "Utile",
"feedbackUnhelpful": "Poco utile",
"feedbackThanks": "Grazie per il segnale.",
"feedbackError": "Impossibile salvare il feedback",
"dailyLimitTitle": "Limite giornaliero raggiunto",
"dailyLimitBody": "Limite giornaliero raggiunto. Il budget si reimposta a mezzanotte (UTC).",
"providerRateLimitTitle": "Provider con rate-limit",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.",
"errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.",
"errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.",
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli."
"errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.",
"answer": {
"scrollToBottom": "Vai all'ultimo messaggio",
"rememberDone": "Memorizzato",
"timeYesterday": "ieri {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 area consultata",
"areasDoneFew": "{count} aree consultate",
"areasDoneOther": "{count} aree consultate"
}
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "최신",
"sourcesStale": "오래됨",
"sourcesFooter": "코치는 연결한 데이터만 읽어요.",
"evidenceLabel": "내가 보고 있는 것",
"metric": {
"bp": "혈압",
"weight": "체중",
@@ -2956,10 +2955,6 @@
"settingsSave": "저장",
"settingsSaved": "저장됨",
"settingsCancel": "취소",
"feedbackHelpful": "도움됨",
"feedbackUnhelpful": "아쉬움",
"feedbackThanks": "알려줘서 고마워요.",
"feedbackError": "피드백을 저장하지 못했어요",
"dailyLimitTitle": "하루 한도 도달",
"dailyLimitBody": "하루 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"providerRateLimitTitle": "제공자 요청 제한",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.",
"errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.",
"errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.",
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다."
"errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.",
"answer": {
"scrollToBottom": "최신 메시지로 이동",
"rememberDone": "기억함",
"timeYesterday": "어제 {time}",
"timeEarlier": "{date} {time}",
"areasDoneOne": "영역 1개 확인",
"areasDoneFew": "영역 {count}개 확인",
"areasDoneOther": "영역 {count}개 확인"
}
},
"relativeJustNow": "방금",
"relativeMinutesAgoOne": "{count}분 전",
+10 -6
View File
@@ -2877,7 +2877,6 @@
"sourcesFresh": "Aktualne",
"sourcesStale": "Nieaktualne",
"sourcesFooter": "Coach czyta tylko dane, które podłączyłeś.",
"evidenceLabel": "Na czym to się opiera?",
"metric": {
"bp": "Ciśnienie krwi",
"weight": "Waga",
@@ -2956,10 +2955,6 @@
"settingsSave": "Zapisz",
"settingsSaved": "Zapisano",
"settingsCancel": "Anuluj",
"feedbackHelpful": "Przydatne",
"feedbackUnhelpful": "Mało przydatne",
"feedbackThanks": "Dzięki za sygnał.",
"feedbackError": "Nie udało się zapisać opinii",
"dailyLimitTitle": "Osiągnięto dzienny limit",
"dailyLimitBody": "Osiągnięto dzienny limit. Limit resetuje się o północy (UTC).",
"providerRateLimitTitle": "Dostawca z ograniczeniem",
@@ -3113,7 +3108,16 @@
"errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.",
"errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.",
"errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.",
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły."
"errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.",
"answer": {
"scrollToBottom": "Przejdź do najnowszej wiadomości",
"rememberDone": "Zapamiętano",
"timeYesterday": "wczoraj {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Sprawdzono 1 obszar",
"areasDoneFew": "Sprawdzono {count} obszary",
"areasDoneOther": "Sprawdzono {count} obszarów"
}
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -173,7 +173,14 @@ const PINNED_AFFORDANCES: Record<
CheckCircle2: 2,
"toast.success": 1,
},
"src/components/insights/coach-panel/chat-bubble.tsx": { "text-success": 4 },
// The Coach message action row: the copy tick after the clipboard write
// resolved, and the remember icon and toast only for the self-context entry
// the tap wrote (`adopted`); an entry that was already there gets a neutral
// icon and `toast.info`.
"src/components/insights/coach-panel/message-actions.tsx": {
"text-success": 2,
"toast.success": 1,
},
// The copy tick on a result table: shown only after the clipboard write
// resolved, so it reports a copy that happened; nothing is written to the
// record.
@@ -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"