From a91e1bf0a7226d8bbe85bc62125e3bc7b6901ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 4 Oct 2026 20:02:48 +0200 Subject: [PATCH] Give each Coach answer one action row and keep its chips in its column Under an answer: copy, read aloud, try again and the time as text on the left; tokens and model on the right, on one line down to 390 px. The thumbs leave the web (the feedback route stays for the native client), and so does the "What I'm looking at" disclosure: the method line and the tables the answer read move into the steps list, and an older message without steps lists the areas it drew on, never a raw key. Remember becomes a bookmark icon in the question's row and confirms in place with a toast. Follow-up chips render inside the latest answer's column at its text size. The answer column takes the full width below `sm` and a fixed 80 % above, so nothing moves sideways as a reply streams. A round button fades in when the thread is scrolled away from the end and brings it back. --- e2e/a11y.spec.ts | 17 +- e2e/coach-dialog.spec.ts | 7 +- e2e/coach-results.spec.ts | 14 +- messages/de.json | 16 +- messages/en.json | 16 +- messages/es.json | 16 +- messages/fr.json | 16 +- messages/it.json | 16 +- messages/ko.json | 16 +- messages/pl.json | 16 +- .../success-affordance-guard.test.ts | 9 +- .../__tests__/message-actions.test.tsx | 292 ++++++++ .../__tests__/message-thread.test.tsx | 164 ++--- .../scroll-to-bottom-button.test.tsx | 64 ++ .../__tests__/source-chips.test.tsx | 120 ---- .../coach-panel/__tests__/turn-steps.test.tsx | 85 +++ .../insights/coach-panel/chat-bubble.tsx | 646 +++--------------- .../insights/coach-panel/follow-up-chips.tsx | 11 +- .../insights/coach-panel/message-actions.tsx | 356 ++++++++++ .../insights/coach-panel/message-thread.tsx | 355 +++++----- .../coach-panel/message-token-footer.tsx | 37 - .../insights/coach-panel/method-line.tsx | 7 +- .../insights/coach-panel/read-aloud.tsx | 4 +- .../coach-panel/scroll-to-bottom-button.tsx | 54 ++ .../insights/coach-panel/source-chips.tsx | 110 --- .../insights/coach-panel/turn-steps.tsx | 148 +++- 26 files changed, 1469 insertions(+), 1143 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/message-actions.test.tsx create mode 100644 src/components/insights/coach-panel/__tests__/scroll-to-bottom-button.test.tsx delete mode 100644 src/components/insights/coach-panel/__tests__/source-chips.test.tsx create mode 100644 src/components/insights/coach-panel/message-actions.tsx delete mode 100644 src/components/insights/coach-panel/message-token-footer.tsx create mode 100644 src/components/insights/coach-panel/scroll-to-bottom-button.tsx delete mode 100644 src/components/insights/coach-panel/source-chips.tsx 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-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 4f69db510..973c8060c 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "Der Coach ist in einer Akte, die jemand mit dir teilt, nicht verfügbar.", "errorUnavailable": "Der Coach konnte seine Einstellungen gerade nicht prüfen. Bitte versuch es gleich noch einmal.", "errorOperatorOff": "KI ist auf diesem Server abgeschaltet, deshalb kann der Coach nicht antworten.", - "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten." + "errorCoachOff": "Der Coach ist ausgeschaltet. Du kannst ihn unter Einstellungen, Module wieder einschalten.", + "answer": { + "scrollToBottom": "Zur neuesten Nachricht", + "rememberDone": "Gemerkt", + "timeYesterday": "gestern {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 Bereich angesehen", + "areasDoneFew": "{count} Bereiche angesehen", + "areasDoneOther": "{count} Bereiche angesehen" + } }, "relativeJustNow": "gerade eben", "relativeMinutesAgoOne": "vor {count} Minute", diff --git a/messages/en.json b/messages/en.json index 92d60007b..430f4a3f8 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "The Coach is not available inside a record someone shared with you.", "errorUnavailable": "The Coach could not check its settings just now. Please try again in a moment.", "errorOperatorOff": "AI is switched off on this server, so the Coach cannot answer.", - "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules." + "errorCoachOff": "The Coach is switched off. You can turn it back on in Settings, Modules.", + "answer": { + "scrollToBottom": "Jump to the latest message", + "rememberDone": "Remembered", + "timeYesterday": "yesterday {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "Looked at 1 area", + "areasDoneFew": "Looked at {count} areas", + "areasDoneOther": "Looked at {count} areas" + } }, "relativeJustNow": "just now", "relativeMinutesAgoOne": "{count} minute ago", diff --git a/messages/es.json b/messages/es.json index 7fc4efbfb..6b91771c7 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "El Coach no está disponible dentro de un registro que alguien comparte contigo.", "errorUnavailable": "El Coach no pudo comprobar su configuración ahora. Inténtalo de nuevo en un momento.", "errorOperatorOff": "La IA está desactivada en este servidor, así que el Coach no puede responder.", - "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos." + "errorCoachOff": "El Coach está desactivado. Puedes volver a activarlo en Ajustes, Módulos.", + "answer": { + "scrollToBottom": "Ir al último mensaje", + "rememberDone": "Guardado", + "timeYesterday": "ayer {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 área consultada", + "areasDoneFew": "{count} áreas consultadas", + "areasDoneOther": "{count} áreas consultadas" + } }, "relativeJustNow": "ahora mismo", "relativeMinutesAgoOne": "hace {count} minuto", diff --git a/messages/fr.json b/messages/fr.json index 989e0ab17..b8ab7130a 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "Le Coach n'est pas disponible dans un dossier que quelqu'un partage avec vous.", "errorUnavailable": "Le Coach n'a pas pu vérifier ses réglages pour l'instant. Réessayez dans un instant.", "errorOperatorOff": "L'IA est désactivée sur ce serveur, le Coach ne peut donc pas répondre.", - "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules." + "errorCoachOff": "Le Coach est désactivé. Vous pouvez le réactiver dans Réglages, Modules.", + "answer": { + "scrollToBottom": "Aller au dernier message", + "rememberDone": "Mémorisé", + "timeYesterday": "hier {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 domaine consulté", + "areasDoneFew": "{count} domaines consultés", + "areasDoneOther": "{count} domaines consultés" + } }, "relativeJustNow": "à l’instant", "relativeMinutesAgoOne": "il y a {count} minute", diff --git a/messages/it.json b/messages/it.json index b8d636332..cb4d3d037 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "Il Coach non è disponibile in una cartella che qualcuno condivide con te.", "errorUnavailable": "Il Coach non è riuscito a controllare le sue impostazioni. Riprova tra un momento.", "errorOperatorOff": "L'IA è disattivata su questo server, quindi il Coach non può rispondere.", - "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli." + "errorCoachOff": "Il Coach è disattivato. Puoi riattivarlo in Impostazioni, Moduli.", + "answer": { + "scrollToBottom": "Vai all'ultimo messaggio", + "rememberDone": "Memorizzato", + "timeYesterday": "ieri {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "1 area consultata", + "areasDoneFew": "{count} aree consultate", + "areasDoneOther": "{count} aree consultate" + } }, "relativeJustNow": "proprio ora", "relativeMinutesAgoOne": "{count} minuto fa", diff --git a/messages/ko.json b/messages/ko.json index 5dc5fadf4..40065e832 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": "제공자 요청 제한", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "다른 사람이 공유한 기록 안에서는 코치를 사용할 수 없습니다.", "errorUnavailable": "코치가 지금 설정을 확인하지 못했습니다. 잠시 후 다시 시도해 주세요.", "errorOperatorOff": "이 서버에서 AI가 꺼져 있어 코치가 답변할 수 없습니다.", - "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다." + "errorCoachOff": "코치가 꺼져 있습니다. 설정의 모듈에서 다시 켤 수 있습니다.", + "answer": { + "scrollToBottom": "최신 메시지로 이동", + "rememberDone": "기억함", + "timeYesterday": "어제 {time}", + "timeEarlier": "{date} {time}", + "areasDoneOne": "영역 1개 확인", + "areasDoneFew": "영역 {count}개 확인", + "areasDoneOther": "영역 {count}개 확인" + } }, "relativeJustNow": "방금", "relativeMinutesAgoOne": "{count}분 전", diff --git a/messages/pl.json b/messages/pl.json index b967ecdc4..6db3c1cc1 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", @@ -3113,7 +3108,16 @@ "errorNotPermitted": "Coach nie jest dostępny w kartotece, którą ktoś Ci udostępnił.", "errorUnavailable": "Coach nie mógł teraz sprawdzić swoich ustawień. Spróbuj ponownie za chwilę.", "errorOperatorOff": "AI jest wyłączone na tym serwerze, więc Coach nie może odpowiedzieć.", - "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły." + "errorCoachOff": "Coach jest wyłączony. Możesz go ponownie włączyć w Ustawieniach, Moduły.", + "answer": { + "scrollToBottom": "Przejdź do najnowszej wiadomości", + "rememberDone": "Zapamiętano", + "timeYesterday": "wczoraj {time}", + "timeEarlier": "{date}, {time}", + "areasDoneOne": "Sprawdzono 1 obszar", + "areasDoneFew": "Sprawdzono {count} obszary", + "areasDoneOther": "Sprawdzono {count} obszarów" + } }, "relativeJustNow": "przed chwilą", "relativeMinutesAgoOne": "{count} minutę temu", 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 ? ( -