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 ? ( -