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 (
+
= {}): 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[^)]*\),
+ );
+ });
+
+ it("says so without a date when the earlier message is not at hand", () => {
+ 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(/
+ {/* 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 = (
-
- {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({