Rebuild the Coach thinking row as one calm line

While a turn runs the Coach now shows a single row: a brain icon, the
current step in the answer's text size with a shimmer passing through it
and fading dots, and a chevron once there are steps to open. There is no
running clock any more, and the interim table preview with its sparkline
no longer renders under the row; interim tables stay in state until the
answer's own results show them.

When the answer is done the row reads "Thought process · N steps" with
proper plurals, and "Answer needed" while a clarifying question waits.
It never opens by itself, and a row opened during the run closes when
the turn ends. Opened, the steps form a vertical list with an icon per
kind of work on a thin connector line, and a reasoning round shows its
summary under its title.

The shimmer and the dots are a `text-shimmer` utility and a reusable
WaitingText component, both still under reduced motion.

The demo's write block now carries `meta.errorCode: "demo.readOnly"`,
listed in the error-code catalogue, and the Coach answers it with an
honest line instead of the provider-outage copy.

Removes the interim preview component, the elapsed-seconds and summary
helpers, and their message keys in all seven locales.
This commit is contained in:
Marc-André Bombeck committed 2026-10-07 16:55:25 +02:00
1 parent 3df5754ab1
commit dd72634791
32 files changed
+1069 -1312

No files matched your search

+1 -1
View File
@@ -32867,7 +32867,7 @@ components:
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; documents —
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; demo — demo.readOnly; documents —
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
+80 -44
View File
@@ -20,28 +20,29 @@ import {
} from "./setup/coach-live-stream";
/**
* The live trail of a Coach turn: one quiet status line at the top of the
* answer.
* The thinking row of a Coach turn: one quiet row at the top of the answer.
*
* Contracts under test, at 1440 and 390 px, light and dark:
*
* 1. While the turn runs the line says what is happening right now and
* 1. While the turn runs the row says what is happening right now and
* moves with the frames: Thinking… → Fetching blood pressure… →
* Summarising 214 readings… → Writing the answer….
* 2. A table the turn has read shows as a one-line preview before the
* first token.
* 3. Nothing opens by itself: the trail stays closed (`aria-expanded`
* false) in every state, after the answer and after a reload.
* 4. After the answer the line is one summary ("Thought it through, 1
* lookup, 7 s"); the preview gives way to the answer's chart.
* 5. A tap opens the trail; the reasoning text of a round sits behind its
* own closed disclosure.
* Summarising 214 readings… → Writing the answer…, with the words
* shimmering and no clock.
* 2. A table the turn has read shows nothing of its own while the turn
* runs: no second row, no preview, no sparkline.
* 3. Nothing opens by itself: the row stays closed (`aria-expanded`
* false) in every state, after the answer and after a reload; a row
* opened during the run closes when the answer is done.
* 4. After the answer the row reads "Thought process · 4 steps" and the
* table the answer points at renders as its chart.
* 5. A tap opens the steps; a reasoning round shows its summary under
* its title.
* 6. No horizontal scroll, a 44 px tap target on phones, axe clean.
*
* The chat stream is fed frame by frame from the spec
* (`installLiveCoachStream`), so every live state is observed rather than
* flashing by. Set COACH_SCREENSHOTS_DIR to also write screenshots of each
* state.
* state; those runs keep motion on so the shimmer shows mid-sweep.
*/
const CONVERSATION_ID = "coach-activity-e2e";
@@ -250,6 +251,10 @@ const SHOTS = process.env.COACH_SCREENSHOTS_DIR;
async function shot(page: Page, name: string) {
if (!SHOTS) return;
mkdirSync(SHOTS, { recursive: true });
// The pointer off the row and the open/rotate transitions done, so the
// picture shows the resting state; the shimmer keeps running.
await page.mouse.move(0, 0);
await page.waitForTimeout(400);
await page.screenshot({ path: join(SHOTS, `${name}.png`) });
}
@@ -280,6 +285,7 @@ test.describe("Coach live trail", () => {
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, theme);
if (SHOTS) await page.emulateMedia({ reducedMotion: "no-preference" });
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
@@ -291,11 +297,16 @@ test.describe("Coach live trail", () => {
const line = turn.locator('[data-slot="coach-turn-steps"]');
const toggle = line.locator('[data-slot="coach-turn-steps-toggle"]');
const current = line.locator('[data-slot="coach-turn-steps-active"]');
const chevron = line.locator('[data-slot="coach-turn-steps-chevron"]');
const column = turn.locator('[data-slot="coach-answer-column"]');
// 1. Before any frame: Thinking…, closed.
// 1. Before any frame: Thinking…, shimmering, nothing to open yet.
await expect(line).toHaveAttribute("data-state", "running");
await expect(current).toHaveText("Thinking…");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(current).toHaveAttribute("data-text", "Thinking…");
await expect(current.locator(".text-shimmer")).toHaveText("Thinking");
await expect(current.locator(".waiting-dot")).toHaveCount(3);
await expect(chevron).toHaveCount(0);
await expect(toggle).toHaveCount(0);
await feed(page, [entry("a1", "thinking", "running", "Thinking…")]);
await feed(page, [
@@ -303,7 +314,9 @@ test.describe("Coach live trail", () => {
title: THINK_TITLE,
}),
]);
await expect(current).toHaveText(THINK_TITLE);
await expect(current).toHaveAttribute("data-text", `${THINK_TITLE}…`);
await expect(chevron).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await feed(page, [
entry("a1", "thinking", "done", "Thinking…", {
@@ -322,11 +335,12 @@ test.describe("Coach live trail", () => {
},
),
]);
await expect(current).toHaveText(
await expect(current).toHaveAttribute(
"data-text",
"Fetching blood pressure, last 30 days…",
);
// 2. The table it read previews before the first token.
// 2. The table it read shows nothing of its own while the turn runs.
await feed(page, [
{ type: "result", result: BP_TABLE, interim: true },
{ type: "step", step: { ...STEP, status: "done", count: 214 } },
@@ -345,12 +359,16 @@ test.describe("Coach live trail", () => {
count: 214,
}),
]);
await expect(current).toHaveText("Summarising 214 readings…");
const preview = turn.locator(
'[data-slot="coach-interim-result"][data-ref="r1"]',
await expect(current).toHaveAttribute(
"data-text",
"Summarising 214 readings…",
);
await expect(preview).toBeVisible();
await expect(preview).toContainText("Blood pressure by day");
// One row and nothing else in the answer's column: no table title,
// no sparkline, no clock.
await expect(column.locator(":scope > *")).toHaveCount(1);
await expect(turn).not.toContainText("Blood pressure by day");
await expect(turn.locator("svg polyline")).toHaveCount(0);
await expect(line).not.toContainText(/\d\s?s\b/);
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toHaveCount(0);
@@ -358,6 +376,18 @@ test.describe("Coach live trail", () => {
await noHorizontalScroll(page);
await shot(page, `${label}-1-running`);
// A tap opens the steps while the turn runs...
await toggle.click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
await expect(line.locator('[data-slot="coach-turn-step"]')).toHaveCount(
3,
);
await expect(
line.locator('[data-slot="coach-turn-step"]').nth(2),
).toHaveAttribute("data-status", "running");
await noHorizontalScroll(page);
await shot(page, `${label}-2-running-open`);
await feed(page, [
entry("a3", "digest", "done", "214 readings from 1 area", {
count: 214,
@@ -366,11 +396,15 @@ test.describe("Coach live trail", () => {
entry("a4", "answer", "running", "Writing the answer…"),
...tokens(ANSWER),
]);
await expect(current).toHaveText("Writing the answer…");
await expect(current).toHaveAttribute(
"data-text",
"Writing the answer…",
);
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toContainText(ANSWER);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
// ...and stays as the person left it until the turn ends.
await expect(toggle).toHaveAttribute("aria-expanded", "true");
// The persisted copy lands with the refetch after `done`.
stub.messages.push(
@@ -392,7 +426,8 @@ test.describe("Coach live trail", () => {
]);
await end(page);
// 4. One calm summary, still closed; the preview became the chart.
// 4. The thought process and its steps, closed again; the table the
// answer points at is its chart.
const bubble = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER });
@@ -400,13 +435,14 @@ test.describe("Coach live trail", () => {
await expect(settled).toHaveAttribute("data-state", "done");
await expect(
settled.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought it through, 1 lookup, 7 s");
).toHaveText("Thought process · 4 steps");
await expect(settled.locator(".text-shimmer")).toHaveCount(0);
const settledToggle = settled.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(settledToggle).toHaveAttribute("aria-expanded", "false");
await expect(
bubble.locator('[data-slot="coach-interim-result"]'),
settled.locator('[data-slot="coach-turn-steps-panel"]'),
).toHaveCount(0);
await expect(
bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'),
@@ -416,7 +452,7 @@ test.describe("Coach live trail", () => {
).toBeVisible();
expect((await posts(page))[0]).toMatchObject({ message: QUESTION });
await noHorizontalScroll(page);
await shot(page, `${label}-2-settled`);
await shot(page, `${label}-3-settled`);
// 6. A 44 px tap target on phones.
if (viewport.width < 640) {
@@ -424,8 +460,8 @@ test.describe("Coach live trail", () => {
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
// 5. A tap opens the trail; the round's reasoning stays behind its own
// closed disclosure.
// 5. A tap opens the steps; the reasoning round shows its summary
// under its title.
await settledToggle.click();
await expect(settledToggle).toHaveAttribute("aria-expanded", "true");
const panel = settled.locator('[data-slot="coach-turn-steps-panel"]');
@@ -437,15 +473,15 @@ test.describe("Coach live trail", () => {
await expect(
panel.locator('[data-slot="coach-method-line"]'),
).toContainText(METRIC_SOURCE.method.text);
const detail = rows
.nth(0)
.locator('[data-slot="coach-turn-step-detail-toggle"]');
await expect(detail).toHaveAttribute("aria-expanded", "false");
await expect(panel).not.toContainText(THINK_TEXT);
await detail.click();
await expect(panel).toContainText(THINK_TEXT);
await expect(rows.nth(0)).toContainText(THINK_TITLE);
await expect(
rows.nth(0).locator('[data-slot="coach-turn-step-detail"]'),
).toHaveText(THINK_TEXT);
await expect(
panel.locator('[data-slot="coach-turn-step-connector"]'),
).toHaveCount(3);
await noHorizontalScroll(page);
await shot(page, `${label}-3-open`);
await shot(page, `${label}-4-settled-open`);
const axe = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
@@ -470,7 +506,7 @@ test.describe("Coach live trail", () => {
await expect(reloaded).toHaveAttribute("data-state", "done");
await expect(
reloaded.locator('[data-slot="coach-turn-steps-done"]'),
).toHaveText("Thought it through, 1 lookup, 7 s");
).toHaveText("Thought process · 4 steps");
const reloadedToggle = reloaded.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
@@ -479,9 +515,9 @@ test.describe("Coach live trail", () => {
reloaded.locator('[data-slot="coach-turn-steps-panel"]'),
).toHaveCount(0);
await reloadedToggle.click();
await expect(
reloaded.locator('[data-slot="coach-turn-step"]').nth(0),
).toContainText(THINK_TITLE);
const first = reloaded.locator('[data-slot="coach-turn-step"]').nth(0);
await expect(first).toContainText(THINK_TITLE);
await expect(first).toContainText(THINK_TEXT);
});
}
}
+8 -2
View File
@@ -104,7 +104,7 @@ async function ask(
},
},
]);
await expect(live).toHaveText("Asking you…");
await expect(live).toHaveAttribute("data-text", "Asking you…");
stub.messages.push(
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
{
@@ -167,7 +167,13 @@ test.describe("Coach clarifying question", () => {
const choices = pills.locator("[data-choice-id]");
await expect(choices).toHaveCount(2);
await expect(choices.nth(0)).toHaveAttribute("data-choice-id", "c1");
// 4. The trail stays closed.
// 4. The row says an answer is needed, and stays closed.
const row = asked.locator('[data-slot="coach-turn-steps"]');
await expect(row).toHaveAttribute("data-state", "awaiting");
await expect(row.locator('[data-slot="coach-turn-steps-done"]')).toHaveText(
"Answer needed",
);
await expect(row.locator(".text-shimmer")).toHaveCount(0);
await expect(
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
).toHaveAttribute("aria-expanded", "false");
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "In deiner Nachricht stehen Anweisungen, die meine Vorgaben überschreiben sollen. Ich bin der HealthLog-Coach und fasse ausschließlich deine eigenen Gesundheitsdaten zusammen — formuliere deine Frage bitte in diesem Rahmen neu.",
"errorBudget": "Du hast das heutige Coach-Limit erreicht. Das Budget setzt sich um Mitternacht (UTC) zurück.",
"errorProvider": "Der Coach konnte gerade keinen Auswertungs-Anbieter erreichen. Bitte versuche es in einem Moment erneut.",
"errorDemo": "In der Demo antwortet der Coach nicht.",
"errorNoProvider": "Der Coach liest deine erfassten Gesundheitsdaten und beantwortet Fragen dazu (keine medizinische Beratung). Dafür braucht HealthLog einen KI-Anbieter — verbinde deinen eigenen Schlüssel oder ein lokales Modell.",
"errorNoProviderAction": "KI-Anbieter verbinden",
"errorNetwork": "Keine Internetverbindung — versuche es erneut, sobald du online bist.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Unterhaltungen ausblenden",
"heroGreeting": "Frage mich etwas zu deinen Daten",
"heroSubline": "Frag nach deinen Verläufen, Medikamenten oder Messwerten.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Nachgedacht für {seconds}s",
"thinkingExpandAria": "Zeigen, was der Coach berücksichtigt hat",
"thinkingDetail": "Beantwortet aus deinem Gesundheits-Snapshot.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Gemerkt",
"timeYesterday": "gestern {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 Bereich angesehen",
"areasDoneFew": "{count} Bereiche angesehen",
"areasDoneOther": "{count} Bereiche angesehen",
"info": "Details zur Antwort",
"infoModel": "Modell: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Schlägt einen Plan vor…",
"asking": "Fragt nach…",
"answer": "Schreibt die Antwort…",
"summaryOne": "Nachgedacht, {lookups} Abfrage, {seconds} s",
"summaryFew": "Nachgedacht, {lookups} Abfragen, {seconds} s",
"summaryOther": "Nachgedacht, {lookups} Abfragen, {seconds} s",
"summaryNoLookups": "Nachgedacht, {seconds} s",
"stop": {
"budget": "Budget erreicht, antwortet mit dem, was da ist",
"time": "Zeit erreicht, antwortet mit dem, was da ist",
"cap": "Viele Schritte, fasst jetzt zusammen",
"noProgress": "Keine neuen Daten mehr, fasst zusammen"
}
},
"thoughtProcessOne": "Denkprozess · {count} Schritt",
"thoughtProcessFew": "Denkprozess · {count} Schritte",
"thoughtProcessOther": "Denkprozess · {count} Schritte",
"awaitingAnswer": "Antwort nötig"
},
"memory": {
"saved": "Gemerkt: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Antwortvorschläge"
},
"interim": {
"labelOne": "{title}, {window}, {count} Wert",
"labelFew": "{title}, {window}, {count} Werte",
"labelOther": "{title}, {window}, {count} Werte"
},
"chart": {
"compare": {
"periods": "{a} gegen {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Öffne eine frühere Tabelle",
"reuse": "Nutze die Tabelle von vorhin",
"headerActive": "{label}…",
"headerDoneOne": "1 Quelle angesehen",
"headerDoneFew": "{count} Quellen angesehen",
"headerDoneOther": "{count} Quellen angesehen",
"listLabel": "Was der Coach angesehen hat",
"toggleShow": "Anzeigen, was der Coach angesehen hat",
"toggleHide": "Ausblenden, was der Coach angesehen hat",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "I noticed wording that tries to override my instructions. I am the HealthLog Coach and only summarise your own health data — please rephrase your question in those terms.",
"errorBudget": "You've reached today's Coach usage limit. The budget refreshes at midnight UTC.",
"errorProvider": "The Coach could not reach an Insights provider just now. Please try again in a moment.",
"errorDemo": "The Coach doesn't answer in the demo.",
"errorNoProvider": "The Coach reads your logged health data and answers questions about it (not medical advice). HealthLog needs an AI provider for that — connect your own key or a local model.",
"errorNoProviderAction": "Connect an AI provider",
"errorNetwork": "No internet connection — try again once you're back online.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Hide conversations",
"heroGreeting": "Ask me anything about your data",
"heroSubline": "Ask about your trends, medications, or readings.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Thought for {seconds}s",
"thinkingExpandAria": "Show what the Coach considered",
"thinkingDetail": "Answered from your health snapshot.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Remembered",
"timeYesterday": "yesterday {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Looked at 1 area",
"areasDoneFew": "Looked at {count} areas",
"areasDoneOther": "Looked at {count} areas",
"info": "Answer details",
"infoModel": "Model: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Drafting a plan…",
"asking": "Asking you…",
"answer": "Writing the answer…",
"summaryOne": "Thought it through, {lookups} lookup, {seconds} s",
"summaryFew": "Thought it through, {lookups} lookups, {seconds} s",
"summaryOther": "Thought it through, {lookups} lookups, {seconds} s",
"summaryNoLookups": "Thought it through, {seconds} s",
"stop": {
"budget": "Budget reached, answering with what it has",
"time": "Time limit reached, answering with what it has",
"cap": "Many steps, wrapping up",
"noProgress": "No new data, wrapping up"
}
},
"thoughtProcessOne": "Thought process · {count} step",
"thoughtProcessFew": "Thought process · {count} steps",
"thoughtProcessOther": "Thought process · {count} steps",
"awaitingAnswer": "Answer needed"
},
"memory": {
"saved": "Remembered: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Suggested replies"
},
"interim": {
"labelOne": "{title}, {window}, {count} reading",
"labelFew": "{title}, {window}, {count} readings",
"labelOther": "{title}, {window}, {count} readings"
},
"chart": {
"compare": {
"periods": "{a} vs {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Opening an earlier table",
"reuse": "Using the table from before",
"headerActive": "{label}…",
"headerDoneOne": "Looked at 1 source",
"headerDoneFew": "Looked at {count} sources",
"headerDoneOther": "Looked at {count} sources",
"listLabel": "What the Coach looked at",
"toggleShow": "Show what the Coach looked at",
"toggleHide": "Hide what the Coach looked at",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "Tu mensaje contiene instrucciones que pretenden sobrescribir mis indicaciones. Soy el coach de HealthLog y solo resumo tus propios datos de salud — reformula tu pregunta dentro de ese marco.",
"errorBudget": "Has alcanzado el límite diario del coach. El cupo se reinicia a medianoche (UTC).",
"errorProvider": "El coach no ha podido contactar con un proveedor de análisis ahora mismo. Inténtalo en un momento.",
"errorDemo": "En la demo, el Coach no responde.",
"errorNoProvider": "El coach lee tus datos de salud registrados y responde preguntas sobre ellos (no es consejo médico). HealthLog necesita un proveedor de IA para ello: conecta tu propia clave o un modelo local.",
"errorNoProviderAction": "Conectar un proveedor de IA",
"errorNetwork": "Sin conexión a Internet — inténtalo de nuevo cuando vuelvas a estar en línea.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Ocultar conversaciones",
"heroGreeting": "Pregúntame lo que quieras sobre tus datos",
"heroSubline": "Pregunta por tus tendencias, medicamentos o mediciones.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Pensó durante {seconds}s",
"thinkingExpandAria": "Mostrar lo que el Coach tuvo en cuenta",
"thinkingDetail": "Respondido a partir de tu resumen de salud.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Guardado",
"timeYesterday": "ayer {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 área consultada",
"areasDoneFew": "{count} áreas consultadas",
"areasDoneOther": "{count} áreas consultadas",
"info": "Detalles de la respuesta",
"infoModel": "Modelo: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Preparando un plan…",
"asking": "Te pregunta…",
"answer": "Escribiendo la respuesta…",
"summaryOne": "Lo ha pensado, {lookups} consulta, {seconds} s",
"summaryFew": "Lo ha pensado, {lookups} consultas, {seconds} s",
"summaryOther": "Lo ha pensado, {lookups} consultas, {seconds} s",
"summaryNoLookups": "Lo ha pensado, {seconds} s",
"stop": {
"budget": "Presupuesto alcanzado, responde con lo que tiene",
"time": "Tiempo agotado, responde con lo que tiene",
"cap": "Muchos pasos, ahora lo resume",
"noProgress": "No hay datos nuevos, lo resume"
}
},
"thoughtProcessOne": "Razonamiento · {count} paso",
"thoughtProcessFew": "Razonamiento · {count} pasos",
"thoughtProcessOther": "Razonamiento · {count} pasos",
"awaitingAnswer": "Se necesita tu respuesta"
},
"memory": {
"saved": "Recordado: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Respuestas sugeridas"
},
"interim": {
"labelOne": "{title}, {window}, {count} valor",
"labelFew": "{title}, {window}, {count} valores",
"labelOther": "{title}, {window}, {count} valores"
},
"chart": {
"compare": {
"periods": "{a} frente a {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Abriendo una tabla anterior",
"reuse": "Usando la tabla de antes",
"headerActive": "{label}…",
"headerDoneOne": "Consultó 1 fuente",
"headerDoneFew": "Consultó {count} fuentes",
"headerDoneOther": "Consultó {count} fuentes",
"listLabel": "Lo que consultó el Coach",
"toggleShow": "Mostrar lo que consultó el Coach",
"toggleHide": "Ocultar lo que consultó el Coach",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "Ton message contient des instructions qui cherchent à remplacer mes consignes. Je suis le coach HealthLog et je ne résume que tes propres données de santé — reformule ta question dans ce cadre.",
"errorBudget": "Tu as atteint la limite quotidienne du coach. Le quota se réinitialise à minuit (UTC).",
"errorProvider": "Le coach n’a pas pu joindre un fournisseur d’analyse maintenant. Réessaie dans un instant.",
"errorDemo": "Dans la démo, le Coach ne répond pas.",
"errorNoProvider": "Le coach lit tes données de santé enregistrées et répond aux questions à leur sujet (pas un avis médical). HealthLog a besoin d'un fournisseur d'IA pour cela : connecte ta propre clé ou un modèle local.",
"errorNoProviderAction": "Connecter un fournisseur d'IA",
"errorNetwork": "Pas de connexion Internet — réessaie une fois reconnecté.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Masquer les conversations",
"heroGreeting": "Pose-moi une question sur tes données",
"heroSubline": "Pose une question sur tes tendances, médicaments ou mesures.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "A réfléchi pendant {seconds}s",
"thinkingExpandAria": "Montrer ce que le Coach a pris en compte",
"thinkingDetail": "Réponse établie à partir de ton aperçu santé.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Mémorisé",
"timeYesterday": "hier {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 domaine consulté",
"areasDoneFew": "{count} domaines consultés",
"areasDoneOther": "{count} domaines consultés",
"info": "Détails de la réponse",
"infoModel": "Modèle : {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Propose un plan…",
"asking": "Te pose une question…",
"answer": "Rédige la réponse…",
"summaryOne": "Réflexion faite, {lookups} recherche, {seconds} s",
"summaryFew": "Réflexion faite, {lookups} recherches, {seconds} s",
"summaryOther": "Réflexion faite, {lookups} recherches, {seconds} s",
"summaryNoLookups": "Réflexion faite, {seconds} s",
"stop": {
"budget": "Budget atteint, répond avec ce qu'il a",
"time": "Temps écoulé, répond avec ce qu'il a",
"cap": "Beaucoup d'étapes, conclut maintenant",
"noProgress": "Plus de nouvelles données, conclut"
}
},
"thoughtProcessOne": "Raisonnement · {count} étape",
"thoughtProcessFew": "Raisonnement · {count} étapes",
"thoughtProcessOther": "Raisonnement · {count} étapes",
"awaitingAnswer": "Réponse attendue"
},
"memory": {
"saved": "Retenu : {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Réponses suggérées"
},
"interim": {
"labelOne": "{title}, {window}, {count} mesure",
"labelFew": "{title}, {window}, {count} mesures",
"labelOther": "{title}, {window}, {count} mesures"
},
"chart": {
"compare": {
"periods": "{a} contre {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Ouverture d'un tableau précédent",
"reuse": "Reprise du tableau précédent",
"headerActive": "{label}…",
"headerDoneOne": "1 source consultée",
"headerDoneFew": "{count} sources consultées",
"headerDoneOther": "{count} sources consultées",
"listLabel": "Ce que le Coach a consulté",
"toggleShow": "Afficher ce que le Coach a consulté",
"toggleHide": "Masquer ce que le Coach a consulté",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "Nel tuo messaggio ci sono istruzioni che cercano di sovrascrivere le mie indicazioni. Sono il coach di HealthLog e riassumo solo i tuoi dati di salute — riformula la domanda in questo perimetro.",
"errorBudget": "Hai raggiunto il limite giornaliero del coach. Il budget si reimposta a mezzanotte (UTC).",
"errorProvider": "Il coach non è riuscito a contattare un provider di analisi ora. Riprova fra poco.",
"errorDemo": "Nella demo il Coach non risponde.",
"errorNoProvider": "Il coach legge i tuoi dati di salute registrati e risponde a domande su di essi (non è consulenza medica). HealthLog ha bisogno di un provider IA per questo: collega la tua chiave o un modello locale.",
"errorNoProviderAction": "Collega un provider IA",
"errorNetwork": "Nessuna connessione Internet — riprova quando sei di nuovo online.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Nascondi conversazioni",
"heroGreeting": "Chiedimi qualcosa sui tuoi dati",
"heroSubline": "Chiedi dei tuoi andamenti, farmaci o misurazioni.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Ha riflettuto per {seconds}s",
"thinkingExpandAria": "Mostra cosa ha considerato il Coach",
"thinkingDetail": "Risposta basata sul tuo riepilogo di salute.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Memorizzato",
"timeYesterday": "ieri {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "1 area consultata",
"areasDoneFew": "{count} aree consultate",
"areasDoneOther": "{count} aree consultate",
"info": "Dettagli della risposta",
"infoModel": "Modello: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Prepara un piano…",
"asking": "Ti fa una domanda…",
"answer": "Scrive la risposta…",
"summaryOne": "Ci ha pensato, {lookups} ricerca, {seconds} s",
"summaryFew": "Ci ha pensato, {lookups} ricerche, {seconds} s",
"summaryOther": "Ci ha pensato, {lookups} ricerche, {seconds} s",
"summaryNoLookups": "Ci ha pensato, {seconds} s",
"stop": {
"budget": "Budget raggiunto, risponde con ciò che ha",
"time": "Tempo scaduto, risponde con ciò che ha",
"cap": "Molti passaggi, ora riassume",
"noProgress": "Nessun dato nuovo, riassume"
}
},
"thoughtProcessOne": "Ragionamento · {count} passaggio",
"thoughtProcessFew": "Ragionamento · {count} passaggi",
"thoughtProcessOther": "Ragionamento · {count} passaggi",
"awaitingAnswer": "Serve una tua risposta"
},
"memory": {
"saved": "Memorizzato: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Risposte suggerite"
},
"interim": {
"labelOne": "{title}, {window}, {count} valore",
"labelFew": "{title}, {window}, {count} valori",
"labelOther": "{title}, {window}, {count} valori"
},
"chart": {
"compare": {
"periods": "{a} rispetto a {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Apertura di una tabella precedente",
"reuse": "Uso della tabella di prima",
"headerActive": "{label}…",
"headerDoneOne": "1 fonte consultata",
"headerDoneFew": "{count} fonti consultate",
"headerDoneOther": "{count} fonti consultate",
"listLabel": "Cosa ha consultato il Coach",
"toggleShow": "Mostra cosa ha consultato il Coach",
"toggleHide": "Nascondi cosa ha consultato il Coach",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "제 지시를 덮어쓰려는 표현이 보였어요. 저는 HealthLog 코치이고 회원님의 건강 데이터만 정리해 드려요 — 그 범위로 질문을 다시 적어 주세요.",
"errorBudget": "오늘 코치 사용 한도에 도달했어요. 사용량은 UTC 자정에 초기화돼요.",
"errorProvider": "지금은 코치가 인사이트 제공자에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.",
"errorDemo": "데모에서는 코치가 답하지 않아요.",
"errorNoProvider": "코치는 기록된 건강 데이터를 읽고 그에 대한 질문에 답해요(의학적 조언은 아니에요). 그러려면 HealthLog에 AI 제공자가 필요해요 — 직접 발급한 키나 로컬 모델을 연결해 주세요.",
"errorNoProviderAction": "AI 제공자 연결",
"errorNetwork": "인터넷에 연결되어 있지 않아요 — 다시 연결되면 시도해 주세요.",
@@ -3018,7 +3019,6 @@
"hideConversations": "대화 숨기기",
"heroGreeting": "내 데이터에 대해 무엇이든 물어보세요",
"heroSubline": "추이, 약, 측정값에 대해 물어보세요.",
"thinkingElapsed": "{seconds}초",
"thinkingDuration": "{seconds}초 동안 생각했어요",
"thinkingExpandAria": "코치가 참고한 내용 보기",
"thinkingDetail": "건강 스냅샷을 바탕으로 답했어요.",
@@ -3152,9 +3152,6 @@
"rememberDone": "기억함",
"timeYesterday": "어제 {time}",
"timeEarlier": "{date} {time}",
"areasDoneOne": "영역 1개 확인",
"areasDoneFew": "영역 {count}개 확인",
"areasDoneOther": "영역 {count}개 확인",
"info": "답변 정보",
"infoModel": "모델: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "계획을 제안하는 중…",
"asking": "질문하는 중…",
"answer": "답변을 쓰는 중…",
"summaryOne": "생각 완료, 조회 {lookups}회, {seconds}초",
"summaryFew": "생각 완료, 조회 {lookups}회, {seconds}초",
"summaryOther": "생각 완료, 조회 {lookups}회, {seconds}초",
"summaryNoLookups": "생각 완료, {seconds}초",
"stop": {
"budget": "예산 도달, 지금까지의 내용으로 답변",
"time": "시간 제한 도달, 지금까지의 내용으로 답변",
"cap": "단계가 많아 이제 정리하는 중",
"noProgress": "새 데이터가 없어 정리하는 중"
}
},
"thoughtProcessOne": "사고 과정 · {count}단계",
"thoughtProcessFew": "사고 과정 · {count}단계",
"thoughtProcessOther": "사고 과정 · {count}단계",
"awaitingAnswer": "답변이 필요해요"
},
"memory": {
"saved": "기억함: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "추천 답변"
},
"interim": {
"labelOne": "{title}, {window}, 값 {count}개",
"labelFew": "{title}, {window}, 값 {count}개",
"labelOther": "{title}, {window}, 값 {count}개"
},
"chart": {
"compare": {
"periods": "{a} 대 {b}"
@@ -9878,9 +9870,6 @@
"showResult": "이전 표 여는 중",
"reuse": "앞서 만든 표 사용 중",
"headerActive": "{label}…",
"headerDoneOne": "출처 1개 확인함",
"headerDoneFew": "출처 {count}개 확인함",
"headerDoneOther": "출처 {count}개 확인함",
"listLabel": "코치가 확인한 내용",
"toggleShow": "코치가 확인한 내용 보기",
"toggleHide": "코치가 확인한 내용 숨기기",
+6 -17
View File
@@ -2797,6 +2797,7 @@
"refusalInjection": "Twoja wiadomość zawiera instrukcje, które próbują nadpisać moje wytyczne. Jestem coachem HealthLog i podsumowuję wyłącznie twoje własne dane zdrowotne — przeformułuj pytanie w tych ramach.",
"errorBudget": "Osiągnięto dzienny limit coacha. Limit resetuje się o północy (UTC).",
"errorProvider": "Coach nie mógł teraz skontaktować się z dostawcą analizy. Spróbuj za chwilę.",
"errorDemo": "W wersji demo Coach nie odpowiada.",
"errorNoProvider": "Coach czyta Twoje zapisane dane zdrowotne i odpowiada na pytania o nie (to nie porada medyczna). HealthLog potrzebuje do tego dostawcy AI — połącz własny klucz lub model lokalny.",
"errorNoProviderAction": "Połącz dostawcę AI",
"errorNetwork": "Brak połączenia z Internetem — spróbuj ponownie, gdy wrócisz online.",
@@ -3018,7 +3019,6 @@
"hideConversations": "Ukryj rozmowy",
"heroGreeting": "Zapytaj mnie o cokolwiek na temat twoich danych",
"heroSubline": "Zapytaj o swoje trendy, leki lub pomiary.",
"thinkingElapsed": "{seconds} s",
"thinkingDuration": "Myślał przez {seconds}s",
"thinkingExpandAria": "Pokaż, co Coach wziął pod uwagę",
"thinkingDetail": "Odpowiedź na podstawie Twojego podsumowania zdrowia.",
@@ -3152,9 +3152,6 @@
"rememberDone": "Zapamiętano",
"timeYesterday": "wczoraj {time}",
"timeEarlier": "{date}, {time}",
"areasDoneOne": "Sprawdzono 1 obszar",
"areasDoneFew": "Sprawdzono {count} obszary",
"areasDoneOther": "Sprawdzono {count} obszarów",
"info": "Szczegóły odpowiedzi",
"infoModel": "Model: {model}"
},
@@ -3177,16 +3174,16 @@
"plan": "Przygotowuje plan…",
"asking": "Dopytuje…",
"answer": "Pisze odpowiedź…",
"summaryOne": "Przemyślane, {lookups} zapytanie, {seconds} s",
"summaryFew": "Przemyślane, {lookups} zapytania, {seconds} s",
"summaryOther": "Przemyślane, {lookups} zapytań, {seconds} s",
"summaryNoLookups": "Przemyślane, {seconds} s",
"stop": {
"budget": "Budżet wyczerpany, odpowiada na podstawie tego, co ma",
"time": "Limit czasu osiągnięty, odpowiada na podstawie tego, co ma",
"cap": "Wiele kroków, teraz podsumowuje",
"noProgress": "Brak nowych danych, podsumowuje"
}
},
"thoughtProcessOne": "Tok myślenia · {count} krok",
"thoughtProcessFew": "Tok myślenia · {count} kroki",
"thoughtProcessOther": "Tok myślenia · {count} kroków",
"awaitingAnswer": "Potrzebna odpowiedź"
},
"memory": {
"saved": "Zapamiętane: {fact}",
@@ -3209,11 +3206,6 @@
"suggestedReplies": {
"groupLabel": "Proponowane odpowiedzi"
},
"interim": {
"labelOne": "{title}, {window}, {count} pomiar",
"labelFew": "{title}, {window}, {count} pomiary",
"labelOther": "{title}, {window}, {count} pomiarów"
},
"chart": {
"compare": {
"periods": "{a} wobec {b}"
@@ -9878,9 +9870,6 @@
"showResult": "Otwieram wcześniejszą tabelę",
"reuse": "Korzystam z tabeli z poprzedniej odpowiedzi",
"headerActive": "{label}…",
"headerDoneOne": "Sprawdzono 1 źródło",
"headerDoneFew": "Sprawdzono {count} źródła",
"headerDoneOther": "Sprawdzono {count} źródeł",
"listLabel": "Co sprawdził Coach",
"toggleShow": "Pokaż, co sprawdził Coach",
"toggleHide": "Ukryj, co sprawdził Coach",
@@ -159,6 +159,18 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(res.status).toBe(403);
});
it("refuses the Coach chat with a code a client can name", async () => {
// The chat POST is not on the allowlist. The refusal carries a stable
// code, so the Coach says "not in the demo" instead of reading the 403
// as a provider outage.
const res = proxy(makeRequest("/api/insights/chat", "POST"));
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { demo?: boolean; errorCode?: string };
};
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
});
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
// The allowlist pins method per path; admitting the widgets PUT
// must not open DELETE (which wipes the user's dashboard layout).
+54
View File
@@ -1032,6 +1032,60 @@
}
}
/* ── Live status text ────────────────────────────────────────────────
* `text-shimmer`: a band of foreground light sweeps through muted text, for a
* line that says something is still in progress (the Coach's thinking row).
* The gradient is clipped to the glyphs; the band travels left to right once
* every two seconds. Under reduced motion the text is plain muted text.
*
* `waiting-dot`: one of three trailing dots that fade in turn (the caller
* staggers them by 0.2 s). Under reduced motion they stand still at full
* opacity, which the global backstop below would not give them: it pins the
* last frame, and the last frame is dim. */
@keyframes text-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0% 0;
}
}
@utility text-shimmer {
background-image: linear-gradient(
90deg,
var(--muted-foreground) 40%,
var(--foreground) 50%,
var(--muted-foreground) 60%
);
background-size: 250% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: text-shimmer 2s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
background-image: none;
color: var(--muted-foreground);
}
}
@keyframes waiting-dot {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
@utility waiting-dot {
animation: waiting-dot 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
}
}
/* ── Global reduced-motion backstop ──────────────────────────────────
* Defensive catch-all so any animation/transition we forget to gate
* per-component still collapses to near-instant when the user has asked
@@ -1,143 +0,0 @@
/**
* v1.41 — the tables a turn has read so far, one quiet line each, before the
* first token; taken down when the turn is blocked.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { CoachResultTable } from "@/lib/ai/coach/types";
vi.mock("../coach-results", () => ({ CoachResultView: () => null }));
import {
CoachInterimResults,
interimLabel,
interimPreviews,
interimReadingCount,
sparklinePoints,
} from "../interim-results";
import {
upsertActivity,
upsertResult,
withoutWithheldResults,
} from "../use-coach";
function bp(ref: string): CoachResultTable {
return {
ref,
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: 3,
chartKind: "line",
displayed: false,
columns: [
{ key: "day", kind: "period", labelKey: "k", label: "Day" },
{ key: "systolic", kind: "number", labelKey: "k", label: "Systolic" },
{ key: "readings", kind: "count", labelKey: "k", label: "Readings" },
],
rows: [
["2026-09-01", 128, 2],
["2026-09-02", 124, 3],
["2026-09-03", 131, 1],
],
truncated: false,
chart: { kind: "line", x: "day", series: ["systolic"] },
};
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("interim previews", () => {
it("one line per interim table: title, window, readings, a sparkline", () => {
const html = render(
<CoachInterimResults
results={[bp("r1"), bp("r2")]}
interimRefs={["r1"]}
/>,
);
expect(html.match(/data-slot="coach-interim-result"/g)).toHaveLength(1);
expect(html).toContain("Blood pressure by day, last 30 days, 6 readings");
expect(html).toContain('stroke="var(--chart-1)"');
// Closed until tapped, and the arrival fades in only with motion allowed.
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("motion-safe:animate-in");
});
it("shows at most three, and nothing without interim tables", () => {
const four = ["r1", "r2", "r3", "r4"];
expect(interimPreviews(four.map(bp), four)).toHaveLength(3);
expect(
render(<CoachInterimResults results={[bp("r1")]} interimRefs={[]} />),
).toBe("");
});
it("counts readings from the count column, else rows", () => {
expect(interimReadingCount(bp("r1"))).toBe(6);
const noCount = { ...bp("r1"), columns: bp("r1").columns.slice(0, 2) };
expect(interimReadingCount(noCount)).toBe(3);
// The title is the server's, already in the request locale.
expect(interimLabel(bp("r1"), getServerTranslator("de").t, "de")).toBe(
"Blood pressure by day, letzte 30 Tage, 6 Werte",
);
});
it("draws the sparkline inside its box", () => {
const points = sparklinePoints([1, 3, 2], 48, 16).split(" ");
expect(points).toHaveLength(3);
for (const point of points) {
const [x, y] = point.split(",").map(Number);
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(48);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(16);
}
});
});
describe("the stream's bookkeeping", () => {
it("replaces an interim table with its final frame by ref", () => {
const final = { ...bp("r1"), displayed: true };
const next = upsertResult([bp("r1"), bp("r2")], final);
expect(next).toHaveLength(2);
expect(next[0].displayed).toBe(true);
});
it("takes the interim tables down when the turn was blocked", () => {
expect(
withoutWithheldResults([bp("r1"), bp("r2"), bp("r3")], ["r1", "r3"]).map(
(r) => r.ref,
),
).toEqual(["r2"]);
});
it("keeps an entry's title when a later frame leaves it out", () => {
const first = {
id: "a1",
phase: "thinking" as const,
status: "running" as const,
round: 1,
labelKey: "k",
label: "Thinking…",
title: "Comparing months",
};
const done = { ...first, status: "done" as const, durationMs: 900 };
delete (done as { title?: string }).title;
const [entry] = upsertActivity([first], done);
expect(entry.title).toBe("Comparing months");
expect(entry.status).toBe("done");
});
});
@@ -101,12 +101,9 @@ function streaming(
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -255,10 +252,10 @@ describe("<MessageThread>", () => {
);
});
it("folds an older message's provenance into the areas header, never a raw key", () => {
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-turn-steps-done"');
expect(html).toContain("Looked at 1 area");
expect(html).toContain("Thought process · 1 step");
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain("insights.coach.metric.");
expect(html).not.toContain("n=26");
@@ -481,16 +478,16 @@ describe("<MessageThread>", () => {
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain("138 mmHg");
// The areas the answer drew on stay reachable from the header.
expect(html).toContain("Looked at 1 area");
// The areas the answer drew on stay reachable from the row.
expect(html).toContain("Thought process · 1 step");
});
it("names the areas header in German under 'de'", () => {
it("names the thought-process row in German under 'de'", () => {
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain("1 Bereich angesehen");
expect(html).toContain("Denkprozess · 1 Schritt");
expect(html).not.toContain("Worauf bezieht sich das?");
});
@@ -797,6 +794,12 @@ describe("errorCodeToI18nKey", () => {
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
// generic provider copy; the user needs the actionable offline hint.
it("maps the demo's refusal to its own honest line, not the outage copy", () => {
expect(errorCodeToI18nKey("demo.readOnly")).toBe(
"insights.coach.errorDemo",
);
});
it("maps coach.network to the dedicated offline key", () => {
expect(errorCodeToI18nKey("coach.network")).toBe(
"insights.coach.errorNetwork",
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -8,18 +10,18 @@ import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
import { Brain, ChartScatter, Database, Lightbulb, Table2 } from "lucide-react";
import {
CoachTurnActivity,
CoachTurnStepList,
activityLineLabel,
activitySeconds,
activitySummary,
countLookups,
countSources,
currentActivity,
currentStep,
describeStep,
inProgressLabel,
legacyAreaLabels,
openAfter,
stepIcon,
stepLabel,
} from "../turn-activity";
@@ -218,7 +220,133 @@ const SETTLED = [
ANSWER_DONE,
];
describe("the running line", () => {
/** The row's element (button or plain row) and everything inside it. */
function rowHtml(html: string): string {
const match = html.match(
/<(button|div)[^>]*data-slot="coach-turn-steps-(?:toggle|row)"[\s\S]*?<\/\1>/,
);
return match?.[0] ?? "";
}
/** The visible text of a fragment, tags removed. */
function textOf(html: string): string {
let text = html.replace(/<span class="sr-only select-none">, <\/span>/g, "");
for (let prev = ""; prev !== text;) {
prev = text;
text = text.replace(/<[^>]*>/g, "");
}
return text;
}
describe("one row while the Coach works", () => {
it("walks thinking → fetching → summarising on one shimmering row", () => {
const frames = [
[THINKING_RUNNING],
[
{ ...THINKING_RUNNING, status: "done" as const },
{ ...FETCH_BP, status: "running" as const },
],
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
];
const htmls = frames.map((activity) =>
render(<CoachTurnActivity activity={activity} steps={[]} active />),
);
expect(htmls[0]).toContain('data-text="Thinking…"');
expect(htmls[1]).toContain(
'data-text="Fetching blood pressure, last 90 days…"',
);
expect(htmls[2]).toContain('data-text="Summarising 214 readings…"');
for (const html of htmls) {
// Exactly one row, and nothing under it while closed.
expect(
html.match(/data-slot="coach-turn-steps-(toggle|row)"/g),
).toHaveLength(1);
expect(html).toContain('data-state="running"');
expect(html).not.toContain("coach-interim");
expect(html).not.toContain('<svg viewBox="0 0 48 16"');
// The words shimmer; the trailing dots fade on their own.
expect(html).toMatch(/<span class="text-shimmer">[^<]+<\/span>/);
expect(html).toContain('data-slot="waiting-dots"');
expect(html.match(/class="waiting-dot /g)).toHaveLength(3);
expect(html).toContain("[animation-delay:0.2s]");
expect(html).toContain("[animation-delay:0.4s]");
// No clock while it runs, in any form.
expect(html).not.toMatch(/\d+\s?s</);
expect(html).not.toContain("coach-turn-steps-seconds");
expect(html).not.toContain("tabular-nums");
}
});
it("reads brain, text, chevron, in the answer's text size and the muted tone", () => {
const html = render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
);
const row = rowHtml(html);
expect(row.startsWith("<button")).toBe(true);
const order = [
row.indexOf("lucide-brain"),
row.indexOf('data-slot="coach-turn-steps-active"'),
row.indexOf('data-slot="coach-turn-steps-chevron"'),
];
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(row).toContain("lucide-chevron-down");
expect(row).toMatch(/class="[^"]*\bw-fit\b/);
expect(row).toMatch(/class="[^"]*\btext-muted-foreground\b/);
expect(row).toMatch(/class="[^"]*\bhover:text-foreground\b/);
expect(row).toContain("focus-visible:ring-input-focus");
expect(row).not.toMatch(/ring-ring|ring-primary|text-primary/);
expect(html).toMatch(
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-sm\b/,
);
expect(html).not.toMatch(
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-xs\b/,
);
// A stable name while the text keeps changing.
expect(row).toContain('aria-label="Show what the Coach looked at"');
expect(row).toContain('aria-expanded="false"');
});
it("says Thinking… before the first frame, with no chevron and nothing to open", () => {
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
expect(html).toContain('data-text="Thinking…"');
expect(html).toContain("text-shimmer");
expect(html).not.toContain("coach-turn-steps-chevron");
expect(html).not.toContain("aria-expanded");
expect(rowHtml(html).startsWith("<div")).toBe(true);
});
it("names a reasoning title as still going, and a step-only server's step", () => {
expect(
render(
<CoachTurnActivity
activity={[{ ...THINKING_RUNNING, title: "Checking the trend" }]}
steps={[]}
active
/>,
),
).toContain('data-text="Checking the trend…"');
expect(
render(
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
),
).toContain('data-text="Checking: Lab results, last 12 months…"');
});
it("reads in German", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[]} active />,
"de",
);
expect(html).toContain('data-text="Denkt nach…"');
});
});
describe("the labels", () => {
it("names the latest running entry, else the latest one", () => {
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
"a4",
@@ -242,59 +370,134 @@ describe("the running line", () => {
).toBe("Fetching blood pressure, last 90 days…");
});
it("walks thinking → fetching → summarising, one quiet line", () => {
const frames = [
[THINKING_RUNNING],
[
{ ...THINKING_RUNNING, status: "done" as const },
{ ...FETCH_BP, status: "running" as const },
],
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
];
const lines = frames.map((activity) =>
render(
<CoachTurnActivity
activity={activity}
steps={[]}
active
startedAt={Date.now() - 12_000}
/>,
),
it("ends a running line in exactly one ellipsis", () => {
expect(inProgressLabel("Writing the answer")).toBe("Writing the answer…");
expect(inProgressLabel("Writing the answer…")).toBe("Writing the answer…");
expect(inProgressLabel("Writing the answer...")).toBe(
"Writing the answer…",
);
expect(lines[0]).toContain(">Thinking…<");
expect(lines[1]).toContain(">Fetching blood pressure, last 90 days…<");
expect(lines[2]).toContain(">Summarising 214 readings…<");
for (const html of lines) {
expect(html.match(/data-slot="coach-turn-steps-toggle"/g)).toHaveLength(
1,
);
expect(html).toContain('data-state="running"');
expect(html).toContain('aria-label="Show what the Coach looked at"');
// The seconds so far follow the text directly, in the line's own
// muted tone, with the summary's spacing ("12 s"), never pushed to
// the far edge of the column.
expect(html).toMatch(
/data-slot="coach-turn-steps-active"[^>]*>[^<]*<\/span><span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s</,
);
const seconds = html.match(
/<span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>/,
)?.[0];
expect(seconds).toContain("tabular-nums");
expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/);
});
it("picks an icon by the kind of work", () => {
expect(stepIcon("thinking", undefined)).toBe(Lightbulb);
expect(stepIcon("fetch", "get_metric_series")).toBe(Database);
expect(stepIcon("fetch", "get_metric_table")).toBe(Table2);
expect(stepIcon("fetch", "get_correlations")).toBe(ChartScatter);
expect(stepIcon(null, "get_sleep")).toBe(Database);
// The brain belongs to the row, never to a step.
for (const phase of [
"thinking",
"memory",
"fetch",
"digest",
"checkpoint",
"remember",
"plan",
"asking",
"stop",
"answer",
] as const) {
expect(stepIcon(phase, undefined)).not.toBe(Brain);
}
});
});
it("says Thinking… before the first frame arrives", () => {
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
expect(html).toContain(">Thinking…<");
expect(html).toContain('aria-expanded="false"');
describe("after the answer", () => {
it("settles into the thought process and its step count, without shimmer", () => {
const html = render(
<CoachTurnActivity
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
/>,
);
expect(html).toContain('data-state="done"');
expect(html).toMatch(
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
);
expect(html).not.toContain("text-shimmer");
expect(html).not.toContain("waiting-dot");
expect(html).toContain('data-slot="coach-turn-steps-chevron"');
expect(html).not.toMatch(/\d+\s?s</);
// The settled row names itself by its text.
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
});
it("falls back to the step label on a server that sends only steps", () => {
it("counts in the reader's language, with proper plurals", () => {
const de = render(
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
"de",
);
expect(de).toContain("Denkprozess · 1 Schritt<");
const pl = render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
steps={[]}
active={false}
/>,
"pl",
);
expect(pl).toContain("Tok myślenia · 3 kroki<");
});
it("an older message counts its steps, or the areas it drew on", () => {
expect(
render(
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
),
).toContain("Thought process · 2 steps<");
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp"]}
/>,
),
).toContain("Thought process · 1 step<");
});
it("says an answer is needed while a question waits, without shimmer", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
<CoachTurnActivity
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
);
expect(html).toContain('data-state="awaiting"');
expect(html).toMatch(
/data-slot="coach-turn-steps-done"[^>]*>Answer needed</,
);
expect(html).not.toContain("text-shimmer");
expect(
render(
<CoachTurnActivity
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
"de",
),
).toContain(">Antwort nötig<");
});
it("renders nothing for a settled message with nothing to show", () => {
expect(
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
).not.toContain("coach-turn-steps");
});
it("carries a polite status region that starts empty", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
);
expect(html).toContain("Checking: Lab results, last 12 months");
});
});
@@ -320,6 +523,7 @@ describe("nothing opens by itself", () => {
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
],
[
@@ -370,109 +574,78 @@ describe("nothing opens by itself", () => {
for (const [name, node] of states) {
it(`is closed when ${name}`, () => {
const html = render(node);
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain('aria-expanded="true"');
expect(html).not.toContain('rotate-180"');
expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
expect(html).not.toContain('data-slot="coach-turn-step-detail"');
expect(html).not.toContain("The second half of the month runs higher.");
});
}
it("a row opened while the turn ran closes when it ends, and nothing opens it", () => {
// Opened during the run, the turn ends: closed.
expect(openAfter({ open: true, wasActive: true, active: false })).toBe(
false,
);
// Closed stays closed through every move.
for (const [wasActive, active] of [
[true, false],
[false, true],
[true, true],
[false, false],
] as const) {
expect(openAfter({ open: false, wasActive, active })).toBe(false);
}
// A tap on a settled row is the person's, and stays.
expect(openAfter({ open: true, wasActive: false, active: false })).toBe(
true,
);
});
});
describe("after the answer", () => {
const { t } = translators("en");
it("collapses to one calm summary of lookups and seconds", () => {
describe("tokens and motion", () => {
it("uses theme tokens only, and every motion is guarded or turned off by reduced motion", () => {
const html = render(
<CoachTurnActivity
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
);
expect(html).toContain('data-state="done"');
expect(html).toContain(
'data-slot="coach-turn-steps-done" class="min-w-0 truncate">Thought it through, 2 lookups, 12 s<',
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
expect(html).not.toMatch(/text-muted-foreground\/\d/);
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
m[1].split(/\s+/),
);
// No spinner, no running seconds, no stable-name override once settled.
expect(html).not.toContain("coach-turn-steps-spinner");
expect(html).not.toContain("coach-turn-steps-seconds");
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
for (const cls of classes) {
if (cls.startsWith("transition") || cls.startsWith("animate-")) {
throw new Error(`unguarded motion: ${cls}`);
}
}
// A 44 px tap target on phones.
expect(html).toContain("min-h-11");
});
it("counts fetches as lookups and runs a round's fetches side by side", () => {
expect(countLookups(SETTLED)).toBe(2);
// 4 s thinking + max(0.9, 1.4) s fetching + 0.6 s digest + 6 s answer.
expect(activitySeconds(SETTLED)).toBe(12);
expect(activitySeconds([THINKING_RUNNING])).toBeNull();
});
it("uses the live turn's clock when the trail has no durations", () => {
expect(
activitySummary({
activity: [{ ...ANSWER_DONE, durationMs: undefined }],
startedAt: 1_000,
endedAt: 19_000,
t,
locale: "en",
}),
).toBe("Thought it through, 18 s");
});
it("reads in German", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
"de",
it("the shimmer and the dots stop under reduced motion, in the stylesheet", () => {
const css = readFileSync(
join(__dirname, "../../../../app/globals.css"),
"utf8",
);
expect(html).toContain("Nachgedacht, 2 Abfragen, 12 s");
});
it("an older message still folds into its sources", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
const shimmer =
css.match(/@utility text-shimmer \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(shimmer).toContain("background-clip: text");
expect(shimmer).toContain("background-size: 250% 100%");
expect(shimmer).toContain("animation: text-shimmer 2s linear infinite");
expect(shimmer).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*background-image: none;\s*color: var\(--muted-foreground\);/,
);
expect(html).toContain("Looked at 2 sources");
expect(
countSources([BP, { ...BP, id: "s9", period: "previous" }, SLEEP]),
).toBe(2);
});
it("an answer saved before steps folds into its areas", () => {
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp", "sleep"]}
/>,
),
).toContain("Looked at 2 areas");
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp"]}
/>,
"de",
),
).toContain("1 Bereich angesehen");
});
it("renders nothing for a settled message with nothing to show", () => {
expect(
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
).not.toContain("coach-turn-steps");
});
it("carries a polite status region that starts empty", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
expect(css).toMatch(
/@keyframes text-shimmer \{\s*from \{\s*background-position: 100% 0;\s*\}\s*to \{\s*background-position: 0% 0;/,
);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
const dot = css.match(/@utility waiting-dot \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(dot).toContain("animation: waiting-dot 1.4s ease-in-out infinite");
expect(dot).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*opacity: 1;/,
);
});
});
@@ -485,72 +658,6 @@ describe("currentStep", () => {
});
});
describe("tokens and motion", () => {
it("uses theme tokens only, and every motion stops under reduced motion", () => {
const html =
render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
) +
render(
<CoachTurnStepList
steps={[
BP,
SLEEP,
LABS_RUNNING,
{
...SLEEP,
id: "s4",
status: "failed",
reason: "retrieval_failed",
},
]}
active
/>,
);
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
expect(html).not.toMatch(/text-muted-foreground\/\d/);
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
m[1].split(/\s+/),
);
for (const cls of classes) {
if (cls.startsWith("transition")) {
throw new Error(`unguarded transition: ${cls}`);
}
}
expect(html).toContain("animate-spin motion-reduce:animate-none");
// A 44 px tap target on phones.
expect(html).toContain("min-h-11");
});
});
describe("<CoachTurnStepList>", () => {
it("one row per step: domain · window · count or reason, with its status", () => {
const html = render(
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
);
expect(html).toContain('aria-label="What the Coach looked at"');
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
expect(html).toContain('data-status="done"');
expect(html).toContain('data-status="empty"');
expect(html).toContain('data-status="running"');
let text = html.replace(
/<span class="sr-only select-none">, <\/span>/g,
"",
);
for (let prev = ""; prev !== text;) {
prev = text;
text = text.replace(/<[^>]*>/g, "");
}
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
expect(text).toContain("Sleep · last 30 days · no readings");
expect(text).toContain("Lab results · last 12 months");
});
});
describe("legacyAreaLabels", () => {
const { t } = translators("en");
@@ -563,3 +670,22 @@ describe("legacyAreaLabels", () => {
).toEqual(["Blood pressure", "Sleep"]);
});
});
describe("the visible row text", () => {
it("is the status alone", () => {
expect(
textOf(
rowHtml(
render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
),
),
),
).toBe("Thought process · 5 steps");
expect(
textOf(
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
),
).toBe("Thinking...");
});
});
@@ -56,12 +56,9 @@ function streaming(
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal";
import {
parseChartTokens,
tokenToMetric,
@@ -20,7 +21,6 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { CoachTurnActivity } from "./turn-activity";
import { CoachInterimResults } from "./interim-results";
import {
CoachResults,
countResultsInSection,
@@ -100,6 +100,10 @@ export function errorCodeToI18nKey(code: string): string {
return "insights.coach.errorNoProvider";
case "module.disabled":
return "insights.coach.errorCoachOff";
// A demo deploy refuses every write, the chat POST included. That is
// the demo's nature, not an outage, and the line says so.
case DEMO_READ_ONLY_CODE:
return "insights.coach.errorDemo";
default:
// The operator switched the Coach (or all AI) off on this server.
if (code.startsWith("assistant.disabled.")) {
@@ -337,11 +341,6 @@ interface ChatBubbleProps {
* its metadata on `metricSource.activity`; the bubble falls back to that.
*/
activity?: CoachActivity[];
/** v1.41 — the refs of the tables that arrived while the turn ran. */
interimRefs?: string[];
/** v1.41 — when the live turn started and ended on this device. */
startedAt?: number | null;
endedAt?: number | null;
/** v1.41 — the live `memoryNote` frame, with the fact's words. */
memoryNote?: CoachMemoryNoteFrame | null;
}
@@ -416,9 +415,6 @@ export function areChatBubblePropsEqual(
prev.onFollowUp === next.onFollowUp &&
prev.replies === next.replies &&
prev.activity === next.activity &&
prev.interimRefs === next.interimRefs &&
prev.startedAt === next.startedAt &&
prev.endedAt === next.endedAt &&
prev.memoryNote === next.memoryNote &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -449,9 +445,6 @@ function ChatBubbleImpl({
onFollowUp,
replies,
activity,
interimRefs,
startedAt,
endedAt,
memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
@@ -645,18 +638,22 @@ function ChatBubbleImpl({
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
{/* v1.41 — what the Coach is doing, one quiet line at the top of the
answer: live from the activity frames while the turn runs, one
calm summary once it is done. Nothing opens by itself; a tap on
the line opens the trail, which also says how the answer was
worked out and holds the tables the answer read. A message saved
before v1.41 falls back to its steps, or the areas it drew on. */}
{/* v1.41 — what the Coach is doing, one quiet row at the top of the
answer and the only thing in the column until the answer starts:
live from the activity frames while the turn runs, "Thought
process · N steps" once it is done, "Answer needed" while a
question waits. Nothing opens by itself; a tap on the row opens
the steps, which also say how the answer was worked out and hold
the tables the answer read. A message saved before v1.41 falls
back to its steps, or the areas it drew on. */}
<CoachTurnActivity
activity={activity ?? metricSource?.activity ?? []}
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
startedAt={startedAt}
endedAt={endedAt}
awaitingAnswer={
!inProgress &&
!!replies?.replies.some((reply) => reply.kind === "clarification")
}
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
conversationId={conversationId}
@@ -673,11 +670,6 @@ function ChatBubbleImpl({
) : null
}
/>
{/* v1.41 — the tables read so far, one line each, while the turn
still runs. They give way to the answer's own charts. */}
{inProgress && results && interimRefs && interimRefs.length > 0 ? (
<CoachInterimResults results={results} interimRefs={interimRefs} />
) : null}
{/* The prose bubble, once there is prose or an error. While the turn
runs without prose the status line above says what is happening;
the bubble arrives with the first token. */}
@@ -124,7 +124,7 @@ function displayedByRef(
/**
* The live tables with `displayed` taken from the provenance where it names
* them: an interim table is sent before the answer decides.
* them: a table read while the turn ran is sent before the answer decides.
*/
export function liveWithProvenance<
T extends Pick<CoachResultMeta, "ref" | "displayed">,
@@ -265,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
export function CoachResultView({
function CoachResultView({
result,
chartFirst,
}: {
@@ -1,191 +0,0 @@
"use client";
/**
* v1.41 — the tables a turn has read so far, while it still runs.
*
* Under the status line, one quiet line per table as soon as the call that
* read it settles: its title, window and how many readings it holds, with a
* small sparkline in the first chart colour and no axes. At most three. A
* tap opens the full chart (or table) right there; nothing opens by itself.
* Once the answer settles the previews go: the tables the answer points at
* become its charts, the rest sit under "Data used" in the closed trail.
*/
import { useId, useState } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import {
coachWindowLabelKey,
interimLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import { CoachResultView } from "./coach-results";
/** At most this many previews show, whatever the turn has read. */
export const MAX_INTERIM_PREVIEWS = 3;
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/** The interim tables to preview, in the order they arrived. */
export function interimPreviews(
results: readonly CoachResultTable[],
interimRefs: readonly string[],
): CoachResultTable[] {
const refs = new Set(interimRefs);
return results
.filter((result) => refs.has(result.ref))
.slice(0, MAX_INTERIM_PREVIEWS);
}
/**
* How many readings a table holds: the sum of its count column when it has
* one (readings per day or week), else its rows.
*/
export function interimReadingCount(result: CoachResultTable): number {
const countIndex = result.columns.findIndex((c) => c.kind === "count");
if (countIndex === -1) return result.rowCount;
let sum = 0;
for (const row of result.rows) {
const cell = row[countIndex];
if (typeof cell === "number") sum += cell;
}
return sum > 0 ? sum : result.rowCount;
}
/** "Blood pressure by day, last 30 days, 214 readings". */
export function interimLabel(
result: CoachResultTable,
t: Translate,
locale: Locale,
): string {
const count = interimReadingCount(result);
return t(interimLabelKey(count, locale), {
title: result.title,
window: t(coachWindowLabelKey(result.source.window)),
count,
});
}
/** The values the sparkline draws: the first charted series, in row order. */
export function sparklineValues(result: CoachResultTable): number[] {
const spec = result.chart;
const key =
spec?.kind === "line" || spec?.kind === "bar"
? spec.series[0]
: spec?.kind === "compare"
? spec.a
: result.columns.find((c) => c.kind === "number")?.key;
const index = result.columns.findIndex((c) => c.key === key);
if (index === -1) return [];
return result.rows
.map((row) => row[index])
.filter((cell): cell is number => typeof cell === "number");
}
/** An SVG polyline path through the values, inside `width × height`. */
export function sparklinePoints(
values: readonly number[],
width: number,
height: number,
): string {
if (values.length === 0) return "";
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const step = values.length > 1 ? width / (values.length - 1) : 0;
return values
.map((value, i) => {
const x = values.length > 1 ? i * step : width / 2;
const y = height - 1 - ((value - min) / span) * (height - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
function Sparkline({ values }: { values: number[] }) {
if (values.length < 2) return null;
return (
<svg
aria-hidden="true"
data-slot="coach-interim-sparkline"
viewBox="0 0 48 16"
className="h-4 w-12 shrink-0 overflow-visible"
preserveAspectRatio="none"
>
<polyline
points={sparklinePoints(values, 48, 16)}
fill="none"
stroke="var(--chart-1)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
function InterimPreview({ result }: { result: CoachResultTable }) {
const { t, locale } = useTranslations();
const detailId = useId();
const [open, setOpen] = useState(false);
return (
<li
data-slot="coach-interim-result"
data-ref={result.ref}
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 flex min-w-0 flex-col gap-2 motion-safe:duration-300"
>
<button
type="button"
aria-expanded={open}
aria-controls={open ? detailId : undefined}
onClick={() => setOpen(!open)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus -my-1 flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded text-left text-xs outline-none focus-visible:ring-2 sm:my-0 sm:min-h-6"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
<span className="min-w-0 truncate">
{interimLabel(result, t, locale)}
</span>
<Sparkline values={sparklineValues(result)} />
</button>
{open ? (
<div id={detailId} className="w-full min-w-0">
<CoachResultView result={result} chartFirst />
</div>
) : null}
</li>
);
}
export function CoachInterimResults({
results,
interimRefs,
}: {
results: readonly CoachResultTable[];
interimRefs: readonly string[];
}) {
const previews = interimPreviews(results, interimRefs);
if (previews.length === 0) return null;
return (
<ul
data-slot="coach-interim-results"
className="flex w-full min-w-0 flex-col gap-0.5 self-stretch"
>
{previews.map((result) => (
<InterimPreview key={result.ref} result={result} />
))}
</ul>
);
}
@@ -716,15 +716,13 @@ export function MessageThread({
? { results: streaming.results }
: {})}
// v1.41 — and its live trail (with the titles and texts
// the persisted copy only has behind `…/trail`), its clock
// and the words of the fact it kept.
// the persisted copy only has behind `…/trail`) and the
// words of the fact it kept.
{...(m.id === streaming?.messageId
? {
...(streaming.activity.length > 0
? { activity: streaming.activity }
: {}),
startedAt: streaming.startedAt,
endedAt: streaming.endedAt,
memoryNote: streaming.memoryNote,
}
: {})}
@@ -782,9 +780,6 @@ export function MessageThread({
steps={streaming.steps}
results={streaming.results}
activity={streaming.activity}
interimRefs={streaming.interimRefs}
startedAt={streaming.startedAt}
endedAt={streaming.endedAt}
memoryNote={streaming.memoryNote}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
File diff suppressed because it is too large. Load diff
@@ -568,25 +568,12 @@ export interface CoachStreamingMessage {
* `metricSource.activity` and the model text behind `…/trail`.
*/
activity: CoachActivity[];
/**
* v1.41 — the refs of the tables that arrived as `interim` while the turn
* still ran. They show as one-line previews until the answer settles, and
* are taken down again when `done` says `withheldResults`.
*/
interimRefs: string[];
/** v1.41 — the fact this turn saved or proposes (`memoryNote` frame). */
memoryNote: CoachMemoryNote | null;
/** v1.41 — the plan this turn proposes (`planProposal` frame). */
planProposal: CoachPlanProposal | null;
/** v1.41 — why the answer was forced, when it was (`done.stop`). */
stop: CoachStop | null;
/**
* v1.41 — when the turn started on this device (epoch ms), for the live
* seconds on the status line. Null when no turn has run.
*/
startedAt: number | null;
/** v1.41 — when the stream closed (epoch ms); null while it runs. */
endedAt: number | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -670,12 +657,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -988,12 +972,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: params.message,
conversationId: params.conversationId ?? null,
});
const startedAt = Date.now();
setStreaming({
...EMPTY_STREAMING,
inProgress: true,
startedAt,
});
setStreaming({ ...EMPTY_STREAMING, inProgress: true });
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
// user gets the offline-specific copy immediately rather than
@@ -1159,11 +1138,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (evt.interim && !interimRefs.includes(result.ref)) {
interimRefs.push(result.ref);
}
const refs = [...interimRefs];
// An interim table is kept quietly: nothing renders it
// until the answer's own results do, and `done` may still
// take it down again (`withheldResults`).
setStreaming((prev) => ({
...prev,
results: upsertResult(prev.results, result),
interimRefs: refs,
}));
break;
}
@@ -1259,12 +1239,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
followUps: collectedFollowUps,
clarification: collectedClarification,
activity: collectedActivity,
interimRefs: withheldResults ? [] : interimRefs,
memoryNote: collectedMemoryNote,
planProposal: collectedPlanProposal,
stop: collectedStop,
startedAt,
endedAt: Date.now(),
inProgress: false,
messageId,
errorCode: lastError,
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
/** A trailing ellipsis, three dots or the single character. */
const TRAILING_ELLIPSIS = /(?:\.\.\.|…)\s*$/u;
/** The stagger of the three dots, one class each (no inline style). */
const DOT_DELAYS = [
"[animation-delay:0s]",
"[animation-delay:0.2s]",
"[animation-delay:0.4s]",
] as const;
/**
* Text that is still waiting on something: a trailing "..." or "…" becomes
* three dots that fade in turn (`waiting-dot` in `globals.css`), so the line
* reads as alive without a spinner beside it. Text without a trailing
* ellipsis renders as it is. Under reduced motion the dots stand still.
*
* `textClassName` styles the words alone, never the dots: a text effect
* clipped to the glyphs (`text-shimmer`) does not reliably reach a child
* that animates its own opacity, so the dots keep the surrounding colour.
*
* The dots are ordinary full stops, so the text reads and copies as
* "Thinking...". A surface that announces its status elsewhere hides this
* text from assistive technology itself.
*/
export function WaitingText({
text,
textClassName,
}: {
text: string;
textClassName?: string;
}) {
const match = TRAILING_ELLIPSIS.exec(text);
const words = match ? text.slice(0, match.index) : text;
return (
<>
<span className={textClassName}>{words}</span>
{match ? (
<span data-slot="waiting-dots">
{DOT_DELAYS.map((delay) => (
<span key={delay} className={cn("waiting-dot", delay)}>
.
</span>
))}
</span>
) : null}
</>
);
}
@@ -26,13 +26,12 @@ import {
activityDigestDoneKey,
activityDigestKey,
activityMemoryKey,
activitySummaryKey,
activityThoughtProcessKey,
} from "@/lib/ai/coach/activity/contract";
import {
COACH_ASSUMPTION_KEYS,
COACH_CHART_COMPARE_KEYS,
COACH_SUGGESTED_REPLIES_KEYS,
interimLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
COACH_MEMORY_KEYS,
@@ -91,8 +90,7 @@ function catalogKeys(): Set<string> {
keys.add(activityDigestDoneKey(count, locale));
keys.add(activityAreasKey(count, locale));
keys.add(activityMemoryKey(count, locale));
keys.add(activitySummaryKey(count, locale));
keys.add(interimLabelKey(count, locale));
keys.add(activityThoughtProcessKey(count, locale));
}
}
return keys;
@@ -104,7 +102,6 @@ const OWNED = [
"insights.coach.plan",
"insights.coach.assumption",
"insights.coach.suggestedReplies",
"insights.coach.interim",
"insights.coach.chart",
"insights.coach.reasoning",
"settings.coach.memory",
@@ -122,7 +119,7 @@ describe("v1.41 contract keys", () => {
// Every key laid down for the release but the `change_assumption` chip
// label, which the dialog catalog's own test covers, plus the two
// declined-proposal replies and the admin reasoning description.
expect(keys.size).toBe(60);
expect(keys.size).toBe(57);
});
it.each(locales)("every catalog key resolves in %s", (locale) => {
@@ -47,7 +47,6 @@ import {
methodAveragesKey,
methodReadingsKey,
methodTotalsKey,
stepHeaderDoneKey,
stepMetricsKey,
stepReadingsKey,
stepRowsKey,
@@ -112,7 +111,6 @@ function catalogKeys(): Set<string> {
}
for (const locale of locales) {
for (const count of COUNTS) {
keys.add(stepHeaderDoneKey(count, locale));
keys.add(stepReadingsKey(count, locale));
keys.add(stepRowsKey(count, locale));
keys.add(stepMetricsKey(count, locale));
@@ -4,7 +4,7 @@ import {
ACTIVITY_TEXT_MAX_CHARS,
ACTIVITY_TITLE_MAX_CHARS,
TRAIL_MAX_BYTES,
activitySummaryKey,
activityThoughtProcessKey,
createNoopActivityRecorder,
} from "../contract";
@@ -38,15 +38,15 @@ describe("activity contract", () => {
);
});
it("says nothing about lookups on a turn that made none", () => {
expect(activitySummaryKey(0, "en")).toBe(
"insights.coach.activity.summaryNoLookups",
it("counts the steps of the settled line by plural tier", () => {
expect(activityThoughtProcessKey(1, "en")).toBe(
"insights.coach.activity.thoughtProcessOne",
);
expect(activitySummaryKey(1, "en")).toBe(
"insights.coach.activity.summaryOne",
expect(activityThoughtProcessKey(0, "en")).toBe(
"insights.coach.activity.thoughtProcessOther",
);
expect(activitySummaryKey(3, "pl")).toBe(
"insights.coach.activity.summaryFew",
expect(activityThoughtProcessKey(3, "pl")).toBe(
"insights.coach.activity.thoughtProcessFew",
);
});
});
+8 -8
View File
@@ -100,6 +100,8 @@ export const COACH_ACTIVITY_KEYS = {
plan: "insights.coach.activity.plan",
asking: "insights.coach.activity.asking",
answer: "insights.coach.activity.answer",
/** The settled line of a turn that waits for the person's choice. */
awaitingAnswer: "insights.coach.activity.awaitingAnswer",
} as const;
/** The `stop` entry's label, by reason. */
@@ -132,12 +134,10 @@ export function activityMemoryKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.memory", count, locale);
}
/**
* The one-line summary once the turn is done: "Thought it through, N lookups,
* S s", or the seconds alone on a turn that looked nothing up.
*/
export function activitySummaryKey(lookups: number, locale: Locale): string {
return lookups === 0
? "insights.coach.activity.summaryNoLookups"
: pluralKey("insights.coach.activity.summary", lookups, locale);
/** The settled line once the turn is done: "Thought process · N steps". */
export function activityThoughtProcessKey(
count: number,
locale: Locale,
): string {
return pluralKey("insights.coach.activity.thoughtProcess", count, locale);
}
+1 -11
View File
@@ -46,11 +46,6 @@ export const COACH_STEP_UI_KEYS = {
announceDone: "coach.step.announceDone",
} as const;
/** "Looked at N sources", by plural tier. */
export function stepHeaderDoneKey(count: number, locale: Locale): string {
return pluralKey("coach.step.headerDone", count, locale);
}
/** "N readings" on a step row, by plural tier. */
export function stepReadingsKey(count: number, locale: Locale): string {
return pluralKey("coach.step.readings", count, locale);
@@ -256,7 +251,7 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
return `coach.clarify.window.${window}`;
}
// ── Replies, assumptions, interim tables (v1.41) ──────────────────────────
// ── Replies, assumptions (v1.41) ──────────────────────────────────────
/** The group of reply pills under the latest answer. */
export const COACH_SUGGESTED_REPLIES_KEYS = {
@@ -269,11 +264,6 @@ export const COACH_ASSUMPTION_KEYS = {
change: "insights.coach.assumption.change",
} as const;
/** The one-line preview of a table read while the turn still runs. */
export function interimLabelKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.interim.label", count, locale);
}
/** The legend of a chart comparing two periods ("{a} vs {b}"). */
export const COACH_CHART_COMPARE_KEYS = {
periods: "insights.coach.chart.compare.periods",
+15
View File
@@ -0,0 +1,15 @@
/**
* The refusal a demo deploy answers a write with (`DEMO_MODE=true`): the
* proxy's mutation block and the routes that bypass the proxy send the same
* body, a 403 whose `meta.errorCode` names it, so a client can tell "the demo
* does not do this" from an outage and say so.
*
* Client-safe: no server import.
*/
export const DEMO_READ_ONLY_CODE = "demo.readOnly";
export const DEMO_REFUSAL_BODY = {
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: DEMO_READ_ONLY_CODE },
} as const;
+2 -8
View File
@@ -28,6 +28,7 @@
* nothing else under `/api`.
*/
import { shouldRunWeb } from "@/lib/process-type";
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
export const PROXY_BYPASS_ROUTES: readonly string[] = [
// Streamed archive uploads, up to 1.5 GB.
@@ -115,14 +116,7 @@ export function bypassRouteRefusal(method: string): Response | null {
const m = method.toUpperCase();
const isMutation = m !== "GET" && m !== "HEAD" && m !== "OPTIONS";
if (process.env.DEMO_MODE === "true" && isMutation) {
return Response.json(
{
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true },
},
{ status: 403 },
);
return Response.json(DEMO_REFUSAL_BODY, { status: 403 });
}
return null;
}
+1
View File
@@ -118,6 +118,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
"disable-coach": ["disable-coach.body.invalid_json"],
demo: ["demo.readOnly"],
documents: [
"documents.chat.conversationNotFound",
"documents.chat.invalid",
+2 -8
View File
@@ -6,6 +6,7 @@ import {
RETIRED_ROUTE_STATUS,
} from "@/lib/http/retired-routes";
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
/**
* Paths that do NOT require a session cookie (public pages + external webhooks).
@@ -361,14 +362,7 @@ export function proxy(request: NextRequest) {
)
) {
return applyBaselineSecurityHeaders(
NextResponse.json(
{
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true },
},
{ status: 403 },
),
NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }),
);
}
}