diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index 80374f8ed..abc0c487d 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -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, )), ); }); diff --git a/e2e/coach-answer-layout.spec.ts b/e2e/coach-answer-layout.spec.ts new file mode 100644 index 000000000..b4aec3b52 --- /dev/null +++ b/e2e/coach-answer-layout.spec.ts @@ -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(); + }); +}); diff --git a/e2e/coach-dialog.spec.ts b/e2e/coach-dialog.spec.ts index 513d778e5..a5dfc1478 100644 --- a/e2e/coach-dialog.spec.ts +++ b/e2e/coach-dialog.spec.ts @@ -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. diff --git a/e2e/coach-results.spec.ts b/e2e/coach-results.spec.ts index a0f4d67af..ca211ec63 100644 --- a/e2e/coach-results.spec.ts +++ b/e2e/coach-results.spec.ts @@ -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"]', diff --git a/messages/de.json b/messages/de.json index 617fb1412..5e157f269 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 856fdecd4..3a6b94269 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 2ff2debd6..f12c10f61 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 78bb57638..8865160fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 17bcdf252..9f93a1e6d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a840c5328..4c5b57898 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "방금", diff --git a/messages/pl.json b/messages/pl.json index 12af86e9f..cb2f76da1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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ą", diff --git a/scripts/seed-demo.ts b/scripts/seed-demo.ts index 33167d40a..60eb03e4d 100644 --- a/scripts/seed-demo.ts +++ b/scripts/seed-demo.ts @@ -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, ], ); diff --git a/src/__tests__/success-affordance-guard.test.ts b/src/__tests__/success-affordance-guard.test.ts index 927aaf7b6..bd9e68c89 100644 --- a/src/__tests__/success-affordance-guard.test.ts +++ b/src/__tests__/success-affordance-guard.test.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. diff --git a/src/components/insights/coach-panel/__tests__/message-actions.test.tsx b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx new file mode 100644 index 000000000..0d53a6e70 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/message-actions.test.tsx @@ -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(); + return { + ...actual, + useClipboardSupported: () => true, + ReadAloudButton: () => ( + - ); -} - -/** - * 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 ( - - ); -} - -/** - * 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 ( - - - - {label} - - - ); -} - 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 `
` 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 `
` - // 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. */} - {/* 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). */} -
- - {createdAt && } -
- {/* 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 && ( - - )} + {/* 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. */} + {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 `
` 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:` 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({ )} - {/* 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. */}
- {/* 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. */} 0 ? ( + + ) : 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) && (
)} - {/* 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 `
`, 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 && ( -
- 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", - )} - > - - -
- {/* 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 && ( - - )} - {keyValues.length > 0 && ( -
    - {keyValues.map((kv, idx) => ( -
  • - {/* 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?". */} - - {kv.value} - {kv.unit ? ` ${kv.unit}` : ""} - - {kv.window && ( - - {" "} - ({kv.window}) - - )} -
  • - ))} -
- )} - {/* v1.39.4 — how the answer was worked out, beside the key - values it explains. */} - - {/* v1.39.4 — the tables the answer read but did not point at. */} - {dataUsedCount > 0 && ( - - )} -
-
- )} {/* 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 ? : 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" && ( - )} - {/* 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 && ( -
- - {!streaming && } - {messageId && } - {!streaming && onRegenerate && ( - - )} - {createdAt && } -
- )} + {/* The follow-up chips of the latest answer, in its own column. */} + {followUps && onFollowUp ? ( + + ) : null}
); @@ -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 . The confirmation paragraph takes - // the focus instead (`tabIndex={-1}` + programmatic focus) so the - // reading position survives the swap. - const statusRef = useRef(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 ( -

-

- ); - } - - return ( - - ); -} - /** * 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 }) { ); } - -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 ( - - {t("insights.coach.feedbackThanks")} - {submittedRating === "helpful" ? ( - - ); - } - - const helpfulLabel = t("insights.coach.feedbackHelpful"); - const unhelpfulLabel = t("insights.coach.feedbackUnhelpful"); - return ( -
- - -
- ); -} diff --git a/src/components/insights/coach-panel/follow-up-chips.tsx b/src/components/insights/coach-panel/follow-up-chips.tsx index 525319dcc..64e5561c6 100644 --- a/src/components/insights/coach-panel/follow-up-chips.tsx +++ b/src/components/insights/coach-panel/follow-up-chips.tsx @@ -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) => ( + ); +} + +/** Re-runs the question that produced this answer. */ +function TryAgainButton({ onRegenerate }: { onRegenerate: () => void }) { + const { t } = useTranslations(); + const label = t("insights.coach.regenerate"); + return ( + + ); +} + +/** + * 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 ( + + ); +} + +/** + * 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 ( + + ); +} + +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 ( +
+
+ + {!streaming && } + {!streaming && onRegenerate && ( + + )} + {createdAt && } +
+

+ {meta} +

+
+ ); +} + +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 ( +
+ + {canRemember && } + {createdAt && } +
+ ); +} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index e87246f5e..c80672533 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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(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 (
+ {/* 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. */}
*]: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; +
*]: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 = ( - 0 - ? { results: streaming.results } - : {})} - onRegenerate={ - precedingUserContent !== null && onRegenerate - ? () => onRegenerate(precedingUserContent as string) - : undefined - } - /> - ); - const key = messageRenderKey(liveKeys, m.id); - return ( - - {guidedBefore?.node} - {m.role === "assistant" ? ( - - {bubble} - - ) : ( - bubble - )} - - ); - }), - // v1.16.5 — guided question whose answer is still optimistic-only. - ...placement.beforeOptimistic.map((i) => ( - {i.node} - )), - // 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 ? ( - - ) : null, - streamingActive && streaming ? ( - // Same key, fragment and wrapper as its persisted copy above. - - {null} - + const bubble = ( 0 + ? { results: streaming.results } + : {})} + {...(m.role === "assistant" ? chipsFor(m.id) : {})} + onRegenerate={ + precedingUserContent !== null && onRegenerate + ? () => onRegenerate(precedingUserContent as string) + : undefined + } /> - - - ) : null, - ]} - {/* v1.39.4 — follow-up chips under the latest assistant turn only. */} - {onFollowUp && chips ? ( - - ) : null} - {/* v1.16.5 — thread tail: the current guided question and/or the + ); + const key = messageRenderKey(liveKeys, m.id); + return ( + + {guidedBefore?.node} + {m.role === "assistant" ? ( + + {bubble} + + ) : ( + bubble + )} + + ); + }), + // v1.16.5 — guided question whose answer is still optimistic-only. + ...placement.beforeOptimistic.map((i) => ( + {i.node} + )), + // 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 ? ( + + ) : null, + streamingActive && streaming ? ( + // Same key, fragment and wrapper as its persisted copy above. + + {null} + + + + + ) : null, + ]} + {/* v1.16.5 — thread tail: the current guided question and/or the closing summary follow the last completed turn. */} - {placement.tail.map((i) => ( - {i.node} - ))} - {/* Plan-proposal confirm cards for THIS conversation. The extractor + {placement.tail.map((i) => ( + {i.node} + ))} + {/* Plan-proposal confirm cards for THIS conversation. The extractor runs after the turn (memory-refresh worker), so the block appears at the thread tail once the proposal lands rather than under a specific bubble; the component itself slow-polls and renders nothing while the conversation has no open proposal. */} - {conversation ? ( - - ) : null} + {conversation ? ( + + ) : null} +
+
); diff --git a/src/components/insights/coach-panel/message-token-footer.tsx b/src/components/insights/coach-panel/message-token-footer.tsx deleted file mode 100644 index 0efc03521..000000000 --- a/src/components/insights/coach-panel/message-token-footer.tsx +++ /dev/null @@ -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 ( -

- {label} -

- ); -} diff --git a/src/components/insights/coach-panel/method-line.tsx b/src/components/insights/coach-panel/method-line.tsx index 60a2227b3..3b6246b8e 100644 --- a/src/components/insights/coach-panel/method-line.tsx +++ b/src/components/insights/coach-panel/method-line.tsx @@ -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"; diff --git a/src/components/insights/coach-panel/read-aloud.tsx b/src/components/insights/coach-panel/read-aloud.tsx index ac94adba6..2fe931eae 100644 --- a/src/components/insights/coach-panel/read-aloud.tsx +++ b/src/components/insights/coach-panel/read-aloud.tsx @@ -260,9 +260,9 @@ export function ReadAloudButton({ content }: { content: string }) { className={COACH_ICON_BUTTON} > {speaking ? ( -