From 98920886208671722e1398f69cd44b2cdb991c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 27 Sep 2026 13:07:07 +0200 Subject: [PATCH] fix(coach): account for every stored table and keep table controls on screen A withheld table now leaves a line saying why (module off, or the stored tables could not be read) and a failed read shows an error row with a retry, so the Data used count matches what is listed. A table copied from an earlier answer names that answer's date under its title. The preview shows the latest twelve periods of a time series, and the truncation note says the latest rows are kept, which is what the server keeps. The title, copy and view controls sit above the scrolling table with the title kept as a hidden caption, and a reply with tables takes the full column width. The step headers and the related-metric chip read as label: value in all seven locales, so a metric name no longer sits capitalised mid-sentence. --- messages/de.json | 10 +- messages/en.json | 6 +- messages/es.json | 10 +- messages/fr.json | 6 +- messages/it.json | 10 +- messages/ko.json | 10 +- messages/pl.json | 4 +- .../__tests__/coach-results-states.test.tsx | 242 ++++++++++++++++ .../__tests__/follow-up-chips.test.tsx | 17 +- .../__tests__/message-thread.test.tsx | 85 +++++- .../__tests__/result-table.test.tsx | 42 ++- .../insights/coach-panel/chat-bubble.tsx | 11 +- .../insights/coach-panel/coach-results.tsx | 177 +++++++++--- .../insights/coach-panel/message-thread.tsx | 261 +++++++++--------- .../insights/coach-panel/result-table.tsx | 80 +++--- src/hooks/use-coach-message-results.ts | 3 + src/lib/ai/coach/dialog-keys.ts | 2 + src/lib/ai/coach/turn/__tests__/steps.test.ts | 2 +- 18 files changed, 761 insertions(+), 217 deletions(-) create mode 100644 src/components/insights/coach-panel/__tests__/coach-results-states.test.tsx diff --git a/messages/de.json b/messages/de.json index a9dd2e313..90964abea 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9490,8 +9490,8 @@ }, "noRecordedData": "Zu deiner Frage sind bei dir keine Messwerte erfasst, also kann ich dir dazu keine Zahl nennen. Sobald du etwas eingetragen hast, frag mich noch einmal — dann rechne ich mit deinen eigenen Werten.", "step": { - "read": "Lese {domain}", - "readWindow": "Lese {domain}, {window}", + "read": "Prüfe: {domain}", + "readWindow": "Prüfe: {domain}, {window}", "snapshot": "Lese die Übersicht deiner Daten", "showResult": "Öffne eine frühere Tabelle", "reuse": "Nutze die Tabelle von vorhin", @@ -9595,13 +9595,15 @@ "viewTable": "Tabelle", "chartSummary": "{title} als Diagramm. Die Tabellenansicht listet jeden Wert auf.", "dataUsed": "Verwendete Daten ({count})", - "truncated": "Die ersten {shown} von {total} Zeilen", + "truncated": "Die letzten {shown} von {total} Zeilen", "withheld": { "moduleDisabled": "Diese Tabelle ist ausgeblendet, weil ihr Modul ausgeschaltet ist.", "unavailable": "Diese Tabelle konnte nicht geladen werden." }, "loading": "Tabelle wird geladen…", "loadFailed": "Die Tabellen konnten nicht geladen werden.", + "reusedFrom": "Aus einer früheren Antwort ({date})", + "reusedFromUndated": "Aus einer früheren Antwort", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "Mit dem Vorjahr vergleichen", "asChart": "Als Diagramm zeigen", "asTable": "Als Tabelle zeigen", - "relatedMetric": "Auch {metric} ansehen", + "relatedMetric": "Siehe auch: {metric}", "continue": "Weitersuchen", "settingLabel": "Anschlussfragen vorschlagen", "settingHint": "Zeigt unter der letzten Antwort des Coaches bis zu drei Vorschläge für die nächste Frage." diff --git a/messages/en.json b/messages/en.json index 5c8867cc1..b9eae3f92 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9595,13 +9595,15 @@ "viewTable": "Table", "chartSummary": "{title}, shown as a chart. The table view lists every value.", "dataUsed": "Data used ({count})", - "truncated": "Showing the first {shown} of {total} rows", + "truncated": "Showing the latest {shown} of {total} rows", "withheld": { "moduleDisabled": "This table is hidden because its module is switched off.", "unavailable": "This table couldn't be loaded." }, "loading": "Loading table…", "loadFailed": "Couldn't load the tables.", + "reusedFrom": "From an earlier answer ({date})", + "reusedFromUndated": "From an earlier answer", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "Compare with a year earlier", "asChart": "Show as a chart", "asTable": "Show as a table", - "relatedMetric": "Look at {metric} too", + "relatedMetric": "See also: {metric}", "continue": "Keep looking", "settingLabel": "Suggest follow-up questions", "settingHint": "Show up to three follow-up suggestions under the Coach's latest answer." diff --git a/messages/es.json b/messages/es.json index 934eed626..3dca0af66 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9490,8 +9490,8 @@ }, "noRecordedData": "No tienes ninguna medición registrada sobre lo que preguntas, así que no puedo darte ninguna cifra. Cuando registres algo, vuelve a preguntarme y trabajaré con tus propios datos.", "step": { - "read": "Leyendo {domain}", - "readWindow": "Leyendo {domain}, {window}", + "read": "Consultando: {domain}", + "readWindow": "Consultando: {domain}, {window}", "snapshot": "Leyendo el resumen de tus datos", "showResult": "Abriendo una tabla anterior", "reuse": "Usando la tabla de antes", @@ -9595,13 +9595,15 @@ "viewTable": "Tabla", "chartSummary": "{title}, mostrado como gráfico. La vista de tabla recoge cada valor.", "dataUsed": "Datos usados ({count})", - "truncated": "Se muestran las primeras {shown} de {total} filas", + "truncated": "Se muestran las últimas {shown} de {total} filas", "withheld": { "moduleDisabled": "Esta tabla está oculta porque su módulo está desactivado.", "unavailable": "No se pudo cargar esta tabla." }, "loading": "Cargando tabla…", "loadFailed": "No se pudieron cargar las tablas.", + "reusedFrom": "De una respuesta anterior ({date})", + "reusedFromUndated": "De una respuesta anterior", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "Comparar con un año antes", "asChart": "Mostrar como gráfico", "asTable": "Mostrar como tabla", - "relatedMetric": "Ver también {metric}", + "relatedMetric": "Ver también: {metric}", "continue": "Seguir buscando", "settingLabel": "Sugerir preguntas de seguimiento", "settingHint": "Muestra hasta tres sugerencias bajo la última respuesta del Coach." diff --git a/messages/fr.json b/messages/fr.json index fe3e5f3f3..d7e6af27c 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9595,13 +9595,15 @@ "viewTable": "Tableau", "chartSummary": "{title}, sous forme de graphique. La vue tableau liste chaque valeur.", "dataUsed": "Données utilisées ({count})", - "truncated": "Affichage des {shown} premières lignes sur {total}", + "truncated": "Affichage des {shown} dernières lignes sur {total}", "withheld": { "moduleDisabled": "Ce tableau est masqué car son module est désactivé.", "unavailable": "Impossible de charger ce tableau." }, "loading": "Chargement du tableau…", "loadFailed": "Impossible de charger les tableaux.", + "reusedFrom": "D'une réponse précédente ({date})", + "reusedFromUndated": "D'une réponse précédente", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "Comparer avec l'an dernier", "asChart": "Afficher en graphique", "asTable": "Afficher en tableau", - "relatedMetric": "Regarder aussi {metric}", + "relatedMetric": "Voir aussi : {metric}", "continue": "Continuer à chercher", "settingLabel": "Suggérer des questions de suivi", "settingHint": "Affiche jusqu'à trois suggestions sous la dernière réponse du Coach." diff --git a/messages/it.json b/messages/it.json index 607881026..aeb29b538 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9490,8 +9490,8 @@ }, "noRecordedData": "Non hai misurazioni registrate su quello che chiedi, quindi non posso darti alcun valore. Appena ne inserisci qualcuna, richiedimelo e userò i tuoi dati.", "step": { - "read": "Lettura di {domain}", - "readWindow": "Lettura di {domain}, {window}", + "read": "Lettura: {domain}", + "readWindow": "Lettura: {domain}, {window}", "snapshot": "Lettura del riepilogo dei tuoi dati", "showResult": "Apertura di una tabella precedente", "reuse": "Uso della tabella di prima", @@ -9595,13 +9595,15 @@ "viewTable": "Tabella", "chartSummary": "{title}, mostrato come grafico. La vista tabella elenca ogni valore.", "dataUsed": "Dati usati ({count})", - "truncated": "Prime {shown} righe su {total}", + "truncated": "Ultime {shown} righe su {total}", "withheld": { "moduleDisabled": "Questa tabella è nascosta perché il suo modulo è disattivato.", "unavailable": "Impossibile caricare questa tabella." }, "loading": "Caricamento della tabella…", "loadFailed": "Impossibile caricare le tabelle.", + "reusedFrom": "Da una risposta precedente ({date})", + "reusedFromUndated": "Da una risposta precedente", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "Confronta con un anno prima", "asChart": "Mostra come grafico", "asTable": "Mostra come tabella", - "relatedMetric": "Guarda anche {metric}", + "relatedMetric": "Vedi anche: {metric}", "continue": "Continua a cercare", "settingLabel": "Suggerisci domande di approfondimento", "settingHint": "Mostra fino a tre suggerimenti sotto l'ultima risposta del Coach." diff --git a/messages/ko.json b/messages/ko.json index 1de539767..08a012016 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9490,8 +9490,8 @@ }, "noRecordedData": "물어보신 내용에 대해 기록된 측정값이 없어서 수치를 알려드릴 수 없어요. 기록을 남기신 뒤에 다시 물어보시면 본인 수치로 답해 드릴게요.", "step": { - "read": "{domain} 확인 중", - "readWindow": "{domain}, {window} 확인 중", + "read": "확인 중: {domain}", + "readWindow": "확인 중: {domain}, {window}", "snapshot": "기록 요약 확인 중", "showResult": "이전 표 여는 중", "reuse": "앞서 만든 표 사용 중", @@ -9595,13 +9595,15 @@ "viewTable": "표", "chartSummary": "{title}을(를) 차트로 표시해요. 표 보기에서 모든 값을 확인할 수 있어요.", "dataUsed": "사용한 데이터 ({count})", - "truncated": "전체 {total}행 중 처음 {shown}행 표시", + "truncated": "전체 {total}행 중 최근 {shown}행 표시", "withheld": { "moduleDisabled": "해당 모듈이 꺼져 있어 이 표를 숨겼어요.", "unavailable": "이 표를 불러오지 못했어요." }, "loading": "표 불러오는 중…", "loadFailed": "표를 불러오지 못했어요.", + "reusedFrom": "이전 답변에서 ({date})", + "reusedFromUndated": "이전 답변에서", "histogramBin": "{from}–{to} {unit}" }, "method": { @@ -9643,7 +9645,7 @@ "yearAgo": "1년 전과 비교", "asChart": "차트로 보기", "asTable": "표로 보기", - "relatedMetric": "{metric}도 보기", + "relatedMetric": "함께 보기: {metric}", "continue": "계속 찾아보기", "settingLabel": "후속 질문 추천", "settingHint": "코치의 가장 최근 답변 아래에 후속 질문을 최대 세 개까지 보여줘요." diff --git a/messages/pl.json b/messages/pl.json index fc35699df..bcf9065b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9595,13 +9595,15 @@ "viewTable": "Tabela", "chartSummary": "{title} w formie wykresu. Widok tabeli pokazuje każdą wartość.", "dataUsed": "Użyte dane ({count})", - "truncated": "Pierwsze {shown} z {total} wierszy", + "truncated": "Ostatnie {shown} z {total} wierszy", "withheld": { "moduleDisabled": "Ta tabela jest ukryta, bo jej moduł jest wyłączony.", "unavailable": "Nie udało się wczytać tej tabeli." }, "loading": "Wczytywanie tabeli…", "loadFailed": "Nie udało się wczytać tabel.", + "reusedFrom": "Z wcześniejszej odpowiedzi ({date})", + "reusedFromUndated": "Z wcześniejszej odpowiedzi", "histogramBin": "{from}–{to} {unit}" }, "method": { diff --git a/src/components/insights/coach-panel/__tests__/coach-results-states.test.tsx b/src/components/insights/coach-panel/__tests__/coach-results-states.test.tsx new file mode 100644 index 000000000..2f2bd5a81 --- /dev/null +++ b/src/components/insights/coach-panel/__tests__/coach-results-states.test.tsx @@ -0,0 +1,242 @@ +/** + * v1.39.4 — what a persisted message's tables look like when not every one + * can be shown: a withheld table leaves a meta line saying why, a failed + * read leaves an error row with a retry, and the "Data used (n)" header + * counts the tables it goes on to list. A table copied from an earlier + * answer says so, with that answer's date. SSR harness with the lazy read + * stubbed, like the other Coach panel tests. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { + CoachResultEntry, + CoachResultMeta, + CoachResultTable as Table, +} from "@/lib/ai/coach/types"; + +const state: { + results: CoachResultEntry[] | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} = { + results: undefined, + isLoading: false, + isError: false, + refetch: () => {}, +}; + +vi.mock("@/hooks/use-coach-message-results", () => ({ + useCoachMessageResults: () => ({ ref: () => {}, ...state }), +})); + +// The error row's own markup is the primitive's business; what matters here +// is which message and which retry it is handed. +const errorRow: { + props: { message?: ReactNode; onRetry?: () => void } | null; +} = { props: null }; +vi.mock("@/components/ui/query-error-row", () => ({ + QueryErrorRow: (props: { + message?: ReactNode; + onRetry?: () => void; + slot?: string; + retrySlot?: string; + }) => { + errorRow.props = props; + return ( +
+ {props.message} + +
+ ); + }, +})); + +import { I18nProvider } from "@/lib/i18n/context"; + +import { CoachMessageDatesProvider, CoachResults } from "../coach-results"; + +function table(overrides: Partial = {}): Table { + return { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: 1, + chartKind: null, + displayed: true, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + { + key: "systolic", + kind: "number", + labelKey: "coach.result.column.systolic", + label: "Systolic", + unit: "mmHg", + decimals: 0, + }, + ], + rows: [["2026-09-01", 124]], + truncated: false, + chart: null, + ...overrides, + }; +} + +function meta(table: Table): CoachResultMeta { + const { columns: _c, rows: _r, truncated: _t, chart: _ch, ...rest } = table; + return rest; +} + +function render(node: ReactNode) { + return renderToStaticMarkup( + {node}, + ); +} + +function persisted( + metas: CoachResultMeta[], + section: "displayed" | "dataUsed", +) { + return render( + , + ); +} + +beforeEach(() => { + state.results = undefined; + state.isLoading = false; + state.isError = false; + state.refetch = () => {}; + errorRow.props = null; +}); + +describe("CoachResults on a persisted message", () => { + it("says why a withheld table is missing, one line per table", () => { + const shown = table({ ref: "r1", displayed: false }); + const off = table({ ref: "r2", displayed: false }); + const lost = table({ ref: "r3", displayed: false }); + state.results = [ + shown, + { ref: "r2", withheld: "module_disabled" }, + { ref: "r3", withheld: "unavailable" }, + ]; + const html = persisted([meta(shown), meta(off), meta(lost)], "dataUsed"); + expect(html).toContain("Data used (3)"); + expect(html).toContain('data-ref="r1"'); + expect(html.match(/data-slot="coach-result-withheld"/g)).toHaveLength(2); + expect(html).toContain( + "This table is hidden because its module is switched off.", + ); + expect(html).toContain("This table couldn't be loaded."); + expect(html).toMatch( + /data-slot="coach-result-withheld"[^>]*class="[^"]*text-muted-foreground/, + ); + }); + + it("counts only the tables it shows or explains in the header", () => { + const one = table({ ref: "r1", displayed: false }); + const two = table({ ref: "r2", displayed: false }); + // The read returned r1 and nothing for r2: r2 is explained, not dropped. + state.results = [one]; + const html = persisted([meta(one), meta(two)], "dataUsed"); + expect(html).toContain("Data used (2)"); + expect(html.match(/data-slot="coach-result-withheld"/g)).toHaveLength(1); + }); + + it("shows a failed read as an error row with a retry", () => { + state.isError = true; + const refetch = vi.fn(); + state.refetch = refetch; + const html = persisted([meta(table())], "displayed"); + expect(html).toContain('data-slot="coach-results-error"'); + expect(html).toContain("Couldn't load the tables."); + expect(html).toContain('data-slot="coach-results-retry"'); + }); + + it("wires the retry to a new read", () => { + state.isError = true; + const refetch = vi.fn(); + state.refetch = refetch; + persisted([meta(table())], "displayed"); + errorRow.props?.onRetry?.(); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); + +describe("a table from an earlier answer", () => { + it("carries a meta line naming that answer's date", () => { + const reused = table({ reusedFrom: { messageId: "m1", ref: "r1" } }); + state.results = [reused]; + const html = render( + + + , + ); + expect(html).toMatch( + /class="text-muted-foreground text-xs">From an earlier answer \([^)]*\b0?3\b[^)]*\) { + const reused = table({ reusedFrom: { messageId: "gone", ref: "r1" } }); + state.results = [reused]; + const html = persisted([meta(reused)], "displayed"); + expect(html).toContain(">From an earlier answer<"); + }); + + it("puts the line under the chart title too", () => { + const reused = table({ + reusedFrom: { messageId: "m1", ref: "r1" }, + chart: { kind: "line", x: "day", series: ["systolic"] }, + chartKind: "line", + }); + const html = render( + , + ); + expect(html).toMatch( + //, + ); + expect(html).toMatch(/]*class="[^"]*\bw-full\b/); + }); + + it("shows no such line on a fresh table", () => { + state.results = [table()]; + const html = persisted([meta(table())], "displayed"); + expect(html).not.toContain("From an earlier answer"); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx b/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx index 32a3f30d9..8010ff450 100644 --- a/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx +++ b/src/components/insights/coach-panel/__tests__/follow-up-chips.test.tsx @@ -12,6 +12,11 @@ vi.mock("@/lib/i18n/context", () => ({ }), })); +const focusComposer = vi.fn(); +vi.mock("../composer-focus", () => ({ + focusCoachComposer: () => focusComposer(), +})); + import { CoachFollowUpChips } from "../follow-up-chips"; import type { CoachFollowUp } from "@/lib/ai/coach/types"; @@ -28,7 +33,7 @@ function chip(id: string, label: string): CoachFollowUp { const CHIPS = [ chip("f1", "Compare with the period before"), - chip("f2", "Look at Sleep too"), + chip("f2", "See also: Sleep"), chip("f3", "Show as a chart"), chip("f4", "A fourth one"), ]; @@ -57,7 +62,7 @@ describe("", () => { const { html, tree } = render(CHIPS); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Suggested follow-ups"'); - expect(html).toContain(">Look at Sleep too"); + expect(html).toContain(">See also: Sleep"); expect(html).not.toContain("A fourth one"); const rendered = buttons(tree); expect(rendered).toHaveLength(3); @@ -73,6 +78,14 @@ describe("", () => { expect(onSelect).toHaveBeenCalledWith(CHIPS[1], "m1"); }); + it("hands focus to the composer, since the tapped chip goes away", () => { + focusComposer.mockClear(); + const { tree, onSelect } = render(CHIPS); + (buttons(tree)[0].props.onClick as () => void)(); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(focusComposer).toHaveBeenCalledTimes(1); + }); + it("is hidden while a turn is in flight, and when there is nothing to offer", () => { expect(render(CHIPS, true).tree).toBeNull(); expect(render([]).tree).toBeNull(); diff --git a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx index 1518762b8..25b85b40b 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -57,12 +57,31 @@ vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), })); +// v1.39.4 — the stored tables of a message, served by the lazy read. Empty +// unless a test hands some in. +const storedResults: { value: CoachResultEntry[] | undefined } = { + value: undefined, +}; +vi.mock("@/hooks/use-coach-message-results", () => ({ + useCoachMessageResults: () => ({ + ref: () => {}, + results: storedResults.value, + isLoading: false, + isError: false, + refetch: () => {}, + }), +})); + import { MessageThread, placeInterleaved, type InterleavedThreadItem, } from "../message-thread"; -import type { CoachConversationDetailDTO } from "@/lib/ai/coach/types"; +import type { + CoachConversationDetailDTO, + CoachResultEntry, + CoachResultTable, +} from "@/lib/ai/coach/types"; import type { CoachStreamingMessage } from "../use-coach"; // v1.18.9 — `CoachStreamingMessage` gained a `usage` field for the @@ -135,6 +154,70 @@ const baseConversation: CoachConversationDetailDTO = { }; describe("", () => { + it("dates a table copied from an earlier answer by that answer, and widens the reply for it", () => { + const reused: CoachResultTable = { + ref: "r1", + source: { + tool: "get_metric_table", + domain: "bp", + window: "last30days", + period: "current", + granularity: "day", + }, + shape: "timeSeries", + titleKey: "coach.result.title.byDay", + title: "Blood pressure by day", + rowCount: 1, + chartKind: null, + displayed: true, + reusedFrom: { messageId: "m2", ref: "r1" }, + columns: [ + { + key: "day", + kind: "period", + labelKey: "coach.result.column.day", + label: "Day", + }, + ], + rows: [["2026-05-09"]], + truncated: false, + chart: null, + }; + const { + columns: _c, + rows: _r, + truncated: _t, + chart: _ch, + ...meta + } = reused; + storedResults.value = [reused]; + try { + const html = render( + , + ); + // m2 was written on 10 May; the copy in m3 names that day. + expect(html).toMatch(/From an earlier answer \([^)]*10[^)]*\)/); + expect(html).toMatch( + /
/, + ); + } finally { + storedResults.value = undefined; + } + }); + it("shows the empty hint when no conversation and no streaming", () => { const html = render(); expect(html).toContain('data-slot="coach-message-thread"'); diff --git a/src/components/insights/coach-panel/__tests__/result-table.test.tsx b/src/components/insights/coach-panel/__tests__/result-table.test.tsx index 63255b912..eef4feb4f 100644 --- a/src/components/insights/coach-panel/__tests__/result-table.test.tsx +++ b/src/components/insights/coach-panel/__tests__/result-table.test.tsx @@ -1,7 +1,7 @@ /** * v1.39.4 — the result table's anatomy: caption with title and method, * column headers scoped, numbers right-aligned in tabular figures, absence - * shown as a dash and announced, and the first twelve rows before + * shown as a dash and announced, and the latest twelve rows before * "Show all". SSR harness, like the other Coach panel tests; the toggle * itself is exercised in the browser suite. */ @@ -93,7 +93,45 @@ describe("CoachResultTable", () => { expect(html).not.toMatch(/>0<\/td>/); }); - it("shows the first twelve rows and offers the rest", () => { + it("previews the latest twelve periods of a time series, oldest first", () => { + const html = render(); + // Row i holds 120.4 + i, shown without decimals: the latest twelve of + // twenty are rows 8..19, values 128..139. + expect(html).not.toMatch(/>120<\/td>/); + expect(html).not.toMatch(/>127<\/td>/); + expect(html).toMatch(/>128<\/td>/); + expect(html).toMatch(/>139<\/td>/); + expect(html.indexOf(">128")).toBeLessThan(html.indexOf(">139")); + }); + + it("previews the first twelve rows of a table that is not a time series", () => { + const html = render( + , + ); + expect(html).toMatch(/>120<\/td>/); + expect(html).not.toMatch(/>139<\/td>/); + }); + + it("keeps the title, copy and view controls above the scrolling table", () => { + const html = render( + } + />, + ); + const header = html.indexOf('data-slot="coach-result-table-header"'); + const scroller = html.indexOf('data-slot="coach-result-table-scroll"'); + expect(header).toBeGreaterThan(-1); + expect(scroller).toBeGreaterThan(header); + expect(html.indexOf('data-slot="test-toolbar"')).toBeLessThan(scroller); + expect(html).toMatch(/
/); + expect(html).toMatch( + /data-slot="coach-result-table"[^>]*class="[^"]*\bw-full\b/, + ); + }); + + it("shows twelve rows and offers the rest", () => { const html = render(); expect(html.match(/]*data-slot="table-row"/g)).toHaveLength( RESULT_TABLE_PREVIEW_ROWS + 1, diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 24c5bce53..3fdd98f51 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -696,6 +696,7 @@ 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") @@ -733,7 +734,15 @@ 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. */} +
{/* v1.39.4 — what the Coach read on this turn: live from the step frames, restored from the provenance on reload. */} | null>( + null, +); + +export function CoachMessageDatesProvider({ + dates, + children, +}: { + dates: ReadonlyMap; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** "From an earlier answer (date)" for a copied table, else null. */ +function useReusedLine(result: CoachResultMeta): string | null { + const { t } = useTranslations(); + const formatters = useFormatters(); + const dates = useContext(CoachMessageDates); + if (!result.reusedFrom) return null; + const iso = dates?.get(result.reusedFrom.messageId); + return iso + ? t(COACH_RESULT_UI_KEYS.reusedFrom, { + date: formatters.dateShortSmart(iso), + }) + : t(COACH_RESULT_UI_KEYS.reusedFromUndated); +} + /** How many tables sit in a section, from the metadata alone. */ export function countResultsInSection( metas: ReadonlyArray>, @@ -73,6 +125,45 @@ export function countResultsInSection( ).length; } +type Withheld = Extract["withheld"]; + +/** One slot of a section: a table to show, or why it is not shown. */ +type SectionItem = + | { ref: string; table: CoachResultTableData } + | { ref: string; withheld: Withheld }; + +/** + * The section's slots, in the order the message lists its tables. A live + * turn has its tables in hand. A persisted message lists them in its + * metadata; each is the fetched table, the reason the server gave for not + * serving it, or `unavailable` when the read came back without it. + */ +function sectionItems(args: { + metas: CoachResultMeta[]; + live: CoachResultTableData[] | undefined; + fetched: CoachResultEntry[] | undefined; + section: CoachResultsSection; +}): SectionItem[] { + const inSection = (displayed: boolean) => + args.section === "displayed" ? displayed : !displayed; + if (args.live && args.live.length > 0) { + return args.live + .filter((table) => inSection(table.displayed)) + .map((table) => ({ ref: table.ref, table })); + } + if (!args.fetched) return []; + const byRef = new Map(args.fetched.map((entry) => [entry.ref, entry])); + return args.metas + .filter((meta) => inSection(meta.displayed)) + .map((meta): SectionItem => { + const entry = byRef.get(meta.ref); + if (!entry) return { ref: meta.ref, withheld: "unavailable" }; + if ("withheld" in entry) + return { ref: meta.ref, withheld: entry.withheld }; + return { ref: meta.ref, table: entry }; + }); +} + export function CoachResults({ conversationId, messageId, @@ -85,41 +176,56 @@ export function CoachResults({ const expected = hasLive ? countResultsInSection(live ?? [], section) : countResultsInSection(metas, section); - const { ref, results } = useCoachMessageResults({ + const { ref, results, isError, refetch } = useCoachMessageResults({ conversationId, messageId, enabled: !hasLive && expected > 0, }); if (expected === 0) return null; - const tables: CoachResultTableData[] = ( - hasLive - ? (live ?? []) - : (results ?? []).filter( - (entry): entry is CoachResultTableData => !("withheld" in entry), - ) - ).filter((table) => - section === "displayed" ? table.displayed : !table.displayed, - ); + const items = sectionItems({ metas, live, fetched: results, section }); const list = (
- {tables.map((table) => ( - void refetch()} /> - ))} + ) : null} + {items.map((item) => + "table" in item ? ( + + ) : ( +

+ {t(COACH_RESULT_WITHHELD_KEYS[item.withheld])} +

+ ), + )}
); if (section === "displayed") return list; return ( -
+

{t(COACH_RESULT_UI_KEYS.dataUsed, { count: expected })}

@@ -139,11 +245,14 @@ function CoachResultView({ }) { const { t } = useTranslations(); const titleId = useId(); + const reusedLine = useReusedLine(result); const hasChart = result.chart !== null; const [view, setView] = useState( hasChart && chartFirst ? "chart" : "table", ); - if (!hasChart) return ; + if (!hasChart) { + return ; + } const toggle = ( @@ -166,7 +275,9 @@ function CoachResultView({ /> ); if (view === "table") { - return ; + return ( + + ); } return (
- - {result.title} + + + {result.title} + + {reusedLine ? ( + + {reusedLine} + + ) : null} {toggle}
diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index 90d6a6763..89233a15e 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -10,6 +10,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { PlanProposalCards } from "./plan-proposal-card"; import { ChatBubble } from "./chat-bubble"; import { CoachFollowUpChips } from "./follow-up-chips"; +import { CoachMessageDatesProvider } from "./coach-results"; import type { CoachConversationDetailDTO, CoachOptimisticUserMessage, @@ -238,6 +239,12 @@ export function MessageThread({ () => conversation?.messages ?? [], [conversation?.messages], ); + // v1.39.4 — when each message was written, so a table copied from an + // earlier answer can name that answer's date. + const messageDates = useMemo( + () => new Map(messages.map((m) => [m.id, m.createdAt])), + [messages], + ); // v1.16.5 — locally-rendered guided bubbles (see `placeInterleaved`). const interleavedItems = interleaved ?? []; // v1.4.20.1 — once the SSE stream emits `done`, the route's @@ -389,144 +396,146 @@ export function MessageThread({ } return ( -
*]: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.4.22 W5 reconcile (Code-MED-3) — suppress the persisted - // twin during the 150ms grace window so the streaming bubble - // stays alone on slow connections. - if (m.id === suppressedTwinId) return null; - // 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.4.22 W5 reconcile (Code-MED-3) — suppress the persisted + // twin during the 150ms grace window so the streaming bubble + // stays alone on slow connections. + if (m.id === suppressedTwinId) return null; + // 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; + } } } - } - return ( - - {guidedBefore?.node} - onRegenerate(precedingUserContent as string) - : undefined - } - /> - - ); - })} - {/* v1.4.25 W5 — optimistic user bubble surfaces between the + return ( + + {guidedBefore?.node} + onRegenerate(precedingUserContent as string) + : undefined + } + /> + + ); + })} + {/* v1.4.25 W5 — optimistic user bubble surfaces between the persisted history and the streaming assistant placeholder so the visible order matches the user's mental model. The send-hook drops it as soon as the SSE `done` frame fires + the invalidate-refetch lands the persisted twin. */} - {/* v1.16.5 — guided question whose answer is still optimistic-only. */} - {placement.beforeOptimistic.map((i) => ( - {i.node} - ))} - {optimisticActive && optimisticUser && ( - - )} - {streamingActive && streaming && ( - // role=log + aria-live=polite so screen-reader users hear the - // assistant prose announce as tokens land. aria-relevant=text - // limits announcements to the streamed content; additions - // covers the bubble-mount edge case. -
; this stays a light - // container fade for the disclosure → prose transition. - className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300" - > + {/* v1.16.5 — guided question whose answer is still optimistic-only. */} + {placement.beforeOptimistic.map((i) => ( + {i.node} + ))} + {optimisticActive && optimisticUser && ( -
- )} - {/* 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 + )} + {streamingActive && streaming && ( + // role=log + aria-live=polite so screen-reader users hear the + // assistant prose announce as tokens land. aria-relevant=text + // limits announcements to the streamed content; additions + // covers the bubble-mount edge case. +
; this stays a light + // container fade for the disclosure → prose transition. + className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-bottom-1 motion-safe:duration-300" + > + +
+ )} + {/* 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 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/result-table.tsx b/src/components/insights/coach-panel/result-table.tsx index e8ecc92a7..121f57eb8 100644 --- a/src/components/insights/coach-panel/result-table.tsx +++ b/src/components/insights/coach-panel/result-table.tsx @@ -3,11 +3,15 @@ /** * v1.39.4 — one table of values a Coach turn read. * - * A captioned table (title, then the method line when the caller passes - * one), numbers right-aligned in tabular figures, a period without a reading - * shown as "—" and announced as "no reading". The first twelve rows show; a - * "Show all (n)" button opens the rest in a scrolling region whose header - * row stays put. Copy for a spreadsheet or as text sits beside the title. + * A title row (title, then the meta line when the caller passes one, with + * copy and the caller's toolbar beside it) above the table, so the controls + * stay on screen however wide the table scrolls; the table keeps the title + * as a visually hidden caption. Numbers are right-aligned in tabular + * figures, a period without a reading shows as "—" and is announced as "no + * reading". Twelve rows show — the latest twelve periods of a time series, + * still oldest first, and the first twelve rows of any other table; a + * "Show all (n)" button opens every row in a scrolling region whose header + * row stays put. * * Periods are calendar days (or the Monday of a week, or a month), so they * are spelled by the UTC-pinned bucket formatters: the label names the day @@ -41,7 +45,7 @@ export const RESULT_TABLE_PREVIEW_ROWS = 12; export interface CoachResultTableProps { result: CoachResultTableData; - /** The method line for this table, shown under the title. */ + /** A meta line for this table (where it came from), shown under the title. */ method?: ReactNode; /** Controls shown beside the copy button (the chart/table toggle). */ toolbar?: ReactNode; @@ -96,6 +100,19 @@ function useCellFormatter(): (grouped: boolean) => CellFormatter { }, [locale, dateFormat]); } +/** + * The rows shown before "Show all". A time series is chronological and the + * latest periods are what the answer is about, so it previews its last + * twelve (still oldest first); any other table previews its first twelve. + */ +function previewRows( + result: Pick, +): CoachResultCell[][] { + return result.shape === "timeSeries" + ? result.rows.slice(-RESULT_TABLE_PREVIEW_ROWS) + : result.rows.slice(0, RESULT_TABLE_PREVIEW_ROWS); +} + function headingText(column: CoachResultColumn): string { return column.unit ? `${column.label} (${column.unit})` : column.label; } @@ -113,9 +130,7 @@ export function CoachResultTable({ const format = useMemo(() => formatter(true), [formatter]); const hasMore = result.rows.length > RESULT_TABLE_PREVIEW_ROWS; - const rows = expanded - ? result.rows - : result.rows.slice(0, RESULT_TABLE_PREVIEW_ROWS); + const rows = expanded ? result.rows : previewRows(result); const grid = useCallback((): ClipboardGrid => { const plain = formatter(false); @@ -136,40 +151,41 @@ export function CoachResultTable({
+
+
+

+ {result.title} +

+ {method ? ( +

{method}

+ ) : null} +
+
+ + {toolbar} +
+
- -
- - - - {result.title} - - {method ? ( - - {method} - - ) : null} - - - - {toolbar} - - + + diff --git a/src/hooks/use-coach-message-results.ts b/src/hooks/use-coach-message-results.ts index 3aeafe801..16ab0532d 100644 --- a/src/hooks/use-coach-message-results.ts +++ b/src/hooks/use-coach-message-results.ts @@ -26,6 +26,8 @@ export function useCoachMessageResults(args: { results: CoachResultEntry[] | undefined; isLoading: boolean; isError: boolean; + /** Read again after a failure. */ + refetch: () => void; } { const { conversationId, messageId, enabled } = args; const [node, setNode] = useState(null); @@ -70,5 +72,6 @@ export function useCoachMessageResults(args: { results: query.data, isLoading: query.isLoading, isError: query.isError, + refetch: () => void query.refetch(), }; } diff --git a/src/lib/ai/coach/dialog-keys.ts b/src/lib/ai/coach/dialog-keys.ts index 54b01c05d..e08694154 100644 --- a/src/lib/ai/coach/dialog-keys.ts +++ b/src/lib/ai/coach/dialog-keys.ts @@ -166,6 +166,8 @@ export const COACH_RESULT_UI_KEYS = { truncated: "coach.result.truncated", loading: "coach.result.loading", loadFailed: "coach.result.loadFailed", + reusedFrom: "coach.result.reusedFrom", + reusedFromUndated: "coach.result.reusedFromUndated", histogramBin: "coach.result.histogramBin", } as const; diff --git a/src/lib/ai/coach/turn/__tests__/steps.test.ts b/src/lib/ai/coach/turn/__tests__/steps.test.ts index ede739550..25a04b54e 100644 --- a/src/lib/ai/coach/turn/__tests__/steps.test.ts +++ b/src/lib/ai/coach/turn/__tests__/steps.test.ts @@ -367,7 +367,7 @@ describe("toStep — mapping", () => { }, }); expect(step).toMatchObject({ id: "s3", status: "done", count: 142 }); - expect(step?.label).toBe("Lese Blutdruck, letzte 90 Tage"); + expect(step?.label).toBe("Prüfe: Blutdruck, letzte 90 Tage"); }); it("maps each miss to its status and reason", () => {
+ {result.title} + {method ? <>. {method} : null}