mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge remote-tracking branch 'origin/feat/coach-answers' into release/v1.40.2
# Conflicts: # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/ko.json # messages/pl.json
This commit is contained in:
commit
0d6bac8c5c
28 files changed
+1833
-1140
No files matched your search
+9
-8
@@ -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,
|
||||
)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Locator, Page, Route, TestInfo } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./setup/test";
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
|
||||
|
||||
/**
|
||||
* The geometry of a Coach answer, at 1440 x 900 (desktop project) and
|
||||
* 390 x 844 (mobile project).
|
||||
*
|
||||
* Contracts under test:
|
||||
*
|
||||
* 1. Under an answer there is exactly one action row: the icon group and
|
||||
* the time on the left, the tokens and model on the right, both halves
|
||||
* on one line (their centres within 2 px, the row no taller than one
|
||||
* 44 px target), the left group starting on the bubble's left edge and
|
||||
* the meta ending inside the answer column.
|
||||
* 2. The follow-up chips sit in the answer's column: their left edge is
|
||||
* the bubble's, their text is the answer's size, and a chip is at most
|
||||
* 36 px tall beside a pointer and at least 44 px on a phone.
|
||||
* 3. Under a question, copy and remember share one row; nothing stacks
|
||||
* below it.
|
||||
* 4. Scrolled away from the end, a round button appears; it brings the
|
||||
* thread back to the end and goes away again.
|
||||
* 5. No thumbs, no evidence disclosure, no horizontal overflow.
|
||||
*
|
||||
* Screenshots are attached to the report (no pixel baselines) and, with
|
||||
* `COACH_SHOT_DIR` set, written there too.
|
||||
*/
|
||||
|
||||
const CONVERSATION_ID = "coach-answer-layout-e2e";
|
||||
const TITLE = "Blood pressure and sleep";
|
||||
|
||||
const ANSWERS = [
|
||||
"Your blood pressure stayed in a steady band over the last month. The morning readings sat a little higher than the evening ones, which is common, and none of the weeks stands out from the others.",
|
||||
"Sleep looked regular on most nights. The weekends ran about forty minutes later at both ends, so the total stayed much the same while the timing moved.",
|
||||
"Your resting heart rate held steady across the same weeks. Nothing in it lines up with the later weekend nights, so the two seem to move independently for now.",
|
||||
"The period before looked much the same for blood pressure. Two readings in the second week were higher than the rest, and both were taken shortly after a workout.",
|
||||
"Taken together, the month reads calm: steady blood pressure, regular sleep and a resting heart rate without surprises. Measuring at the same time each morning keeps the trend easy to read.",
|
||||
];
|
||||
const QUESTIONS = [
|
||||
"How was my blood pressure this month?",
|
||||
"And my sleep?",
|
||||
"What about my resting heart rate?",
|
||||
"Compare blood pressure with the period before",
|
||||
"Sum it up for me",
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: "s1",
|
||||
tool: "get_metric_series",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Blood pressure, last 30 days",
|
||||
domain: "bp",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: 28,
|
||||
},
|
||||
{
|
||||
id: "s2",
|
||||
tool: "get_sleep",
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: "Checking: Sleep, last 30 days",
|
||||
domain: "sleep",
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
count: 30,
|
||||
},
|
||||
];
|
||||
|
||||
const FOLLOW_UPS = [
|
||||
{
|
||||
id: "f1",
|
||||
kind: "previous_period",
|
||||
labelKey: "coach.followUp.previousPeriod",
|
||||
label: "Compare with the period before",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
{
|
||||
id: "f2",
|
||||
kind: "related_metric",
|
||||
labelKey: "coach.followUp.relatedMetric",
|
||||
label: "See also: Resting HR",
|
||||
reuse: false,
|
||||
origin: "server",
|
||||
},
|
||||
];
|
||||
|
||||
function messages() {
|
||||
// Today, a few minutes apart, so the time reads as a bare clock.
|
||||
const base = Date.now() - 60 * 60 * 1000;
|
||||
const at = (minute: number) =>
|
||||
new Date(base + minute * 60 * 1000).toISOString();
|
||||
return ANSWERS.flatMap((answer, index) => [
|
||||
{
|
||||
id: `${CONVERSATION_ID}-u${index + 1}`,
|
||||
role: "user",
|
||||
content: QUESTIONS[index],
|
||||
createdAt: at(index * 2),
|
||||
metricSource: null,
|
||||
providerType: null,
|
||||
promptVersion: null,
|
||||
tokensUsed: null,
|
||||
model: null,
|
||||
},
|
||||
{
|
||||
id: `${CONVERSATION_ID}-a${index + 1}`,
|
||||
role: "assistant",
|
||||
content: answer,
|
||||
createdAt: at(index * 2 + 1),
|
||||
metricSource: {
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp", "sleep"],
|
||||
steps: STEPS,
|
||||
...(index === ANSWERS.length - 1 ? { followUps: FOLLOW_UPS } : {}),
|
||||
},
|
||||
providerType: "mock",
|
||||
promptVersion: "e2e",
|
||||
tokensUsed: 1234,
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function fulfilJson(route: Route, data: unknown) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data, error: null }),
|
||||
});
|
||||
}
|
||||
|
||||
async function mockConversation(page: Page) {
|
||||
const summary = {
|
||||
id: CONVERSATION_ID,
|
||||
title: TITLE,
|
||||
createdAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
messageCount: ANSWERS.length * 2,
|
||||
fenced: false,
|
||||
attachments: [],
|
||||
documentTitle: null,
|
||||
};
|
||||
await page.route(
|
||||
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.startsWith(`/api/insights/chat/${CONVERSATION_ID}/`)) {
|
||||
return fulfilJson(route, { results: [] });
|
||||
}
|
||||
if (url.pathname === `/api/insights/chat/${CONVERSATION_ID}`) {
|
||||
return fulfilJson(route, {
|
||||
...summary,
|
||||
attachmentCount: 0,
|
||||
summary: null,
|
||||
messages: messages(),
|
||||
});
|
||||
}
|
||||
return fulfilJson(route, { conversations: [summary], nextCursor: null });
|
||||
},
|
||||
);
|
||||
await page.route("**/api/insights/coach/nudge-status*", (route) =>
|
||||
fulfilJson(route, { nudgedAt: null, unread: false }),
|
||||
);
|
||||
await page.route("**/api/coach/about-me/questions*", (route) =>
|
||||
fulfilJson(route, { questions: [] }),
|
||||
);
|
||||
}
|
||||
|
||||
async function box(locator: Locator) {
|
||||
const b = await locator.boundingBox();
|
||||
expect(b, "element has a box").not.toBeNull();
|
||||
return b!;
|
||||
}
|
||||
|
||||
async function shoot(page: Page, testInfo: TestInfo, name: string) {
|
||||
const body = await page.screenshot({ fullPage: false });
|
||||
await testInfo.attach(name, { body, contentType: "image/png" });
|
||||
const dir = process.env.COACH_SHOT_DIR;
|
||||
if (dir) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
await page.screenshot({ path: join(dir, `${name}.png`) });
|
||||
}
|
||||
}
|
||||
|
||||
const VIEWPORTS = {
|
||||
"chromium-desktop": { width: 1440, height: 900, label: "1440" },
|
||||
"chromium-mobile": { width: 390, height: 844, label: "390" },
|
||||
} as const;
|
||||
|
||||
test.describe("Coach answer layout", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test("one action row, chips in the answer column, jump to the latest", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const viewport = VIEWPORTS[testInfo.project.name as keyof typeof VIEWPORTS];
|
||||
test.skip(!viewport, "measured in the desktop and the mobile project");
|
||||
const desktop = viewport.width >= 640;
|
||||
await page.setViewportSize({
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await serveAiBlock(page, aiBlockAvailable());
|
||||
await mockConversation(page);
|
||||
|
||||
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
const thread = page.locator('[data-slot="coach-message-thread"]');
|
||||
const answers = page.locator('[data-slot="coach-bubble-assistant"]');
|
||||
await expect(answers).toHaveCount(ANSWERS.length, { timeout: 15_000 });
|
||||
const last = answers.last();
|
||||
await expect(last).toContainText(ANSWERS.at(-1)!.slice(0, 40));
|
||||
// The thread opens at its end.
|
||||
await expect
|
||||
.poll(() =>
|
||||
thread.evaluate(
|
||||
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
if (desktop) await last.hover();
|
||||
await shoot(page, testInfo, `answer-${viewport.label}`);
|
||||
|
||||
// 5. Nothing of the old furniture.
|
||||
await expect(
|
||||
page.locator('[data-slot^="coach-message-feedback"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot^="coach-evidence"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-token-footer"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
for (const el of [page.locator("#main-content"), thread]) {
|
||||
const overflow = await el.evaluate((n) => n.scrollWidth - n.clientWidth);
|
||||
expect(overflow, "no horizontal overflow").toBeLessThanOrEqual(0);
|
||||
}
|
||||
|
||||
// 1. One action row under the answer.
|
||||
const bubble = last.locator('[data-slot="coach-answer-bubble"]');
|
||||
const column = last.locator('[data-slot="coach-answer-column"]');
|
||||
const row = last.locator('[data-slot="coach-answer-actions"]');
|
||||
const left = row.locator('[data-slot="coach-answer-actions-left"]');
|
||||
const meta = row.locator('[data-slot="coach-answer-meta"]');
|
||||
await expect(row).toHaveCount(1);
|
||||
await expect(meta).toContainText("1,234 tokens");
|
||||
await expect(row).toHaveCSS("opacity", "1");
|
||||
const [bubbleBox, columnBox, rowBox, leftBox, metaBox] = await Promise.all(
|
||||
[bubble, column, row, left, meta].map(box),
|
||||
);
|
||||
expect(
|
||||
Math.abs(
|
||||
leftBox.y + leftBox.height / 2 - (metaBox.y + metaBox.height / 2),
|
||||
),
|
||||
"left group and meta share one line",
|
||||
).toBeLessThanOrEqual(2);
|
||||
expect(rowBox.height, "the row is one line").toBeLessThanOrEqual(44);
|
||||
expect(Math.abs(leftBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(metaBox.x + metaBox.width).toBeLessThanOrEqual(
|
||||
columnBox.x + columnBox.width + 1,
|
||||
);
|
||||
await expect(left.locator('[data-slot="coach-message-time"]')).toHaveCount(
|
||||
1,
|
||||
);
|
||||
|
||||
// 2. The chips in the answer's column, at the answer's size.
|
||||
const chips = last.locator('[data-slot="coach-follow-up-chips"]');
|
||||
await expect(chips.locator("[data-follow-up-id]")).toHaveCount(2);
|
||||
const chipsBox = await box(chips);
|
||||
expect(Math.abs(chipsBox.x - bubbleBox.x)).toBeLessThanOrEqual(1);
|
||||
expect(chipsBox.y).toBeGreaterThanOrEqual(rowBox.y + rowBox.height - 1);
|
||||
const answerSize = await bubble.evaluate(
|
||||
(el) => getComputedStyle(el).fontSize,
|
||||
);
|
||||
for (const chip of await chips.locator("[data-follow-up-id]").all()) {
|
||||
await expect(chip).toHaveCSS("font-size", answerSize);
|
||||
const chipBox = await box(chip);
|
||||
if (desktop) expect(chipBox.height).toBeLessThanOrEqual(36);
|
||||
else expect(chipBox.height).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
|
||||
// 3. Copy and remember share the question's one row.
|
||||
const question = page.locator('[data-slot="coach-bubble-user"]').last();
|
||||
if (desktop) await question.hover();
|
||||
const userRow = question.locator('[data-slot="coach-user-actions"]');
|
||||
const remember = userRow.locator('[data-slot="coach-remember-message"]');
|
||||
const copy = userRow.locator('[data-slot="coach-copy-message"]');
|
||||
await expect(remember).toHaveAttribute("aria-label", "Remember");
|
||||
const [rememberBox, copyBox] = await Promise.all([remember, copy].map(box));
|
||||
expect(
|
||||
Math.abs(
|
||||
rememberBox.y +
|
||||
rememberBox.height / 2 -
|
||||
(copyBox.y + copyBox.height / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
// The question's column holds the bubble and its one row, nothing else.
|
||||
expect(
|
||||
await userRow.evaluate((el) => el.parentElement?.childElementCount),
|
||||
).toBe(2);
|
||||
|
||||
// 4. Away from the end, the button appears and brings the thread back.
|
||||
const jump = page.locator('[data-slot="coach-scroll-to-bottom"]');
|
||||
await expect(jump).toHaveAttribute("data-visible", "false");
|
||||
await expect(jump).toHaveCSS("opacity", "0");
|
||||
await thread.evaluate((el) => el.scrollTo({ top: 0, behavior: "auto" }));
|
||||
await expect(jump).toHaveAttribute("data-visible", "true");
|
||||
await expect(jump).toHaveCSS("opacity", "1");
|
||||
await shoot(page, testInfo, `scrolled-up-${viewport.label}`);
|
||||
const jumpBox = await box(jump);
|
||||
const threadBox = await box(thread);
|
||||
expect(
|
||||
Math.abs(
|
||||
jumpBox.x + jumpBox.width / 2 - (threadBox.x + threadBox.width / 2),
|
||||
),
|
||||
"centred over the thread",
|
||||
).toBeLessThanOrEqual(1);
|
||||
await jump.click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
thread.evaluate(
|
||||
(el) => el.scrollHeight - el.scrollTop - el.clientHeight,
|
||||
),
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
await expect(jump).toHaveAttribute("data-visible", "false");
|
||||
await expect(jump).toHaveAttribute("tabindex", "-1");
|
||||
await expect(page.locator("#coach-composer-textarea")).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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"]',
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Wird gespeichert…",
|
||||
"saveFailed": "Die Änderung ließ sich nicht speichern.",
|
||||
"allSettings": "Alle KI-Einstellungen"
|
||||
},
|
||||
"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",
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Saving…",
|
||||
"saveFailed": "That change could not be saved.",
|
||||
"allSettings": "All AI settings"
|
||||
},
|
||||
"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",
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Guardando…",
|
||||
"saveFailed": "No se pudo guardar el cambio.",
|
||||
"allSettings": "Todos los ajustes de IA"
|
||||
},
|
||||
"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",
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Enregistrement…",
|
||||
"saveFailed": "Impossible d’enregistrer cette modification.",
|
||||
"allSettings": "Tous les paramètres IA"
|
||||
},
|
||||
"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",
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Salvataggio…",
|
||||
"saveFailed": "Impossibile salvare la modifica.",
|
||||
"allSettings": "Tutte le impostazioni IA"
|
||||
},
|
||||
"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",
|
||||
|
||||
+9
-5
@@ -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": "제공자 요청 제한",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "저장 중…",
|
||||
"saveFailed": "변경 사항을 저장하지 못했어요.",
|
||||
"allSettings": "모든 AI 설정"
|
||||
},
|
||||
"answer": {
|
||||
"scrollToBottom": "최신 메시지로 이동",
|
||||
"rememberDone": "기억함",
|
||||
"timeYesterday": "어제 {time}",
|
||||
"timeEarlier": "{date} {time}",
|
||||
"areasDoneOne": "영역 1개 확인",
|
||||
"areasDoneFew": "영역 {count}개 확인",
|
||||
"areasDoneOther": "영역 {count}개 확인"
|
||||
}
|
||||
},
|
||||
"relativeJustNow": "방금",
|
||||
|
||||
+9
-5
@@ -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",
|
||||
@@ -3128,6 +3123,15 @@
|
||||
"saving": "Zapisywanie…",
|
||||
"saveFailed": "Nie udało się zapisać zmiany.",
|
||||
"allSettings": "Wszystkie ustawienia AI"
|
||||
},
|
||||
"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ą",
|
||||
|
||||
+35
-4
@@ -1766,10 +1766,32 @@ async function seed() {
|
||||
[convoId, userId, "Blood pressure trend", convoStart],
|
||||
);
|
||||
|
||||
// The provenance uses the contract tokens (`bp`, `resting_hr`, `sleep`):
|
||||
// the client names an area only through a known token, so a token it
|
||||
// cannot name never reaches the page. The steps give the answer its
|
||||
// "Looked at 3 sources" header the way a live turn would; they carry no
|
||||
// counts, since a count is the server's tally and the seed does not
|
||||
// run a tool.
|
||||
const seedStep = (
|
||||
id: string,
|
||||
tool: string,
|
||||
domain: string,
|
||||
domainLabel: string,
|
||||
) => ({
|
||||
id,
|
||||
tool,
|
||||
labelKey: "coach.step.readWindow",
|
||||
label: `Checking: ${domainLabel}, last 30 days`,
|
||||
domain,
|
||||
window: "last30days",
|
||||
status: "done",
|
||||
});
|
||||
const coachTurns: Array<{
|
||||
role: string;
|
||||
content: string;
|
||||
metricSourceJson?: string;
|
||||
tokensUsed?: number;
|
||||
model?: string;
|
||||
}> = [
|
||||
{
|
||||
role: "user",
|
||||
@@ -1785,9 +1807,16 @@ async function seed() {
|
||||
"paying off — worth staying consistent with the morning measurements " +
|
||||
"so the trend stays easy to read.",
|
||||
metricSourceJson: JSON.stringify({
|
||||
window: "last30days",
|
||||
metrics: ["bloodPressure", "restingHeartRate", "sleep"],
|
||||
windows: ["last30days"],
|
||||
metrics: ["bp", "resting_hr", "sleep"],
|
||||
steps: [
|
||||
seedStep("s1", "get_metric_series", "bp", "Blood pressure"),
|
||||
seedStep("s2", "get_metric_series", "resting_hr", "Resting HR"),
|
||||
seedStep("s3", "get_sleep", "sleep", "Sleep"),
|
||||
],
|
||||
}),
|
||||
tokensUsed: 1840,
|
||||
model: aiModel,
|
||||
},
|
||||
];
|
||||
for (let t = 0; t < coachTurns.length; t++) {
|
||||
@@ -1795,8 +1824,8 @@ async function seed() {
|
||||
const ts = new Date(convoStart.getTime() + t * 60_000);
|
||||
const encrypted = Buffer.from(encryptToBytes(turn.content));
|
||||
await client.query(
|
||||
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
`INSERT INTO coach_messages (id, conversation_id, role, encrypted_content, metric_source_json, provider_type, prompt_version, tokens_used, model, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
cuid(),
|
||||
convoId,
|
||||
@@ -1805,6 +1834,8 @@ async function seed() {
|
||||
turn.metricSourceJson ?? null,
|
||||
turn.role === "assistant" ? aiProvider : null,
|
||||
turn.role === "assistant" ? "demo" : null,
|
||||
turn.tokensUsed ?? null,
|
||||
turn.model ?? null,
|
||||
ts,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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'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'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"
|
||||
|
||||
Reference in new issue
Block a user