mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
3df5754ab1
commit
dd72634791
32 files changed
+1069
-1312
No files matched your search
@@ -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
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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'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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user