From dd72634791b0af2ebe22555fc4245f4ac013c14a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 7 Oct 2026 16:55:25 +0200 Subject: [PATCH] Rebuild the Coach thinking row as one calm line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/api/openapi.yaml | 2 +- e2e/coach-activity.spec.ts | 124 ++- e2e/coach-clarification.spec.ts | 10 +- messages/de.json | 23 +- messages/en.json | 23 +- messages/es.json | 23 +- messages/fr.json | 23 +- messages/it.json | 23 +- messages/ko.json | 23 +- messages/pl.json | 23 +- .../proxy-demo-mode-allowlist.test.ts | 12 + src/app/globals.css | 54 ++ .../__tests__/interim-results.test.tsx | 143 --- .../__tests__/message-thread.test.tsx | 21 +- .../__tests__/turn-activity.test.tsx | 530 ++++++---- .../__tests__/use-coach-dialog.test.ts | 3 - .../insights/coach-panel/chat-bubble.tsx | 42 +- .../insights/coach-panel/coach-results.tsx | 4 +- .../insights/coach-panel/interim-results.tsx | 191 ---- .../insights/coach-panel/message-thread.tsx | 9 +- .../insights/coach-panel/turn-activity.tsx | 903 ++++++++---------- .../insights/coach-panel/use-coach.ts | 31 +- src/components/ui/waiting-text.tsx | 50 + .../ai/coach/__tests__/contract-keys.test.ts | 9 +- .../ai/coach/__tests__/dialog-keys.test.ts | 2 - .../coach/activity/__tests__/contract.test.ts | 16 +- src/lib/ai/coach/activity/contract.ts | 16 +- src/lib/ai/coach/dialog-keys.ts | 12 +- src/lib/demo-refusal.ts | 15 + src/lib/http/proxy-bypass-routes.ts | 10 +- src/lib/openapi/error-codes.ts | 1 + src/proxy.ts | 10 +- 32 files changed, 1069 insertions(+), 1312 deletions(-) delete mode 100644 src/components/insights/coach-panel/__tests__/interim-results.test.tsx delete mode 100644 src/components/insights/coach-panel/interim-results.tsx create mode 100644 src/components/ui/waiting-text.tsx create mode 100644 src/lib/demo-refusal.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 650820e4d..7eb52e432 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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, diff --git a/e2e/coach-activity.spec.ts b/e2e/coach-activity.spec.ts index 259a8b2f6..3b7338c6d 100644 --- a/e2e/coach-activity.spec.ts +++ b/e2e/coach-activity.spec.ts @@ -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); }); } } diff --git a/e2e/coach-clarification.spec.ts b/e2e/coach-clarification.spec.ts index a703bfc66..594fb17cf 100644 --- a/e2e/coach-clarification.spec.ts +++ b/e2e/coach-clarification.spec.ts @@ -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"); diff --git a/messages/de.json b/messages/de.json index 90baf90f3..162341741 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index cb3e738ea..d784d03e7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 531bcc740..53a75e898 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index e13156a46..a194c1d85 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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é", diff --git a/messages/it.json b/messages/it.json index bda0a89a9..fe2c91b79 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 42368be2b..6ad5fb71d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "코치가 확인한 내용 숨기기", diff --git a/messages/pl.json b/messages/pl.json index c26366b41..58c0b8d61 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 7853d5751..272865a8e 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -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). diff --git a/src/app/globals.css b/src/app/globals.css index 1a50e0d52..91d72e44d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 diff --git a/src/components/insights/coach-panel/__tests__/interim-results.test.tsx b/src/components/insights/coach-panel/__tests__/interim-results.test.tsx deleted file mode 100644 index 5342e4353..000000000 --- a/src/components/insights/coach-panel/__tests__/interim-results.test.tsx +++ /dev/null @@ -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( - {node}, - ); -} - -describe("interim previews", () => { - it("one line per interim table: title, window, readings, a sparkline", () => { - const html = render( - , - ); - 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(), - ).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"); - }); -}); 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 d393a8de1..aa0307a7e 100644 --- a/src/components/insights/coach-panel/__tests__/message-thread.test.tsx +++ b/src/components/insights/coach-panel/__tests__/message-thread.test.tsx @@ -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("", () => { ); }); - 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(); 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("", () => { expect(html).not.toContain('data-slot="coach-evidence"'); expect(html).not.toContain("What I'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( , "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", diff --git a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx index 73fb2fa6d..ff555cc59 100644 --- a/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx +++ b/src/components/insights/coach-panel/__tests__/turn-activity.test.tsx @@ -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>/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(), + ); + 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('[^<]+<\/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 { + const html = render( + , + ); + const row = rowHtml(html); + expect(row.startsWith(" 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(); + 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(" { + expect( + render( + , + ), + ).toContain('data-text="Checking the trend…"'); + expect( + render( + , + ), + ).toContain('data-text="Checking: Lab results, last 12 months…"'); + }); + + it("reads in German", () => { + const html = render( + , + "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( - , - ), + 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>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s]*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(); - 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( + , + ); + expect(html).toContain('data-state="done"'); + expect(html).toMatch( + /data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps { + it("counts in the reader's language, with proper plurals", () => { + const de = render( + , + "de", + ); + expect(de).toContain("Denkprozess · 1 Schritt<"); + const pl = render( + , + "pl", + ); + expect(pl).toContain("Tok myślenia · 3 kroki<"); + }); + + it("an older message counts its steps, or the areas it drew on", () => { + expect( + render( + , + ), + ).toContain("Thought process · 2 steps<"); + expect( + render( + , + ), + ).toContain("Thought process · 1 step<"); + }); + + it("says an answer is needed while a question waits, without shimmer", () => { const html = render( - , + , + ); + expect(html).toContain('data-state="awaiting"'); + expect(html).toMatch( + /data-slot="coach-turn-steps-done"[^>]*>Answer needed, + "de", + ), + ).toContain(">Antwort nötig<"); + }); + + it("renders nothing for a settled message with nothing to show", () => { + expect( + render(), + ).not.toContain("coach-turn-steps"); + }); + + it("carries a polite status region that starts empty", () => { + const html = render( + , + ); + expect(html).toMatch( + /<\/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( , ); - 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( - , - "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( - , + 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( - , - ), - ).toContain("Looked at 2 areas"); - expect( - render( - , - "de", - ), - ).toContain("1 Bereich angesehen"); - }); - - it("renders nothing for a settled message with nothing to show", () => { - expect( - render(), - ).not.toContain("coach-turn-steps"); - }); - - it("carries a polite status region that starts empty", () => { - const html = render( - , + 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>/, + 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( - , - ) + - render( - , - ); - 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("", () => { - it("one row per step: domain · window · count or reason, with its status", () => { - const html = render( - , - ); - 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>/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( + , + ), + ), + ), + ).toBe("Thought process · 5 steps"); + expect( + textOf( + rowHtml(render()), + ), + ).toBe("Thinking..."); + }); +}); diff --git a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts index bbb4bcaf6..976bb041e 100644 --- a/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts +++ b/src/components/insights/coach-panel/__tests__/use-coach-dialog.test.ts @@ -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, diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 435d324ad..f2a7ef634 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -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. */} 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 ? ( - - ) : 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. */} diff --git a/src/components/insights/coach-panel/coach-results.tsx b/src/components/insights/coach-panel/coach-results.tsx index b1ad56296..2565c9bb0 100644 --- a/src/components/insights/coach-panel/coach-results.tsx +++ b/src/components/insights/coach-panel/coach-results.tsx @@ -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, @@ -265,7 +265,7 @@ export function CoachResults({ type ResultView = "chart" | "table"; -export function CoachResultView({ +function CoachResultView({ result, chartFirst, }: { diff --git a/src/components/insights/coach-panel/interim-results.tsx b/src/components/insights/coach-panel/interim-results.tsx deleted file mode 100644 index 4e8f6c4ea..000000000 --- a/src/components/insights/coach-panel/interim-results.tsx +++ /dev/null @@ -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; - -/** 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 ( - - ); -} - -function InterimPreview({ result }: { result: CoachResultTable }) { - const { t, locale } = useTranslations(); - const detailId = useId(); - const [open, setOpen] = useState(false); - return ( -
  • - - {open ? ( -
    - -
    - ) : null} -
  • - ); -} - -export function CoachInterimResults({ - results, - interimRefs, -}: { - results: readonly CoachResultTable[]; - interimRefs: readonly string[]; -}) { - const previews = interimPreviews(results, interimRefs); - if (previews.length === 0) return null; - return ( -
      - {previews.map((result) => ( - - ))} -
    - ); -} diff --git a/src/components/insights/coach-panel/message-thread.tsx b/src/components/insights/coach-panel/message-thread.tsx index cab74b884..a7f3e82d6 100644 --- a/src/components/insights/coach-panel/message-thread.tsx +++ b/src/components/insights/coach-panel/message-thread.tsx @@ -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} diff --git a/src/components/insights/coach-panel/turn-activity.tsx b/src/components/insights/coach-panel/turn-activity.tsx index a0acfcdcd..34ac42142 100644 --- a/src/components/insights/coach-panel/turn-activity.tsx +++ b/src/components/insights/coach-panel/turn-activity.tsx @@ -1,50 +1,63 @@ "use client"; /** - * v1.41 — the live trail of a Coach turn, one quiet line at the top of the - * answer. + * v1.41.1 — the thinking row of a Coach turn: one quiet line at the top of + * the answer, and the steps behind it. * - * While the turn runs the line says what the Coach is doing right now, from - * the `activity` frames: "Thinking…", "Fetching blood pressure, last 90 - * days…", "Summarising 214 readings…", "Writing the answer…", with the - * seconds so far right after the text. A reasoning title replaces the - * thinking label when one arrives. Once the turn ends the same line settles - * into one calm summary: "Thought it through, 3 lookups, 12 s". + * The row is a brain, the status text and a chevron, in the answer's own + * text size and the muted tone. While the turn runs the text says what the + * Coach is doing right now ("Fetching blood pressure, last 90 days…", or + * "Thinking…" before the first frame), with a shimmer passing through it and + * the trailing dots fading in turn; there is no clock. When the answer is + * done the row settles into "Thought process · N steps", and a turn that + * ends on a question to the person says "Answer needed" instead. The chevron + * only shows when there is something to open. * - * Nothing opens by itself. The trail starts closed in every state of a turn - * (running, asking, stopped, failed, settled, reloaded) and only a tap on the - * line opens it; it then stays open until the person closes it, and keeps - * growing while the turn runs. Open, it lists one entry per phase with its - * status, the reasoning text of a round behind its own disclosure (closed - * too), the method line and the tables the answer used without pointing at - * them. + * Nothing opens by itself. The row starts closed in every state, only a tap + * opens it, and a row opened while the turn ran closes again when it ends: + * the answer is what the person came for. Open, it lists the steps one under + * the other, each with a small icon on a thin line that runs to the next + * one, a reasoning round with its summary underneath; then the method line + * and the tables the answer used without pointing at them. * * Labels are catalog text the server rendered; titles and texts are screened * model text the owner alone receives. A persisted message carries only the * metadata, so its titles and texts are read from `…/trail` the first time - * the person opens the trail. A message saved before v1.41 has no activity - * and falls back to the step list (`metricSource.steps`), and one saved + * the person opens the row. A message saved before v1.41 has no activity + * and falls back to its step list (`metricSource.steps`), and one saved * before steps existed to the areas it drew on. * - * Screen readers hear the line through a polite status region, throttled to + * Screen readers hear the status through a polite live region, throttled to * one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the - * summary once when the turn ends. A reloaded conversation announces nothing. + * settled line once when the turn ends. A reloaded conversation announces + * nothing. */ import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { - Check, - ChevronRight, - Loader2, - Minus, - TriangleAlert, + BookOpen, + Bookmark, + Brain, + ChartLine, + ChartScatter, + ChevronDown, + CircleStop, + Database, + FileSearch, + Lightbulb, + ListTodo, + MessageCircleQuestion, + PenLine, + Sigma, + Table2, + type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiGet } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; import { useTranslations } from "@/lib/i18n/context"; -import type { Locale } from "@/lib/i18n/config"; +import { WaitingText } from "@/components/ui/waiting-text"; import { COACH_STEP_REASON_KEYS, COACH_STEP_UI_KEYS, @@ -55,7 +68,7 @@ import { } from "@/lib/ai/coach/dialog-keys"; import { COACH_ACTIVITY_KEYS, - activitySummaryKey, + activityThoughtProcessKey, } from "@/lib/ai/coach/activity/contract"; import type { CoachActivity, @@ -65,13 +78,14 @@ import type { CoachTrail, } from "@/lib/ai/coach/types"; +import { COACH_FOCUS_RING } from "./focus-ring"; import { CoachMethodLine } from "./method-line"; /** The shortest gap between two screen-reader announcements. */ export const ANNOUNCE_THROTTLE_MS = 1500; -/** "12s": the seconds on the line and beside a finished round. */ -const ELAPSED_KEY = "insights.coach.thinkingElapsed"; +/** A trailing "…" or "...", which a finished entry no longer carries. */ +const TRAILING_ELLIPSIS = /(?:…|\.\.\.)\s*$/u; type Translate = ( key: string, @@ -141,15 +155,6 @@ export function describeStep( return { title, meta }; } -/** - * How many sources the turn looked at: one per domain (a step without one, - * the snapshot, counts by its tool). A second read of the same domain, for - * the period before or a retry, is not a new source. - */ -export function countSources(steps: readonly CoachStep[]): number { - return new Set(steps.map((step) => step.domain ?? step.tool)).size; -} - /** * The step a legacy running line names: the latest one still running, else * the latest one overall (between two rounds nothing is running). @@ -197,8 +202,9 @@ export function currentActivity( } /** - * What an entry is called on the line: a screened reasoning title for a - * thinking round once one has arrived, else the server's catalog label. + * What an entry is called: a screened reasoning title for a thinking round + * once one has arrived, else the server's catalog label. A finished entry + * no longer trails off: "Writing the answer", not "Writing the answer…". */ export function activityLineLabel(entry: CoachActivity): string { if ( @@ -207,89 +213,76 @@ export function activityLineLabel(entry: CoachActivity): string { ) { return entry.title; } - // A finished entry no longer trails off: "Writing the answer", not - // "Writing the answer…". return entry.status === "running" ? entry.label - : entry.label.replace(/(?:…|\.\.\.)\s*$/u, ""); -} - -/** The lookups a turn made: one per fetch. */ -export function countLookups(activity: readonly CoachActivity[]): number { - return activity.filter((entry) => entry.phase === "fetch").length; + : entry.label.replace(TRAILING_ELLIPSIS, ""); } /** - * The seconds a turn took, from its trail: the phases of a round run one - * after the other, except its fetches, which run side by side (the longest - * counts). Null when the trail carries no durations. + * Whether the row stays open as the turn moves on: a row opened while the + * turn ran closes when it ends. Nothing ever opens it but a tap. */ -export function activitySeconds( - activity: readonly CoachActivity[], -): number | null { - const fetchByRound = new Map(); - let sequential = 0; - let any = false; - for (const entry of activity) { - if (entry.durationMs === undefined) continue; - any = true; - if (entry.phase === "fetch") { - fetchByRound.set( - entry.round, - Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs), - ); - } else { - sequential += entry.durationMs; - } +export function openAfter(args: { + open: boolean; + wasActive: boolean; + active: boolean; +}): boolean { + return args.wasActive && !args.active ? false : args.open; +} + +/** The running row's text: whatever it names, still going ("…"). */ +export function inProgressLabel(label: string): string { + return `${label.replace(TRAILING_ELLIPSIS, "")}…`; +} + +/** + * The icon of a step: what kind of work it was. Reads of the record are a + * database, a table read for the answer a table, connections a scatter, an + * earlier chart reopened a chart, the record summary a search. + */ +export function stepIcon( + phase: CoachActivity["phase"] | null, + tool: CoachStep["tool"] | undefined, +): LucideIcon { + switch (phase) { + case "thinking": + case "checkpoint": + return Lightbulb; + case "memory": + return BookOpen; + case "digest": + return Sigma; + case "remember": + return Bookmark; + case "plan": + return ListTodo; + case "asking": + return MessageCircleQuestion; + case "stop": + return CircleStop; + case "answer": + return PenLine; + default: + break; + } + switch (tool) { + case "snapshot": + return FileSearch; + case "show_result": + return ChartLine; + case "get_correlations": + return ChartScatter; + case "get_metric_table": + return Table2; + default: + return Database; } - if (!any) return null; - let total = sequential; - for (const ms of fetchByRound.values()) total += ms; - return Math.max(1, Math.round(total / 1000)); -} - -/** Whole seconds between two instants, at least one. */ -export function elapsedSeconds(from: number, to: number): number { - return Math.max(1, Math.round((to - from) / 1000)); -} - -/** - * The settled line: "Thought it through, N lookups, S s". The seconds come - * from the trail, else from the clock of the turn that just streamed. Null - * when neither is known (a message saved before v1.41). - */ -export function activitySummary(args: { - activity: readonly CoachActivity[]; - startedAt?: number | null; - endedAt?: number | null; - t: Translate; - locale: Locale; -}): string | null { - const { activity, startedAt, endedAt, t, locale } = args; - if (activity.length === 0) return null; - const seconds = - activitySeconds(activity) ?? - (startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null); - if (seconds === null) return null; - const lookups = countLookups(activity); - return t(activitySummaryKey(lookups, locale), { lookups, seconds }); -} - -/** A tick per second while `running`; the current time otherwise. */ -function useNow(running: boolean): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!running) return; - const id = setInterval(() => setNow(Date.now()), 1000); - return () => clearInterval(id); - }, [running]); - return now; } /** * The status region's text: the running line as it changes, throttled, and - * the summary once when the turn ends. Nothing is announced for a trail that - * was already settled when it mounted. + * the settled line once when the turn ends. Nothing is announced for a row + * that was already settled when it mounted. */ function useLineAnnouncement(text: string, active: boolean): string { const [mountedActive] = useState(active); @@ -299,7 +292,7 @@ function useLineAnnouncement(text: string, active: boolean): string { const [announcement, setAnnouncement] = useState(""); useEffect(() => { - // A reloaded, settled trail stays quiet. + // A reloaded, settled row stays quiet. if (!mountedActive) return; if (!text || text === said.current) return; said.current = text; @@ -355,101 +348,115 @@ function useTrailText(args: { return query.data ?? null; } -// ── Rendering ────────────────────────────────────────────────────────────── +// ── The open list ────────────────────────────────────────────────────────── -type EntryStatus = CoachActivity["status"]; +/** How a step reads: the one going on now, a finished one, a failed one. */ +type StepTone = "active" | "done" | "failed"; -function StatusIcon({ - status, - active, -}: { - status: EntryStatus; - active: boolean; -}) { - const className = "mt-0.5 size-3 shrink-0"; - if (status === "running" && active) { - return ( -