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}