Merge the Coach reasoning interface into release/v1.41.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-06 20:48:42 +02:00
commit 3b5fb1e900
58 files changed
+6040 -1762

No files matched your search

+488
View File
@@ -0,0 +1,488 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* The live trail of a Coach turn: one quiet status line 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
* 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.
* 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.
*/
const CONVERSATION_ID = "coach-activity-e2e";
const QUESTION = "How was my blood pressure this month?";
const ANSWER =
"Your blood pressure held a steady band this month, around 126/82 mmHg on most days. Nothing stands out against your usual range.";
const BP_VALUES: Array<[number, number]> = [
[128, 84],
[125, 82],
[131, 85],
[122, 80],
[124, 81],
[127, 83],
[121, 79],
[126, 82],
[123, 80],
[126, 81],
];
const BP_TABLE = {
ref: "r1",
source: {
tool: "get_metric_table",
domain: "bp",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: BP_VALUES.length,
chartKind: "line",
displayed: false,
columns: [
{
key: "day",
kind: "period",
labelKey: "coach.result.column.day",
label: "Day",
},
{
key: "systolic",
kind: "number",
labelKey: "coach.result.column.systolic",
label: "Systolic",
unit: "mmHg",
decimals: 0,
},
{
key: "diastolic",
kind: "number",
labelKey: "coach.result.column.diastolic",
label: "Diastolic",
unit: "mmHg",
decimals: 0,
},
{
key: "readings",
kind: "count",
labelKey: "coach.result.column.readings",
label: "Readings",
},
],
rows: BP_VALUES.map(([sys, dia], i) => [
new Date(Date.UTC(2026, 8, 7 + i)).toISOString().slice(0, 10),
sys,
dia,
i === 0 ? 205 : 1,
]),
truncated: false,
chart: { kind: "line", x: "day", series: ["systolic", "diastolic"] },
};
const BP_SHOWN = { ...BP_TABLE, displayed: true };
const BP_META = {
ref: "r1",
source: BP_TABLE.source,
shape: BP_TABLE.shape,
titleKey: BP_TABLE.titleKey,
title: BP_TABLE.title,
rowCount: BP_TABLE.rowCount,
chartKind: "line",
displayed: true,
};
const STEP = {
id: "s1",
tool: "get_metric_table",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 30 days",
domain: "bp",
window: "last30days",
granularity: "day",
};
function entry(
id: string,
phase: string,
status: string,
label: string,
extra: Record<string, unknown> = {},
) {
return {
type: "activity",
activity: {
id,
phase,
status,
round: phase === "answer" ? 2 : 1,
labelKey: `insights.coach.activity.${phase}`,
label,
...extra,
},
};
}
const THINK_TITLE = "Looking at the whole month";
const THINK_TEXT =
"The month has daily readings; a daily table answers this best.";
const ACTIVITY_META = [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 2_400,
},
{
id: "a2",
phase: "fetch",
status: "done",
round: 1,
labelKey: "insights.coach.activity.fetching",
label: "Fetching blood pressure, last 30 days…",
stepRef: "s1",
count: 214,
durationMs: 900,
},
{
id: "a3",
phase: "digest",
status: "done",
round: 1,
labelKey: "insights.coach.activity.digestDone",
label: "214 readings from 1 area",
count: 214,
durationMs: 400,
},
{
id: "a4",
phase: "answer",
status: "done",
round: 2,
labelKey: "insights.coach.activity.answer",
label: "Writing the answer…",
durationMs: 3_000,
},
];
const METRIC_SOURCE = {
windows: ["last30days"],
metrics: ["bp"],
counts: { bp: 214 },
steps: [{ ...STEP, status: "done", count: 214, resultRef: "r1" }],
activity: ACTIVITY_META,
results: [BP_META],
method: {
entries: [
{
domain: "bp",
window: "last30days",
granularity: "day",
count: 214,
aggregation: "mean",
},
],
text: "Blood pressure, last 30 days, 214 readings, daily averages.",
},
};
function conversation(): StubConversation {
return {
id: CONVERSATION_ID,
title: "Blood pressure this month",
messages: [],
results: { "coach-activity-e2e-a1": [BP_SHOWN] },
trails: {
"coach-activity-e2e-a1": {
entries: [{ id: "a1", title: THINK_TITLE, text: THINK_TEXT }],
},
},
};
}
const VIEWPORTS = [
{ name: "1440", width: 1440, height: 900 },
{ name: "390", width: 390, height: 844 },
] as const;
const SHOTS = process.env.COACH_SCREENSHOTS_DIR;
async function shot(page: Page, name: string) {
if (!SHOTS) return;
mkdirSync(SHOTS, { recursive: true });
await page.screenshot({ path: join(SHOTS, `${name}.png`) });
}
async function noHorizontalScroll(page: Page) {
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(0);
}
test.describe("Coach live trail", () => {
test.use({ storageState: STORAGE_STATE_PATH });
for (const viewport of VIEWPORTS) {
for (const theme of ["light", "dark"] as const) {
test(`${viewport.name} px, ${theme}: one quiet line that never opens by itself`, async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"sets its own viewports; one project suffices",
);
const label = `${viewport.name}-${theme}`;
await page.setViewportSize(viewport);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub = conversation();
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, theme);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const turn = page.locator(
'[role="log"] [data-slot="coach-bubble-assistant"]',
);
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"]');
// 1. Before any frame: Thinking…, closed.
await expect(line).toHaveAttribute("data-state", "running");
await expect(current).toHaveText("Thinking…");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await feed(page, [entry("a1", "thinking", "running", "Thinking…")]);
await feed(page, [
entry("a1", "thinking", "running", "Thinking…", {
title: THINK_TITLE,
}),
]);
await expect(current).toHaveText(THINK_TITLE);
await feed(page, [
entry("a1", "thinking", "done", "Thinking…", {
title: THINK_TITLE,
text: THINK_TEXT,
durationMs: 2_400,
}),
{ type: "step", step: { ...STEP, status: "running" } },
entry(
"a2",
"fetch",
"running",
"Fetching blood pressure, last 30 days…",
{
stepRef: "s1",
},
),
]);
await expect(current).toHaveText(
"Fetching blood pressure, last 30 days…",
);
// 2. The table it read previews before the first token.
await feed(page, [
{ type: "result", result: BP_TABLE, interim: true },
{ type: "step", step: { ...STEP, status: "done", count: 214 } },
entry(
"a2",
"fetch",
"done",
"Fetching blood pressure, last 30 days…",
{
stepRef: "s1",
count: 214,
durationMs: 900,
},
),
entry("a3", "digest", "running", "Summarising 214 readings…", {
count: 214,
}),
]);
await expect(current).toHaveText("Summarising 214 readings…");
const preview = turn.locator(
'[data-slot="coach-interim-result"][data-ref="r1"]',
);
await expect(preview).toBeVisible();
await expect(preview).toContainText("Blood pressure by day");
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toHaveCount(0);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await noHorizontalScroll(page);
await shot(page, `${label}-1-running`);
await feed(page, [
entry("a3", "digest", "done", "214 readings from 1 area", {
count: 214,
durationMs: 400,
}),
entry("a4", "answer", "running", "Writing the answer…"),
...tokens(ANSWER),
]);
await expect(current).toHaveText("Writing the answer…");
await expect(
turn.locator('[data-slot="coach-answer-bubble"]'),
).toContainText(ANSWER);
await expect(toggle).toHaveAttribute("aria-expanded", "false");
// The persisted copy lands with the refetch after `done`.
stub.messages.push(
{ id: "coach-activity-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-activity-e2e-a1",
role: "assistant",
content: ANSWER,
metricSource: METRIC_SOURCE,
},
);
await feed(page, [
entry("a4", "answer", "done", "Writing the answer…", {
durationMs: 3_000,
}),
{ type: "provenance", metricSource: METRIC_SOURCE },
{ type: "result", result: BP_SHOWN },
done(CONVERSATION_ID, "coach-activity-e2e-a1"),
]);
await end(page);
// 4. One calm summary, still closed; the preview became the chart.
const bubble = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER });
const settled = bubble.locator('[data-slot="coach-turn-steps"]');
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");
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"]'),
).toHaveCount(0);
await expect(
bubble.locator('[data-slot="coach-result-chart"][data-ref="r1"]'),
).toBeVisible();
await expect(
bubble.locator('[data-slot="coach-result-chart-plot"]'),
).toBeVisible();
expect((await posts(page))[0]).toMatchObject({ message: QUESTION });
await noHorizontalScroll(page);
await shot(page, `${label}-2-settled`);
// 6. A 44 px tap target on phones.
if (viewport.width < 640) {
const box = await settledToggle.boundingBox();
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
}
// 5. A tap opens the trail; the round's reasoning stays behind its own
// closed disclosure.
await settledToggle.click();
await expect(settledToggle).toHaveAttribute("aria-expanded", "true");
const panel = settled.locator('[data-slot="coach-turn-steps-panel"]');
await expect(panel).toBeVisible();
const rows = panel.locator('[data-slot="coach-turn-step"]');
await expect(rows).toHaveCount(4);
await expect(rows.nth(0)).toHaveAttribute("data-phase", "thinking");
await expect(rows.nth(1)).toHaveAttribute("data-phase", "fetch");
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 noHorizontalScroll(page);
await shot(page, `${label}-3-open`);
const axe = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
axe.violations.map((v) => ({
id: v.id,
nodes: v.nodes.map((n) => n.target.join(" ")),
})),
).toEqual([]);
// 3. After a reload the trail is closed again, and opening it reads
// the stored titles and texts.
await page.goto(`/coach?c=${CONVERSATION_ID}`, {
waitUntil: "domcontentloaded",
});
const reloaded = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER })
.locator('[data-slot="coach-turn-steps"]');
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");
const reloadedToggle = reloaded.locator(
'[data-slot="coach-turn-steps-toggle"]',
);
await expect(reloadedToggle).toHaveAttribute("aria-expanded", "false");
await expect(
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);
});
}
}
});
+261
View File
@@ -0,0 +1,261 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* A clarifying question is a conversation, not a form.
*
* 1. The question is a normal Coach message; its choices are quiet reply
* pills under it, the assumed one first. There is no card.
* 2. A pill sends its label as the person's own message, with
* `clarification: { messageId, choiceId }`.
* 3. A typed answer works too, with `clarification: { messageId }`.
* 4. The trail above the question stays closed.
*/
const CONVERSATION_ID = "coach-clarify-e2e";
const QUESTION = "Is my heart rate okay?";
const ASKS =
"Do you mean your resting heart rate or your heart rate while walking? Otherwise I'll look at your resting heart rate.";
const ANSWER =
"Your walking heart rate sat around 98 bpm over the last 30 days, close to your usual level.";
const CLARIFICATION = {
kind: "metric",
freeText: true,
assumption: "c1",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.resting_hr",
label: "Resting HR",
value: { metric: "resting_hr" },
},
{
id: "c2",
labelKey: "insights.coach.metric.walking_hr",
label: "Walking HR",
value: { metric: "walking_hr" },
},
],
};
const ASK_SOURCE = {
windows: ["last30days"],
metrics: ["resting_hr"],
activity: [
{
id: "a1",
phase: "thinking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
durationMs: 1_200,
},
{
id: "a2",
phase: "asking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.asking",
label: "Asking you…",
durationMs: 300,
},
],
clarification: CLARIFICATION,
};
async function ask(
page: import("@playwright/test").Page,
stub: StubConversation,
) {
await page.locator('[data-slot="coach-input-textarea"]').fill(QUESTION);
await page.locator('[data-slot="coach-input-send"]').click();
const live = page.locator(
'[role="log"] [data-slot="coach-turn-steps-active"]',
);
await feed(page, [
{
type: "activity",
activity: {
...ASK_SOURCE.activity[0],
status: "running",
durationMs: undefined,
},
},
{
type: "activity",
activity: {
...ASK_SOURCE.activity[1],
status: "running",
durationMs: undefined,
},
},
]);
await expect(live).toHaveText("Asking you…");
stub.messages.push(
{ id: "coach-clarify-e2e-u1", role: "user", content: QUESTION },
{
id: "coach-clarify-e2e-a1",
role: "assistant",
content: ASKS,
metricSource: ASK_SOURCE,
},
);
await feed(page, [
...tokens(ASKS),
{ type: "activity", activity: ASK_SOURCE.activity[1] },
{ type: "provenance", metricSource: ASK_SOURCE },
{ type: "clarification", clarification: CLARIFICATION },
done(CONVERSATION_ID, "coach-clarify-e2e-a1"),
]);
await end(page);
}
test.describe("Coach clarifying question", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test("pills in the conversation, the assumed one first, and a tap answers", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"the flow is viewport-independent; the activity spec covers 390 px",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "light");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
const asked = page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ASKS });
await expect(asked).toHaveCount(1);
// 1. No card anywhere; the pills sit in the answer's own column.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = asked.locator('[data-slot="coach-suggested-replies"]');
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
"coach-clarify-e2e-a1",
);
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.
await expect(
asked.locator('[data-slot="coach-turn-steps-toggle"]'),
).toHaveAttribute("aria-expanded", "false");
// 2. A tap sends the label as the person's message.
const walking = choices.nth(1);
const label = (await walking.textContent())?.trim() ?? "";
await walking.click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: label }),
).toBeVisible();
await expect(pills).toHaveCount(0);
const sent = await posts(page);
expect(sent[1]).toMatchObject({
conversationId: CONVERSATION_ID,
message: label,
clarification: { messageId: "coach-clarify-e2e-a1", choiceId: "c2" },
});
stub.messages.push(
{ id: "coach-clarify-e2e-u2", role: "user", content: label },
{
id: "coach-clarify-e2e-a2",
role: "assistant",
content: ANSWER,
metricSource: { windows: ["last30days"], metrics: ["walking_hr"] },
},
);
await feed(page, [
...tokens(ANSWER),
done(CONVERSATION_ID, "coach-clarify-e2e-a2"),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: ANSWER }),
).toBeVisible();
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toHaveCount(0);
});
test("a typed answer answers the question too", async ({
page,
}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await serveAiBlock(page, aiBlockAvailable());
const stub: StubConversation = {
id: CONVERSATION_ID,
title: "Heart rate",
messages: [],
results: {},
trails: {},
};
await serveConversation(page, stub);
await installLiveCoachStream(page);
await applyTheme(page, "dark");
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await ask(page, stub);
await expect(
page.locator('[data-slot="coach-suggested-replies"]'),
).toBeVisible();
await page
.locator('[data-slot="coach-input-textarea"]')
.fill("the walking one");
await page.locator('[data-slot="coach-input-send"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "the walking one" }),
).toBeVisible();
const sent = await posts(page);
expect(sent[1]).toMatchObject({
message: "the walking one",
clarification: { messageId: "coach-clarify-e2e-a1" },
});
expect(
(sent[1].clarification as Record<string, unknown>).choiceId,
).toBeUndefined();
await end(page);
});
});
+21 -15
View File
@@ -17,12 +17,13 @@ import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
* puts the table on the clipboard.
* 3. A follow-up chip under the latest reply sends its label with
* `followUp: { messageId, id }`.
* 4. A reply that ends in a clarifying question shows the choices card
* and no chips (as the server sends it); a choice sends its label with
* `clarification: { messageId, choiceId }`, and the card leaves once a
* 4. A reply that ends in a clarifying question offers its choices as
* reply pills under it and no chips (as the server sends it); there is
* no card. A choice sends its label with
* `clarification: { messageId, choiceId }`, and the pills leave once a
* plain answer is the latest reply.
* 5. axe finds nothing on the answer, the chips and the clarification
* card, in the light and the dark theme.
* 5. axe finds nothing on the answer, the chips and the reply pills, in
* the light and the dark theme.
*
* The chat POST is a stubbed event stream in the documented frame order,
* and the conversation detail it refetches after `done` returns the same
@@ -441,7 +442,7 @@ async function expectNoAxeViolations(page: Page, label: string) {
const result = await new AxeBuilder({ page })
.include('[data-slot="coach-bubble-assistant"]')
.include('[data-slot="coach-follow-up-chips"]')
.include('[data-slot="coach-clarification-card"]')
.include('[data-slot="coach-suggested-replies"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(
@@ -581,26 +582,31 @@ test.describe("Coach dialog", () => {
});
expect(posts[1].clarification).toBeUndefined();
// 4. The reply asks; the card offers the choices, and the chips of
// the earlier reply are gone.
const card = page.locator('[data-slot="coach-clarification-card"]');
await expect(card).toBeVisible();
await expect(card).toHaveAttribute(
// 4. The reply asks; pills under it offer the choices, and the chips
// of the earlier reply are gone. No card.
await expect(
page.locator('[data-slot="coach-clarification-card"]'),
).toHaveCount(0);
const pills = secondBubble.locator(
'[data-slot="coach-suggested-replies"]',
);
await expect(pills).toBeVisible();
await expect(pills).toHaveAttribute(
"data-message-id",
TURNS[1].assistantId,
);
await expect(card.locator("[data-choice-id]")).toHaveCount(2);
await expect(pills.locator("[data-choice-id]")).toHaveCount(2);
await expect(chips).toHaveCount(0);
// The earlier result still renders under its own answer.
await expect(figure).toBeVisible();
// 5. axe over the answers, the result in both views and the card.
// 5. axe over the answers, the result in both views and the pills.
await expectNoAxeViolations(page, `${theme} chart view with a question`);
await figure.locator('[data-slot="coach-result-view-table"]').click();
await expect(table).toBeVisible();
await expectNoAxeViolations(page, `${theme} table view with a question`);
await card.locator('[data-choice-id="c2"]').click();
await pills.locator('[data-choice-id="c2"]').click();
const thirdBubble = page
.locator('[data-slot="coach-bubble-assistant"]')
@@ -616,7 +622,7 @@ test.describe("Coach dialog", () => {
expect(posts[2].followUp).toBeUndefined();
// A plain answer closes the question and offers no chips.
await expect(card).toHaveCount(0);
await expect(pills).toHaveCount(0);
await expect(chips).toHaveCount(0);
await expect(
thirdBubble.locator('[data-slot="coach-turn-steps"]'),
+436
View File
@@ -0,0 +1,436 @@
import type { Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
import {
FIXED_NOW,
done,
end,
feed,
installLiveCoachStream,
posts,
serveConversation,
tokens,
applyTheme,
type StubConversation,
} from "./setup/coach-live-stream";
/**
* What the Coach keeps, in the same quiet language as everything else in
* the thread, and the thinking depth in its quick settings.
*
* 1. A fact it saved: one meta line under the answer, "Remembered: …",
* with Undo, which forgets it.
* 2. A fact it proposes (health): no line; "Yes, remember it" / "No" as
* reply pills, a tap answers with `memoryDecision`.
* 3. A plan it proposes: "Take on this plan" / "Not now", with
* `planDecision`. No plan card in the thread.
* 4. The memory list in Settings → Coach: an entry is edited in place.
* 5. The thinking depth: levels above the operator's cap are disabled and
* say so; with reasoning switched off the field is locked with one
* sentence.
*/
const CONVERSATION_ID = "coach-memory-e2e";
function fulfilJson(route: Route, data: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
interface Fact {
id: string;
category: string;
text: string;
confidence: number;
source: string;
createdAt: string;
}
async function serveFacts(page: Page, facts: Fact[]) {
const calls: Array<{ method: string; path: string; body: unknown }> = [];
await page.route(
/\/api\/insights\/coach\/facts(?:\/[^?]+)?(?:\?.*)?$/,
async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
calls.push({
method,
path: url.pathname,
body: method === "GET" ? null : request.postDataJSON(),
});
const id = url.pathname.split("/").pop() ?? "";
if (method === "DELETE") {
const at = facts.findIndex((f) => f.id === id);
if (at !== -1) facts.splice(at, 1);
return fulfilJson(route, { deleted: true });
}
if (method === "PATCH") {
const fact = facts.find((f) => f.id === id);
const next = (request.postDataJSON() as { fact: string }).fact;
if (fact) fact.text = next;
return fulfilJson(route, { fact });
}
return fulfilJson(route, { facts });
},
);
return calls;
}
function stub(): StubConversation {
return {
id: CONVERSATION_ID,
title: "Goals",
messages: [],
results: {},
trails: {},
};
}
async function send(page: Page, text: string) {
await page.locator('[data-slot="coach-input-textarea"]').fill(text);
await page.locator('[data-slot="coach-input-send"]').click();
}
async function answer(
page: Page,
conversation: StubConversation,
turn: {
userId: string;
userText: string;
id: string;
text: string;
metricSource: Record<string, unknown>;
frames: unknown[];
providerType?: string;
},
) {
conversation.messages.push(
{ id: turn.userId, role: "user", content: turn.userText },
{
id: turn.id,
role: "assistant",
content: turn.text,
metricSource: turn.metricSource,
providerType: turn.providerType,
},
);
await feed(page, [
...tokens(turn.text),
{ type: "provenance", metricSource: turn.metricSource },
...turn.frames,
done(CONVERSATION_ID, turn.id),
]);
await end(page);
await expect(
page
.locator('[data-slot="coach-bubble-assistant"]')
.filter({ hasText: turn.text }),
).toBeVisible();
}
test.describe("Coach memory and plans", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-independent; desktop run suffices",
);
await page.clock.setFixedTime(FIXED_NOW);
await applyTheme(page, "light");
});
test("a saved fact is one quiet line, and Undo forgets it", async ({
page,
}) => {
await serveAiBlock(page, aiBlockAvailable());
const facts: Fact[] = [
{
id: "fact-1",
category: "goal",
text: "You want to reach 75 kg by December",
confidence: 1,
source: "coach",
createdAt: "2026-10-06T09:00:00.000Z",
},
];
const calls = await serveFacts(page, facts);
const conversation = stub();
await serveConversation(page, conversation);
await installLiveCoachStream(page);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await send(page, "I want to reach 75 kg by December.");
await answer(page, conversation, {
userId: "coach-memory-e2e-u1",
userText: "I want to reach 75 kg by December.",
id: "coach-memory-e2e-a1",
text: "That is about 3 kg from where you are now, a steady pace over ten weeks.",
metricSource: {
windows: [],
metrics: ["weight"],
memoryNote: { proposal: false, factId: "fact-1", category: "goal" },
},
frames: [
{
type: "memoryNote",
note: {
proposal: false,
factId: "fact-1",
category: "goal",
fact: "You want to reach 75 kg by December",
},
},
],
});
const note = page.locator('[data-slot="coach-memory-note"]');
await expect(note).toHaveText(
/Remembered: You want to reach 75 kg by December\.\s*Undo/,
);
// No card, no accent: a meta line.
await expect(note).toHaveClass(/text-muted-foreground/);
await note.locator('[data-slot="coach-memory-note-undo"]').click();
await expect(note).toHaveCount(0);
expect(
calls.some((c) => c.method === "DELETE" && c.path.endsWith("/fact-1")),
).toBe(true);
});
test("a proposed fact and a proposed plan are answered with reply pills", async ({
page,
}) => {
await serveAiBlock(page, aiBlockAvailable());
await serveFacts(page, []);
const conversation = stub();
await serveConversation(page, conversation);
await installLiveCoachStream(page);
await page.goto("/coach", { waitUntil: "domcontentloaded" });
// A health fact is proposed, never saved by itself.
await send(page, "I started taking ramipril in May.");
await answer(page, conversation, {
userId: "coach-memory-e2e-u1",
userText: "I started taking ramipril in May.",
id: "coach-memory-e2e-a1",
text: "Your readings since May sit a little lower. Should I remember that you take ramipril since May?",
metricSource: {
windows: [],
metrics: ["bp"],
memoryNote: {
proposal: true,
proposalId: "p1",
category: "medication",
},
},
frames: [
{
type: "memoryNote",
note: {
proposal: true,
proposalId: "p1",
category: "medication",
fact: "Takes ramipril since May",
},
},
],
});
await expect(page.locator('[data-slot="coach-memory-note"]')).toHaveCount(
0,
);
const pills = page.locator('[data-slot="coach-suggested-replies"]');
await expect(pills.locator("[data-reply-id]")).toHaveText([
"Yes, remember it",
"No",
]);
await pills.locator('[data-reply-id="memory-accept"]').click();
await expect(
page
.locator('[data-slot="coach-bubble-user"]')
.filter({ hasText: "Yes, remember it" }),
).toBeVisible();
expect((await posts(page))[1]).toMatchObject({
message: "Yes, remember it",
memoryDecision: {
messageId: "coach-memory-e2e-a1",
proposalId: "p1",
accept: true,
},
});
await answer(page, conversation, {
userId: "coach-memory-e2e-u2",
userText: "Yes, remember it",
id: "coach-memory-e2e-a2",
text: "Got it, I'll remember.",
providerType: "decision",
metricSource: { windows: [], metrics: [] },
frames: [],
});
await expect(pills).toHaveCount(0);
// A plan is one sentence in the answer, then two pills. No card.
await send(page, "How do I stop snacking late?");
await answer(page, conversation, {
userId: "coach-memory-e2e-u3",
userText: "How do I stop snacking late?",
id: "coach-memory-e2e-a3",
text: "When you finish dinner, then brush your teeth right away. I'll check in with you in 14 days.",
metricSource: {
windows: [],
metrics: [],
planProposal: { planId: "plan-1", metric: "WEIGHT", reviewInDays: 14 },
},
frames: [
{
type: "planProposal",
proposal: {
planId: "plan-1",
metric: "WEIGHT",
reviewInDays: 14,
ifCue: "When you finish dinner",
thenAction: "brush your teeth right away",
},
},
],
});
await expect(
page.locator('[data-slot="coach-plan-proposal-card"]'),
).toHaveCount(0);
await expect(pills.locator("[data-reply-id]")).toHaveText([
"Take on this plan",
"Not now",
]);
await pills.locator('[data-reply-id="plan-decline"]').click();
expect((await posts(page))[3]).toMatchObject({
message: "Not now",
planDecision: {
messageId: "coach-memory-e2e-a3",
planId: "plan-1",
accept: false,
},
});
await end(page);
});
test("an entry in the memory list is edited in place", async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
const calls = await serveFacts(page, [
{
id: "fact-1",
category: "goal",
text: "Wants to reach 80 kg by autumn",
confidence: 1,
source: "user",
createdAt: "2026-10-01T09:00:00.000Z",
},
{
id: "fact-2",
category: "preference",
text: "Prefers morning workouts",
confidence: 0.8,
source: "coach",
createdAt: "2026-10-02T09:00:00.000Z",
},
]);
await page.goto("/settings/coach#coach-memory", {
waitUntil: "domcontentloaded",
});
const card = page.locator("#coach-memory");
const rows = card.locator('[data-testid="settings-coach-memory-fact"]');
await expect(rows).toHaveCount(2);
await expect(rows.nth(0)).toContainText("from you");
await expect(rows.nth(1)).toContainText("from the Coach");
await rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit"]')
.click();
const input = rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit-input"]');
await input.fill("Wants to reach 78 kg by December");
await rows
.nth(0)
.locator('[data-slot="settings-coach-memory-edit-save"]')
.click();
await expect(rows.nth(0)).toContainText("Wants to reach 78 kg by December");
expect(
calls.find((c) => c.method === "PATCH" && c.path.endsWith("/fact-1"))
?.body,
).toEqual({ fact: "Wants to reach 78 kg by December" });
});
for (const scenario of [
{
name: "capped at Medium",
block: {
level: "medium",
preference: "medium",
maxLevel: "medium",
available: true,
offIsReal: true,
source: "admin_cap",
},
},
{
name: "switched off by the operator",
block: {
level: "off",
preference: "medium",
maxLevel: "off",
available: false,
offIsReal: true,
source: "admin_off",
},
},
]) {
test(`thinking depth in the quick settings, ${scenario.name}`, async ({
page,
}) => {
await page.route("**/api/auth/me", async (route) => {
const response = await route.fetch();
const body = (await response.json()) as {
data: Record<string, unknown> | null;
};
if (body.data) {
body.data.ai = aiBlockAvailable();
body.data.coachReasoning = scenario.block;
}
await route.fulfill({ response, json: body });
});
await serveFacts(page, []);
await serveConversation(page, stub());
await page.goto("/coach", { waitUntil: "domcontentloaded" });
await page.locator('[data-slot="coach-settings"]').first().click();
const select = page.locator('[data-slot="coach-reasoning-select"]');
await expect(select).toBeVisible();
await expect(select.locator("option")).toHaveText([
"Off",
"Low",
"Medium",
scenario.block.available ? "High (limited by the admin)" : "High",
]);
const field = page.locator('[data-slot="coach-reasoning-field"]');
if (scenario.block.available) {
await expect(select).toBeEnabled();
await expect(select.locator('option[value="high"]')).toBeDisabled();
await expect(field).toContainText("a little more waiting");
} else {
await expect(select).toBeDisabled();
await expect(field).toContainText(
"The admin has turned deeper thinking off.",
);
}
// The memory link names how much the Coach keeps.
await expect(
page.locator('[data-slot="coach-settings-memory-link"]'),
).toHaveText("Memory (0)");
});
}
});
+220
View File
@@ -0,0 +1,220 @@
import type { Page, Request, Route } from "@playwright/test";
/**
* A Coach chat stream the spec drives frame by frame.
*
* `route.fulfill` hands the browser the whole event stream at once, so the
* live states of a turn (the status line moving from "Thinking…" to
* "Fetching…" to "Summarising…", an interim table before the first token)
* would flash by unobserved. This replaces `fetch` in the page for the chat
* POST only: the response body is a stream the spec feeds with
* `feed(page, frames)` and closes with `end(page)`. Every other request goes
* through untouched, so `page.route` mocks keep working.
*
* The POST bodies are recorded in the page (`posts(page)`).
*/
export async function installLiveCoachStream(page: Page): Promise<void> {
await page.addInitScript(() => {
const w = window as unknown as {
__coachPosts: unknown[];
__coachFeed: (frames: unknown[]) => void;
__coachEnd: () => void;
};
const original = window.fetch.bind(window);
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
const encoder = new TextEncoder();
w.__coachPosts = [];
w.__coachFeed = (frames) => {
for (const frame of frames) {
controller?.enqueue(
encoder.encode(`data: ${JSON.stringify(frame)}\n\n`),
);
}
};
w.__coachEnd = () => {
controller?.close();
controller = null;
};
window.fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
if (
method === "POST" &&
new URL(url, window.location.href).pathname === "/api/insights/chat"
) {
w.__coachPosts.push(JSON.parse(String(init?.body ?? "{}")));
const body = new ReadableStream<Uint8Array>({
start(c) {
controller = c;
},
});
init?.signal?.addEventListener("abort", () => {
try {
controller?.error(new DOMException("aborted", "AbortError"));
} catch {
// already closed
}
});
return new Response(body, {
status: 200,
headers: { "Content-Type": "text/event-stream" },
});
}
return original(input, init);
};
});
}
export async function feed(page: Page, frames: unknown[]): Promise<void> {
await page.evaluate((f) => {
(window as unknown as { __coachFeed: (x: unknown[]) => void }).__coachFeed(
f,
);
}, frames);
}
export async function end(page: Page): Promise<void> {
await page.evaluate(() => {
(window as unknown as { __coachEnd: () => void }).__coachEnd();
});
}
export async function posts(
page: Page,
): Promise<Array<Record<string, unknown>>> {
return page.evaluate(
() =>
(window as unknown as { __coachPosts: Array<Record<string, unknown>> })
.__coachPosts,
);
}
export function tokens(text: string) {
return text.split(/(?<= )/).map((token) => ({ type: "token", token }));
}
function fulfilJson(route: Route, data: unknown) {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data, error: null }),
});
}
export interface StoredMessage {
id: string;
role: "user" | "assistant";
content: string;
metricSource?: Record<string, unknown> | null;
providerType?: string | null;
}
/**
* The conversation as the stubbed server holds it. The spec pushes the
* persisted turn before it sends `done`, so the refetch after `done` finds
* the copy that replaces the streamed reply.
*/
export interface StubConversation {
id: string;
title: string;
messages: StoredMessage[];
/** `…/messages/{id}/results`, by message id. */
results: Record<string, unknown[]>;
/** `…/messages/{id}/trail`, by message id. */
trails: Record<string, unknown>;
}
export async function serveConversation(
page: Page,
conversation: StubConversation,
): Promise<void> {
const at = (index: number) =>
new Date(Date.UTC(2026, 9, 6, 9, 0, index)).toISOString();
const summary = () => ({
id: conversation.id,
title: conversation.title,
createdAt: at(0),
updatedAt: at(conversation.messages.length),
messageCount: conversation.messages.length,
fenced: false,
attachments: [],
documentTitle: null,
});
await page.route(
/\/api\/insights\/chat(?:\/[^?]+)?(?:\?.*)?$/,
async (route: Route, request: Request) => {
const url = new URL(request.url());
const base = `/api/insights/chat/${conversation.id}`;
const message = url.pathname.match(
/\/messages\/([^/]+)\/(results|trail)$/,
);
if (message) {
const [, id, kind] = message;
return kind === "results"
? fulfilJson(route, { results: conversation.results[id] ?? [] })
: fulfilJson(route, {
trail: conversation.trails[id] ?? null,
ai: { available: true, reason: null },
});
}
if (url.pathname === base) {
return fulfilJson(route, {
...summary(),
attachmentCount: 0,
summary: null,
messages: conversation.messages.map((m, index) => ({
id: m.id,
role: m.role,
content: m.content,
createdAt: at(index + 1),
metricSource: m.metricSource ?? null,
providerType:
m.role === "assistant" ? (m.providerType ?? "mock") : null,
promptVersion: null,
tokensUsed: m.role === "assistant" ? 420 : null,
model: m.role === "assistant" ? "mock" : null,
})),
});
}
if (url.pathname === "/api/insights/chat" && request.method() === "GET") {
return fulfilJson(route, {
conversations: conversation.messages.length > 0 ? [summary()] : [],
nextCursor: null,
});
}
return route.fallback();
},
);
await page.route("**/api/insights/coach/nudge-status*", (route) =>
fulfilJson(route, { nudgedAt: null, unread: false }),
);
await page.route("**/api/coach/about-me/questions*", (route) =>
fulfilJson(route, { questions: [] }),
);
}
export function done(conversationId: string, messageId: string) {
return {
type: "done",
conversationId,
messageId,
usage: { totalTokens: 420 },
};
}
export async function applyTheme(page: Page, theme: "light" | "dark") {
await page.emulateMedia({ colorScheme: theme, reducedMotion: "reduce" });
await page.addInitScript((value: string) => {
window.localStorage.setItem("healthlog-theme", value);
}, theme);
}
/** Midday, so no "today / yesterday" boundary moves a label. */
export const FIXED_NOW = new Date("2026-10-06T12:00:00+02:00");
+163
View File
@@ -0,0 +1,163 @@
{
"en": {
"admin": {
"assistant": {
"reasoning": {
"description": "Models think before they answer: the Coach, the daily briefing and the period summaries. More precise, uses more tokens."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "The Coach already remembers this."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "In the Coach, the thinking depth from the Coach settings applies."
}
}
}
},
"de": {
"admin": {
"assistant": {
"reasoning": {
"description": "Modelle denken vor der Antwort nach: im Coach, im Tagesbriefing und in den Zusammenfassungen. Genauer, braucht mehr Tokens."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "Das weiß der Coach schon."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "Im Coach gilt die Denktiefe aus den Coach-Einstellungen."
}
}
}
},
"es": {
"admin": {
"assistant": {
"reasoning": {
"description": "Los modelos piensan antes de responder: el Coach, el resumen diario y los resúmenes por periodo. Más precisos, usan más tokens."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "El Coach ya lo recuerda."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "En el Coach se aplica la profundidad de pensamiento de los ajustes del Coach."
}
}
}
},
"fr": {
"admin": {
"assistant": {
"reasoning": {
"description": "Les modèles réfléchissent avant de répondre : le Coach, le briefing quotidien et les synthèses de période. Plus précis, plus de jetons."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "Le Coach s’en souvient déjà."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "Dans le Coach, c’est la profondeur de réflexion des réglages du Coach qui s’applique."
}
}
}
},
"it": {
"admin": {
"assistant": {
"reasoning": {
"description": "I modelli riflettono prima di rispondere: il Coach, il briefing giornaliero e i riepiloghi di periodo. Più precisi, usano più token."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "Il Coach lo ricorda già."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "Nel Coach vale la profondità di ragionamento delle impostazioni del Coach."
}
}
}
},
"pl": {
"admin": {
"assistant": {
"reasoning": {
"description": "Modele myślą przed odpowiedzią: Coach, codzienne podsumowanie i podsumowania okresów. Dokładniej, ale więcej tokenów."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "Coach już to pamięta."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "W Coachu obowiązuje głębokość myślenia z ustawień Coacha."
}
}
}
},
"ko": {
"admin": {
"assistant": {
"reasoning": {
"description": "모델이 답하기 전에 생각합니다: 코치, 일일 브리핑, 기간 요약. 더 정확하지만 토큰을 더 씁니다."
}
}
},
"insights": {
"coach": {
"rememberMessage": {
"alreadyKept": "코치가 이미 기억하고 있어요."
}
}
},
"settings": {
"ai": {
"reasoning": {
"coachNote": "코치에서는 코치 설정의 사고 깊이가 적용됩니다."
}
}
}
}
}
@@ -196,9 +196,26 @@ const REGISTRY: DestructiveEntry[] = [
},
{
file: "components/settings/coach-memory-section.tsx",
destroys: "one remembered Coach fact, or every one of them",
destroys: "every remembered Coach fact at once",
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton", "AlertDialog"],
confirm: ["AlertDialog"],
},
{
file: "components/insights/coach-panel/use-coach-facts.ts",
destroys: "one remembered Coach fact",
triggers: [
"components/settings/coach-memory-section.tsx",
"components/insights/coach-panel/memory-note.tsx",
],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
control: "Undo on the memory note under an answer — memory-note.tsx",
reason:
"Undo takes back what the Coach saved a moment ago on its own, from the person's own words in the same turn. Nothing the person wrote is lost: their message stays in the thread and the fact can be kept again with one tap. Asking to confirm an undo would make reversing the Coach dearer than letting it keep something unwanted. Forgetting from the memory list, where a fact may be old and relied on, is confirmed.",
},
],
},
{
file: "hooks/use-coach-reminders.ts",
@@ -210,16 +227,12 @@ const REGISTRY: DestructiveEntry[] = [
{
file: "hooks/use-coach-plans.ts",
destroys: "a Coach plan and the record of how it went",
triggers: [
"app/coach/plans/page.tsx",
"components/insights/coach-panel/plan-proposal-card.tsx",
],
triggers: ["app/coach/plans/page.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["ConfirmButton"],
unconfirmed: [
{
control:
"decline a PROPOSED plan — app/coach/plans/page.tsx and plan-proposal-card.tsx",
control: "decline a PROPOSED plan — app/coach/plans/page.tsx",
reason:
"Declining is the negative half of an accept/decline pair the assistant just offered; the person has invested nothing in a proposal they never adopted, and the same suggestion re-derives on a later turn. Confirming a refusal would make saying no more expensive than saying yes. The delete of a plan the person actually RAN is confirmed, and that is the one carrying history.",
},
@@ -190,9 +190,6 @@ const PINNED_AFFORDANCES: Record<
"src/components/insights/coach-panel/history-rail.tsx": {
"toast.success": 1,
},
"src/components/insights/coach-panel/plan-proposal-card.tsx": {
"text-success": 1,
},
"src/components/insights/coach-panel/reminder-suggestion-card.tsx": {
"text-success": 1,
},
+68 -2
View File
@@ -14,9 +14,23 @@ import {
} from "@/lib/query-keys";
import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import {
DEFAULT_REASONING_MAX_EFFORT,
REASONING_ADMIN_KEYS,
REASONING_LEVEL_LABEL_KEYS,
REASONING_MAX_EFFORTS,
type ReasoningMaxEffort,
} from "@/lib/ai/reasoning/levels";
/**
* Operator-side panel for the five assistant switches. The master stops
* Operator-side panel for the five assistant switches, and (v1.41) the two
* reasoning controls: whether models may think before they answer at all,
* and the highest depth a person may choose. Background jobs follow the
* same switch; there is no separate one.
*
* The five switches: The master stops
* every AI feature; four sub-switches each stop one cost or egress profile:
* the Coach, the daily briefing, status notes (per-reading notes, workout
* notes, reaction lines) and reading documents (the vault, lab scans,
@@ -48,8 +62,16 @@ interface AssistantFlagsResponse {
insightStatus: boolean;
documentAi: boolean;
};
/** v1.41 — the operator's reasoning controls; absent before v1.41. */
reasoning?: { enabled: boolean; maxEffort: ReasoningMaxEffort };
}
/** What the route accepts: any of the switches, and the reasoning pair. */
type AssistantFlagsPatch = Partial<AssistantFlagsResponse["raw"]> & {
aiReasoningEnabled?: boolean;
aiReasoningMaxEffort?: ReasoningMaxEffort;
};
function useAssistantFlags() {
return useQuery({
queryKey: queryKeys.adminAssistantFlags(),
@@ -66,7 +88,7 @@ function useUpdateAssistantFlags() {
const { t } = useTranslations();
return useMutation({
mutationFn: async (
patch: Partial<AssistantFlagsResponse["raw"]>,
patch: AssistantFlagsPatch,
): Promise<AssistantFlagsResponse> => {
return apiPut<AssistantFlagsResponse>(
"/api/admin/settings/assistant-flags",
@@ -102,6 +124,7 @@ export function AssistantSection() {
const raw = data?.raw;
const masterOn = raw?.assistantEnabled ?? true;
const disabledSubs = !masterOn || mutation.isPending;
const reasoningOn = data?.reasoning?.enabled ?? true;
return (
<SettingsCard>
@@ -159,6 +182,49 @@ export function AssistantSection() {
disabled={disabledSubs}
/>
</div>
{/* v1.41 — reasoning: allowed at all, and how deep at most. */}
<div
data-slot="admin-assistant-reasoning"
className="border-border space-y-4 border-t pt-4"
>
<SettingsToggle
label={t(REASONING_ADMIN_KEYS.enabled)}
description={t("admin.assistant.reasoning.description")}
checked={reasoningOn}
onCheckedChange={(checked) =>
mutation.mutate({ aiReasoningEnabled: checked })
}
disabled={disabledSubs}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<Label
htmlFor="admin-reasoning-max"
noColon
className="text-sm font-medium"
>
{t(REASONING_ADMIN_KEYS.max)}
</Label>
<NativeSelect
id="admin-reasoning-max"
data-slot="admin-reasoning-max"
className="sm:w-48"
value={data?.reasoning?.maxEffort ?? DEFAULT_REASONING_MAX_EFFORT}
disabled={disabledSubs || !reasoningOn}
onChange={(e) =>
mutation.mutate({
aiReasoningMaxEffort: e.target.value as ReasoningMaxEffort,
})
}
>
{REASONING_MAX_EFFORTS.map((effort) => (
<option key={effort} value={effort}>
{t(REASONING_LEVEL_LABEL_KEYS[effort])}
</option>
))}
</NativeSelect>
</div>
</div>
</div>
</SettingsCard>
);
@@ -0,0 +1,76 @@
/**
* v1.41 — "Assumed: last 30 days. Change": one quiet line, the change only
* under the latest answer and only with alternatives.
*/
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
import { CoachAssumptionLine, alternativesFor } from "../assumption-line";
const WINDOW: CoachAssumption = {
kind: "window",
value: {
labelKey: "coach.step.window.last30days",
label: "last 30 days",
value: { window: "last30days" },
},
alternatives: [],
};
const CHANGE: CoachFollowUp = {
id: "f1",
kind: "change_assumption",
labelKey: "coach.followUp.changeAssumption",
label: "Last 90 days instead",
reuse: false,
origin: "server",
assumption: { kind: "window", value: { window: "last90days" } },
};
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("<CoachAssumptionLine>", () => {
it("names the assumption in one meta line", () => {
const html = render(
<CoachAssumptionLine
assumptions={[WINDOW]}
changes={[]}
messageId={null}
disabled={false}
/>,
);
expect(html).toContain("Assumed: last 30 days.");
expect(html).toMatch(
/data-slot="coach-assumption-line"[^>]*class="text-muted-foreground/,
);
expect(html).not.toContain("coach-assumption-change");
});
it("offers Change under the latest answer, closed until tapped", () => {
const html = render(
<CoachAssumptionLine
assumptions={[WINDOW]}
changes={[CHANGE]}
messageId="m1"
disabled={false}
onChange={() => {}}
/>,
);
expect(html).toContain(">Change</button>");
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("Last 90 days instead");
});
it("matches alternatives to their assumption by kind", () => {
expect(alternativesFor(WINDOW, [CHANGE])).toEqual([CHANGE]);
expect(alternativesFor({ ...WINDOW, kind: "metric" }, [CHANGE])).toEqual(
[],
);
});
});
@@ -1,112 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
// The card's only hook is the translator; the server one resolves the same
// bundles and returns the key for a missing string, like the client one. With
// it, the card can be called as a plain function and its handlers exercised
// (the node test environment has no DOM to click through).
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string) => getServerTranslator("en").t(key),
}),
}));
const focusComposer = vi.fn();
vi.mock("../composer-focus", () => ({
focusCoachComposer: () => focusComposer(),
}));
import { CoachClarificationCard } from "../clarification-card";
import type { CoachClarification } from "@/lib/ai/coach/types";
const METRIC: CoachClarification = {
kind: "metric",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.pulse",
label: "Pulse (server)",
value: { metric: "pulse" },
},
{
id: "c2",
labelKey: "coach.not.a.key",
label: "Server label",
value: { metric: "resting_hr" },
},
],
freeText: true,
};
function card(clarification: CoachClarification, disabled = false) {
const onChoose = vi.fn();
const tree = CoachClarificationCard({
clarification,
messageId: "m1",
disabled,
onChoose,
});
return { tree, onChoose, html: renderToStaticMarkup(tree) };
}
function choiceButtons(
node: ReactNode,
): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap(choiceButtons);
if (!isValidElement(node)) return [];
const el = node as ReactElement<Record<string, unknown>>;
const own = el.props["data-choice-id"] ? [el] : [];
return [...own, ...choiceButtons(el.props.children as ReactNode)];
}
describe("<CoachClarificationCard>", () => {
it("is labelled, takes no focus, and leaves announcing to the live region beside it", () => {
const { html } = card(METRIC);
// The card mounts with its content, so a live region on the card itself
// is never heard; the conversation keeps one mounted for it.
expect(html).not.toContain("aria-live");
expect(html).toContain('aria-label="The Coach has a question"');
expect(html).not.toContain("autofocus");
expect(html).not.toContain("autoFocus");
});
it("renders the catalog label, falling back to the server label", () => {
const { html } = card(METRIC);
expect(html).toContain('aria-label="Possible answers"');
expect(html).toContain(">Pulse</button>");
expect(html).toContain(">Server label</button>");
expect(html).toContain("You can also type your own answer.");
});
it("sends the tapped choice", () => {
const { tree, onChoose } = card(METRIC);
const buttons = choiceButtons(tree);
expect(buttons).toHaveLength(2);
focusComposer.mockClear();
(buttons[1].props.onClick as () => void)();
expect(onChoose).toHaveBeenCalledWith(METRIC.choices[1]);
// The choices disable while the answer runs: focus goes to the composer.
expect(focusComposer).toHaveBeenCalledTimes(1);
});
it("disables the choices while a turn is in flight", () => {
const { tree } = card(METRIC, true);
for (const button of choiceButtons(tree)) {
expect(button.props.disabled).toBe(true);
}
});
it("asks for a typed answer on a context question", () => {
const { html, tree } = card({
kind: "context",
choices: [],
freeText: true,
});
expect(choiceButtons(tree)).toHaveLength(0);
expect(html).not.toContain('role="group"');
expect(html).toContain("Type your answer below.");
});
});
@@ -113,16 +113,15 @@ describe("<CoachConversation> page deep-link (#67)", () => {
expect(html).not.toContain('data-slot="coach-hero"');
});
it("keeps the clarification live region mounted before any question arrives", () => {
// A region that mounts together with its content is not announced; the
// empty region has to be in the page first.
it("asks no question in a card above the composer", () => {
// v1.41 — a clarifying question is a normal Coach message with reply
// pills under it; there is no card and no separate live region.
const html = render(
<CoachConversation surface="page" initialConversationId="older" />,
makeClient(),
);
expect(html).toMatch(
/<div[^>]*data-slot="coach-clarification-live"[^>]*aria-live="polite"[^>]*class="sr-only"[^>]*><\/div>/,
);
expect(html).not.toContain("coach-clarification-card");
expect(html).not.toContain("coach-clarification-live");
});
it("auto-opens the most-recent thread when none is pinned", () => {
@@ -8,7 +8,6 @@ import type {
import {
chainWithFirst,
modelControlFor,
reasoningFieldFor,
selectableProviders,
switchDidNotTake,
} from "../coach-model-picker";
@@ -120,20 +119,3 @@ describe("modelControlFor", () => {
).toEqual({ kind: "gateway", value: "qwen3:32b" });
});
});
describe("reasoningFieldFor", () => {
it("writes the Local and gateway entries' own reasoning fields", () => {
expect(
reasoningFieldFor("local", userProvider({ localReasoningEffort: "low" })),
).toEqual({ field: "localReasoningEffort", value: "low" });
expect(reasoningFieldFor("openai-compatible", userProvider())).toEqual({
field: "compatReasoningEffort",
value: null,
});
});
it("has nothing for providers without a reasoning setting", () => {
expect(reasoningFieldFor("openai", userProvider())).toBeNull();
expect(reasoningFieldFor("codex", userProvider())).toBeNull();
});
});
@@ -0,0 +1,129 @@
/**
* v1.41 — the Coach's thinking depth: Off / Low / Medium / High, rendered
* from what the server resolved, never recomputed.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string, params?: Record<string, string | number>) =>
getServerTranslator(locale.current).t(key, params),
}),
}));
import {
CoachReasoningSelect,
reasoningLocked,
reasoningOptions,
reasoningStateOrDefault,
shownReasoningLevel,
type CoachReasoningState,
} from "../coach-reasoning-field";
const OPEN: CoachReasoningState = {
level: "medium",
preference: "medium",
maxLevel: "high",
available: true,
offIsReal: true,
source: "user",
};
const ADMIN_OFF: CoachReasoningState = {
level: "off",
preference: "high",
maxLevel: "off",
available: false,
offIsReal: true,
source: "admin_off",
};
function render(state: CoachReasoningState, value = state.level) {
return renderToStaticMarkup(
<CoachReasoningSelect
id="r"
value={value}
state={state}
onChange={() => {}}
/>,
);
}
describe("the resolved block", () => {
it("reads an older server's missing block as allowed, uncapped, really off", () => {
expect(reasoningStateOrDefault(null)).toEqual(OPEN);
expect(reasoningStateOrDefault(undefined)).toEqual(OPEN);
});
it("is locked only when the operator switched reasoning off", () => {
expect(reasoningLocked(ADMIN_OFF)).toBe(true);
expect(reasoningLocked(OPEN)).toBe(false);
// A provider that cannot reason does not lock the person's choice.
expect(
reasoningLocked({ ...OPEN, available: false, source: "unsupported" }),
).toBe(false);
});
});
describe("<CoachReasoningSelect>", () => {
it("offers Off, Low, Medium and High with the label and hint", () => {
const html = render(OPEN);
expect(html).toContain(">Thinking depth<");
for (const label of ["Off", "Low", "Medium", "High"]) {
expect(html).toContain(`>${label}</option>`);
}
expect(html).toContain(
"Deeper thinking means more precise answers and a little more waiting.",
);
expect(html).not.toMatch(/<select[^>]*disabled/);
});
it("is locked with one sentence when the admin turned reasoning off", () => {
const html = render(ADMIN_OFF, "high");
expect(html).toMatch(/<select[^>]*disabled/);
expect(html).toContain('data-locked="true"');
expect(html).toContain("The admin has turned deeper thinking off.");
expect(html).not.toContain("a little more waiting");
});
it("shows the admin's cap: levels above it are disabled and say why", () => {
const html = render({ ...OPEN, maxLevel: "medium" });
expect(html).toMatch(
/<option value="high" disabled="">High \(limited by the admin\)<\/option>/,
);
expect(html).toMatch(
/<option value="medium"( selected="")?>Medium<\/option>/,
);
});
it("says Minimal where the provider cannot switch thinking off", () => {
const options = reasoningOptions(
{ ...OPEN, offIsReal: false },
getServerTranslator("en").t,
);
expect(options[0]).toEqual({
value: "off",
label: "Minimal",
disabled: false,
});
});
it("shows a choice above the cap at the cap, which is what runs", () => {
expect(shownReasoningLevel("high", { ...OPEN, maxLevel: "low" })).toBe(
"low",
);
expect(shownReasoningLevel("low", OPEN)).toBe("low");
expect(shownReasoningLevel("high", ADMIN_OFF)).toBe("off");
});
it("reads in German", () => {
locale.current = "de";
const html = render({ ...OPEN, maxLevel: "low" });
locale.current = "en";
expect(html).toContain(">Denktiefe<");
expect(html).toContain("Hoch (vom Admin begrenzt)");
});
});
@@ -100,4 +100,39 @@ describe("resolveCoachSendTarget", () => {
expect(target.url).toBe("/api/insights/chat");
},
);
it("carries a memory or plan decision on the tool route, never on the fenced one", () => {
const memory = JSON.parse(
resolveCoachSendTarget({
conversationId: "c1",
message: "Yes, remember it",
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
}).body,
);
expect(memory.memoryDecision).toEqual({
messageId: "a1",
proposalId: "p1",
accept: true,
});
const plan = JSON.parse(
resolveCoachSendTarget({
conversationId: "c1",
message: "Not now",
planDecision: { messageId: "a1", planId: "plan1", accept: false },
}).body,
);
expect(plan.planDecision).toEqual({
messageId: "a1",
planId: "plan1",
accept: false,
});
const fenced = resolveCoachSendTarget({
conversationId: "c1",
message: "Yes",
fenced: true,
memoryDecision: { messageId: "a1", proposalId: "p1", accept: true },
});
expect(fenced.url).toBe("/api/insights/chat/fenced");
expect(JSON.parse(fenced.body).memoryDecision).toBeUndefined();
});
});
@@ -109,13 +109,13 @@ describe("<CoachSettingsBody>", () => {
it("locks the reasoning select while a change is saving", async () => {
// Two writes in flight at once could land out of order and lose one;
// the provider and model selects already lock, the reasoning one too.
// the provider and model selects already lock, the thinking depth too.
const source = (await import("node:fs")).readFileSync(
new URL("../coach-model-picker.tsx", import.meta.url),
new URL("../coach-reasoning-field.tsx", import.meta.url),
"utf8",
);
const field = source.match(/<ReasoningEffortField[\s\S]*?\/>/)?.[0];
expect(field).toMatch(/disabled=\{busy\}/);
expect(source).toMatch(/disabled=\{locked \|\| busy\}/);
expect(source).toMatch(/busy=\{save\.isPending\}/);
});
it("names the data section so a deep link can land on it", () => {
@@ -0,0 +1,143 @@
/**
* 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");
});
});
@@ -0,0 +1,126 @@
/**
* v1.41 — the one quiet line under an answer that says the Coach kept
* something, with Undo.
*/
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { getServerTranslator } from "@/lib/i18n/server-translator";
const locale = vi.hoisted(() => ({ current: "en" as "en" | "de" }));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string, params?: Record<string, string | number>) =>
getServerTranslator(locale.current).t(key, params),
}),
}));
import { CoachMemoryNote, CoachMemoryNoteLine } from "../memory-note";
function render(node: ReactNode) {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
{node}
</QueryClientProvider>,
);
}
function find(
node: ReactNode,
slot: string,
): ReactElement<Record<string, unknown>> | null {
if (Array.isArray(node)) {
for (const child of node) {
const hit = find(child, slot);
if (hit) return hit;
}
return null;
}
if (!isValidElement(node)) return null;
const el = node as ReactElement<Record<string, unknown>>;
if (el.props["data-slot"] === slot) return el;
return find(el.props.children as ReactNode, slot);
}
describe("<CoachMemoryNoteLine>", () => {
it("says what was kept as one meta line, with Undo as a text button", () => {
locale.current = "en";
const html = render(
<CoachMemoryNoteLine
fact="You want to reach 75 kg by December."
pending={false}
onUndo={() => {}}
/>,
);
expect(html).toContain('data-slot="coach-memory-note"');
expect(html).toContain("Remembered: You want to reach 75 kg by December.");
expect(html).toMatch(/class="text-muted-foreground[^"]*text-xs/);
expect(html).toContain(">Undo</button>");
expect(html).not.toMatch(/\bbg-primary\b|text-muted-foreground\/\d/);
});
it("reads in German", () => {
locale.current = "de";
const html = render(
<CoachMemoryNoteLine
fact="Ziel 75 kg"
pending={false}
onUndo={() => {}}
/>,
);
locale.current = "en";
expect(html).toContain("Gemerkt: Ziel 75 kg.");
expect(html).toContain(">Rückgängig</button>");
});
it("Undo forgets the fact: one tap, one call", () => {
const onUndo = vi.fn();
const tree = CoachMemoryNoteLine({ fact: "x", pending: false, onUndo });
const undo = find(tree, "coach-memory-note-undo");
expect(undo).not.toBeNull();
(undo?.props.onClick as () => void)();
expect(onUndo).toHaveBeenCalledTimes(1);
});
it("locks Undo while the forget is on its way", () => {
const tree = CoachMemoryNoteLine({
fact: "x",
pending: true,
onUndo: () => {},
});
expect(find(tree, "coach-memory-note-undo")?.props.disabled).toBe(true);
});
});
describe("<CoachMemoryNote>", () => {
it("shows a saved fact from the live frame", () => {
const html = render(
<CoachMemoryNote
note={{ proposal: false, factId: "f1", category: "goal" }}
liveFact="Reach 75 kg by December"
/>,
);
expect(html).toContain("Remembered: Reach 75 kg by December.");
});
it("shows nothing for a proposal: the reply pills carry it", () => {
const html = render(
<CoachMemoryNote
note={{ proposal: true, proposalId: "p1", category: "medication" }}
liveFact="Takes ramipril"
/>,
);
expect(html).toBe("");
});
it("shows nothing for a persisted note whose fact is not in the list", () => {
const html = render(
<CoachMemoryNote
note={{ proposal: false, factId: "gone", category: "goal" }}
/>,
);
expect(html).toBe("");
});
});
@@ -100,6 +100,13 @@ function streaming(
results: [],
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -1,105 +0,0 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
import {
PlanProposalCards,
PROPOSAL_POLL_MS,
PROPOSAL_POLL_MAX_ATTEMPTS,
nextProposalPollInterval,
} from "../plan-proposal-card";
/**
* The plan-proposal confirm cards at the thread tail. SSR string render
* against a pre-seeded query cache (the `status:proposed` slot the component
* reads), so the tests pin the render contract without a network layer:
* only proposals born in the OPEN conversation render, the prose reads in
* the card, and both actions are present as real buttons.
*/
function plan(overrides: Partial<CoachPlanDTO> = {}): CoachPlanDTO {
return {
id: "p1",
metric: "WEIGHT",
ifCue: "every morning",
thenAction: "weigh in before breakfast",
target: "steady trend by autumn",
status: "proposed",
reviewDate: null,
sourceConversationId: "conv-1",
createdAt: "2026-07-01T00:00:00.000Z",
updatedAt: "2026-07-01T00:00:00.000Z",
...overrides,
};
}
function render(conversationId: string, plans: CoachPlanDTO[]) {
const client = new QueryClient();
client.setQueryData(queryKeys.coachPlans("status:proposed"), plans);
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<PlanProposalCards conversationId={conversationId} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<PlanProposalCards>", () => {
it("renders nothing when the conversation has no open proposal", () => {
expect(render("conv-1", [])).toBe("");
});
it("renders a confirm card for a proposal born in the open conversation", () => {
const html = render("conv-1", [plan()]);
expect(html).toContain('data-slot="coach-plan-proposal-card"');
// The plan prose is content — visible in the card.
expect(html).toContain("every morning");
expect(html).toContain("weigh in before breakfast");
expect(html).toContain("steady trend by autumn");
expect(html).toContain("WEIGHT");
// Both actions render as real buttons.
expect(html).toContain('data-slot="coach-plan-proposal-accept"');
expect(html).toContain('data-slot="coach-plan-proposal-decline"');
});
it("hides proposals born in a different conversation", () => {
const html = render("conv-2", [plan()]);
expect(html).toBe("");
});
it("skips a proposal whose prose is undecryptable (null fields)", () => {
const html = render("conv-1", [plan({ ifCue: null, thenAction: null })]);
expect(html).toBe("");
});
it("renders one card per open proposal", () => {
const html = render("conv-1", [
plan(),
plan({ id: "p2", ifCue: "after dinner", thenAction: "short walk" }),
]);
expect(html.match(/coach-plan-proposal-card/g)?.length).toBe(2);
expect(html).toContain("after dinner");
});
});
describe("nextProposalPollInterval", () => {
it("polls at the slow interval below the attempt ceiling", () => {
expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
PROPOSAL_POLL_MS,
);
});
it("stops polling once the attempt ceiling is reached", () => {
// Watched red: asserting an interval at the cap fails — the ceiling is
// live, not decorative.
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
false,
);
});
});
@@ -0,0 +1,208 @@
/**
* v1.41 — what the latest answer waits for (a clarifying question, a fact
* proposal, a plan proposal) becomes reply pills, and a tap answers it.
*/
import { describe, expect, it, vi } from "vitest";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { CoachMessageDTO, CoachProvenance } from "@/lib/ai/coach/types";
vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: null }) }));
import {
decisionReplies,
latestDecision,
type CoachReplyIntent,
} from "../message-thread";
import type { CoachStreamingMessage } from "../use-coach";
const { t } = getServerTranslator("en");
function assistant(id: string, metricSource: Partial<CoachProvenance>) {
return {
id,
role: "assistant",
content: "…",
createdAt: "2026-10-06T10:00:00.000Z",
metricSource: { windows: [], metrics: [], ...metricSource },
providerType: "mock",
promptVersion: null,
tokensUsed: null,
model: null,
} as unknown as CoachMessageDTO;
}
const user = (id: string) =>
({
id,
role: "user",
content: "Yes",
createdAt: "2026-10-06T10:01:00.000Z",
metricSource: null,
}) as unknown as CoachMessageDTO;
const CLARIFY = {
kind: "metric" as const,
freeText: true,
assumption: "c2",
choices: [
{
id: "c1",
labelKey: "insights.coach.metric.pulse",
label: "Pulse",
value: { metric: "pulse" as const },
},
{
id: "c2",
labelKey: "insights.coach.metric.resting_hr",
label: "Resting HR",
value: { metric: "resting_hr" as const },
},
],
};
function collect() {
const sent: CoachReplyIntent[] = [];
return { sent, onReply: (intent: CoachReplyIntent) => sent.push(intent) };
}
describe("latestDecision", () => {
it("is the latest answer's open question or proposal", () => {
const msgs = [
assistant("a1", {
memoryNote: {
proposal: true,
proposalId: "p1",
category: "medication",
},
}),
];
expect(latestDecision(msgs, undefined)).toEqual({
messageId: "a1",
clarification: null,
memoryProposalId: "p1",
planId: null,
});
});
it("is gone once the person has answered", () => {
const msgs = [assistant("a1", { clarification: CLARIFY }), user("u2")];
expect(latestDecision(msgs, undefined)).toBeNull();
});
it("offers nothing while a turn runs, and nothing for a saved note", () => {
const msgs = [assistant("a1", { clarification: CLARIFY })];
expect(
latestDecision(msgs, { inProgress: true } as CoachStreamingMessage),
).toBeNull();
expect(
latestDecision(
[
assistant("a1", {
memoryNote: { proposal: false, factId: "f1", category: "goal" },
}),
],
undefined,
),
).toBeNull();
});
it("reads the just-streamed turn before its persisted copy lands", () => {
const streaming = {
inProgress: false,
messageId: "a9",
clarification: null,
memoryNote: null,
planProposal: {
planId: "plan1",
metric: "WEIGHT",
reviewInDays: 14,
ifCue: "after dinner",
thenAction: "walk",
},
} as unknown as CoachStreamingMessage;
expect(latestDecision([], streaming)?.planId).toBe("plan1");
});
});
describe("decisionReplies", () => {
it("puts the assumed choice first and sends it as a clarification", () => {
const { sent, onReply } = collect();
const replies = decisionReplies(
{
messageId: "a1",
clarification: CLARIFY,
memoryProposalId: null,
planId: null,
},
t,
onReply,
);
expect(replies.map((r) => r.label)).toEqual(["Resting HR", "Pulse"]);
replies[0].onSelect();
expect(sent[0]).toMatchObject({
kind: "clarification",
messageId: "a1",
choice: { id: "c2" },
label: "Resting HR",
});
});
it("confirms a fact proposal with Yes and declines it with No", () => {
const { sent, onReply } = collect();
const replies = decisionReplies(
{
messageId: "a1",
clarification: null,
memoryProposalId: "p1",
planId: null,
},
t,
onReply,
);
expect(replies.map((r) => r.label)).toEqual(["Yes, remember it", "No"]);
replies[0].onSelect();
replies[1].onSelect();
expect(sent).toEqual([
{
kind: "memory",
messageId: "a1",
proposalId: "p1",
accept: true,
label: "Yes, remember it",
},
{
kind: "memory",
messageId: "a1",
proposalId: "p1",
accept: false,
label: "No",
},
]);
});
it("takes on or postpones a plan", () => {
const { sent, onReply } = collect();
const replies = decisionReplies(
{
messageId: "a1",
clarification: null,
memoryProposalId: null,
planId: "plan1",
},
getServerTranslator("de").t,
onReply,
);
expect(replies.map((r) => r.label)).toEqual([
"Plan übernehmen",
"Nicht jetzt",
]);
replies[1].onSelect();
expect(sent[0]).toEqual({
kind: "plan",
messageId: "a1",
planId: "plan1",
accept: false,
label: "Nicht jetzt",
});
});
});
@@ -0,0 +1,167 @@
/**
* v1.41 — the compare chart: two periods overlaid on one axis, or two
* metrics with two axes whose grid lines meet. Recharts is replaced by
* pass-through parts so the props the chart hands it can be read without a
* browser layout.
*/
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
const calls = vi.hoisted(() => ({
yAxis: [] as Array<Record<string, unknown>>,
line: [] as Array<Record<string, unknown>>,
}));
vi.mock("recharts", () => {
const Pass = ({ children }: { children?: ReactNode }) => <>{children}</>;
const Leaf = () => null;
return {
ResponsiveContainer: Pass,
LineChart: Pass,
BarChart: Pass,
CartesianGrid: Leaf,
Bar: Leaf,
Tooltip: Leaf,
XAxis: Leaf,
Line: (props: Record<string, unknown>) => {
calls.line.push(props);
return null;
},
YAxis: (props: Record<string, unknown>) => {
calls.yAxis.push(props);
return null;
},
};
});
import { I18nProvider } from "@/lib/i18n/context";
import type { CoachChartSpec, CoachResultTable } from "@/lib/ai/coach/types";
import { CoachResultChart, alignedAxes, comparePoints } from "../result-chart";
function table(
chart: Extract<CoachChartSpec, { kind: "compare" }>,
rows: CoachResultTable["rows"],
labels: [string, string],
): CoachResultTable {
return {
ref: "r1",
source: {
tool: "compare_series" as never,
domain: "resting_hr",
window: "last30days",
period: "current",
},
shape: "timeSeries",
titleKey: "k",
title: "Resting heart rate and sleep",
rowCount: rows.length,
chartKind: "compare",
displayed: true,
columns: [
{ key: "period", kind: "category", labelKey: "k", label: "Day" },
{
key: "a",
kind: "number",
labelKey: "k",
label: labels[0],
unit: "bpm",
},
{ key: "b", kind: "number", labelKey: "k", label: labels[1], unit: "h" },
],
rows,
truncated: false,
chart,
};
}
const METRICS = table(
{ kind: "compare", mode: "metrics", x: "period", a: "a", b: "b", axes: 2 },
[
["1", 58, 7.5],
["2", 61, 6.1],
["3", null, 6.8],
["4", 59, 8.0],
],
["Resting HR", "Sleep"],
);
const PERIODS = table(
{ kind: "compare", mode: "periods", x: "period", a: "a", b: "b", axes: 1 },
[
["1", 58, 60],
["2", 61, 62],
],
["This month", "Last month"],
);
function render(result: CoachResultTable) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CoachResultChart result={result} otherLabel="Other" label="Chart" />
</I18nProvider>,
);
}
beforeEach(() => {
calls.yAxis.length = 0;
calls.line.length = 0;
});
describe("compare chart", () => {
it("renders, where the placeholder rendered nothing", () => {
const html = render(METRICS);
expect(html).toContain('data-slot="coach-result-chart-compare"');
expect(html).toContain('role="img"');
// A legend button per series, both shown.
expect(html.match(/aria-pressed="true"/g)).toHaveLength(2);
expect(html).toContain("Resting HR");
expect(html).toContain("Sleep");
});
it("draws two metrics on two axes whose grid lines meet", () => {
render(METRICS);
const [left, right] = calls.yAxis;
expect(left.yAxisId).toBe("left");
expect(right.yAxisId).toBe("right");
expect(right.orientation).toBe("right");
expect((left.ticks as number[]).length).toBe(
(right.ticks as number[]).length,
);
expect(calls.line.map((l) => [l.dataKey, l.yAxisId, l.stroke])).toEqual([
["a", "left", "var(--chart-1)"],
["b", "right", "var(--chart-2)"],
]);
// A gap stays a gap.
expect(calls.line.every((l) => l.connectNulls === false)).toBe(true);
});
it("overlays two periods on one axis, the earlier one dashed, named a vs b", () => {
const html = render(PERIODS);
expect(calls.yAxis).toHaveLength(1);
expect(calls.line.map((l) => l.strokeDasharray)).toEqual([
undefined,
"5 4",
]);
expect(html).toContain('aria-label="This month vs Last month"');
});
it("reads its rows by the spec's columns", () => {
expect(comparePoints(METRICS, METRICS.chart as never)[2]).toEqual({
x: "3",
a: null,
b: 6.8,
});
});
it("widens the second axis until its ticks line up with the first", () => {
const axes = alignedAxes([50, 90], [6, 7]);
expect(axes).not.toBeNull();
expect(axes?.right.ticks.length).toBe(axes?.left.ticks.length);
const steps = axes!.right.ticks
.slice(1)
.map((v, i) => v - axes!.right.ticks[i]);
expect(new Set(steps.map((s) => s.toFixed(6))).size).toBe(1);
expect(alignedAxes([], [1])).toBeNull();
});
});
@@ -0,0 +1,103 @@
/**
* v1.41 — the one quiet reply pattern: pills under the latest answer that
* send their label as the person's own message.
*/
import { describe, expect, it, vi } from "vitest";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { getServerTranslator } from "@/lib/i18n/server-translator";
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({
t: (key: string, params?: Record<string, string | number>) =>
getServerTranslator("en").t(key, params),
}),
}));
const focusComposer = vi.fn();
vi.mock("../composer-focus", () => ({
focusCoachComposer: () => focusComposer(),
}));
import { SuggestedReplies, type SuggestedReply } from "../suggested-replies";
function pills(node: ReactNode): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) return node.flatMap(pills);
if (!isValidElement(node)) return [];
const el = node as ReactElement<Record<string, unknown>>;
const own = el.props["data-reply-id"] ? [el] : [];
return [...own, ...pills(el.props.children as ReactNode)];
}
function replies(onSelect: (id: string) => void): SuggestedReply[] {
return [
"Resting heart rate",
"Walking heart rate",
"Both",
"Neither",
"Fifth",
].map((label, i) => ({
id: `c${i + 1}`,
label,
kind: "clarification",
data: { "data-choice-id": `c${i + 1}` },
onSelect: () => onSelect(`c${i + 1}`),
}));
}
describe("<SuggestedReplies>", () => {
it("renders at most four quiet outline pills in a labelled group", () => {
const tree = SuggestedReplies({
replies: replies(() => {}),
messageId: "m1",
disabled: false,
});
const html = tree ? renderToStaticMarkup(tree) : "";
expect(html).toContain('role="group"');
expect(html).toContain('aria-label="Suggested replies"');
expect(html).toContain('data-slot="coach-suggested-replies"');
expect(html).toContain('data-message-id="m1"');
expect(html).not.toContain("Fifth");
const rendered = pills(tree);
expect(rendered).toHaveLength(4);
for (const pill of rendered) {
expect(pill.props.variant).toBe("outline");
const cls = String(pill.props.className);
// The answer's text size, low contrast in the foreground, a pill.
expect(cls).toContain("text-sm");
expect(cls).toContain("font-normal");
expect(cls).toContain("text-foreground/80");
expect(cls).toContain("rounded-full");
expect(cls).toContain("min-h-11");
// Never an accent, never muted-with-alpha.
expect(cls).not.toMatch(/\bprimary\b|text-muted-foreground\/\d/);
}
});
it("sends the tapped reply and hands focus to the composer", () => {
focusComposer.mockClear();
const onSelect = vi.fn();
const tree = SuggestedReplies({
replies: replies(onSelect),
messageId: "m1",
disabled: false,
});
(pills(tree)[1].props.onClick as () => void)();
expect(onSelect).toHaveBeenCalledWith("c2");
expect(focusComposer).toHaveBeenCalledTimes(1);
});
it("is hidden while a turn runs, and when there is nothing to offer", () => {
expect(
SuggestedReplies({
replies: replies(() => {}),
messageId: "m1",
disabled: true,
}),
).toBeNull();
expect(
SuggestedReplies({ replies: [], messageId: "m1", disabled: false }),
).toBeNull();
});
});
@@ -0,0 +1,556 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
import {
CoachTurnActivity,
CoachTurnStepList,
activityLineLabel,
activitySeconds,
activitySummary,
countLookups,
countSources,
currentActivity,
currentStep,
describeStep,
legacyAreaLabels,
stepLabel,
} from "../turn-activity";
function render(node: React.ReactNode, locale: Locale = "en") {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale={locale}>{node}</I18nProvider>
</QueryClientProvider>,
);
}
function translators(locale: Locale) {
const { t } = getServerTranslator(locale);
const tCount = (base: string, count: number) =>
t(pluralKey(base, count, locale), { count });
return { t, tCount };
}
const BP: CoachStep = {
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 90 days",
domain: "bp",
window: "last90days",
status: "done",
count: 142,
};
const SLEEP: CoachStep = {
id: "s2",
tool: "get_sleep",
labelKey: "coach.step.readWindow",
label: "Checking: Sleep, last 30 days",
domain: "sleep",
window: "last30days",
status: "empty",
reason: "no_data",
};
const LABS_RUNNING: CoachStep = {
id: "s3",
tool: "get_labs",
labelKey: "coach.step.readWindow",
label: "Checking: Lab results, last 12 months",
domain: "labs",
window: "lastYear",
status: "running",
};
describe("describeStep", () => {
const { t, tCount } = translators("en");
it("reads domain · window · count on a found step", () => {
expect(describeStep(BP, t, tCount)).toEqual({
title: "Blood pressure",
meta: ["last 90 days", "142 readings"],
});
});
it("says why nothing was found on an empty step", () => {
expect(describeStep(SLEEP, t, tCount)).toEqual({
title: "Sleep",
meta: ["last 30 days", "no readings"],
});
});
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
const outside: CoachStep = {
...SLEEP,
reason: "outside_window",
count: 40,
};
expect(describeStep(outside, t, tCount).meta).toEqual([
"last 30 days",
"no readings in this window",
]);
});
it("counts metrics on the snapshot step and rows on labs", () => {
const snapshot: CoachStep = {
id: "s1",
tool: "snapshot",
labelKey: "coach.step.snapshot",
label: "Reading your record summary",
domain: "snapshot",
status: "done",
count: 1,
};
expect(describeStep(snapshot, t, tCount)).toEqual({
title: "Record summary",
meta: ["1 metric"],
});
expect(
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
.meta,
).toEqual(["last 12 months", "3 rows"]);
});
it("renders in the reader's language from the keys, not the server label", () => {
const de = translators("de");
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
"letzte 90 Tage",
"142 Messwerte",
]);
});
it("falls back to the server label for a key the bundle lacks", () => {
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
BP.label,
);
});
});
const THINKING_RUNNING: CoachActivity = {
id: "a1",
phase: "thinking",
status: "running",
round: 1,
labelKey: "insights.coach.activity.thinking",
label: "Thinking…",
};
const FETCH_BP: CoachActivity = {
id: "a2",
phase: "fetch",
status: "done",
round: 1,
labelKey: "insights.coach.activity.fetching",
label: "Fetching blood pressure, last 90 days…",
stepRef: "s1",
durationMs: 900,
};
const FETCH_SLEEP: CoachActivity = {
id: "a3",
phase: "fetch",
status: "empty",
round: 1,
labelKey: "insights.coach.activity.fetching",
label: "Fetching sleep, last 30 days…",
stepRef: "s2",
durationMs: 1_400,
};
const DIGEST_RUNNING: CoachActivity = {
id: "a4",
phase: "digest",
status: "running",
round: 1,
labelKey: "insights.coach.activity.digestOther",
label: "Summarising 214 readings…",
count: 214,
};
const THINKING_DONE: CoachActivity = {
...THINKING_RUNNING,
status: "done",
durationMs: 4_000,
title: "Comparing the two months",
text: "The second half of the month runs higher.",
};
const DIGEST_DONE: CoachActivity = {
...DIGEST_RUNNING,
status: "done",
label: "214 readings from 2 areas",
durationMs: 600,
};
const ANSWER_DONE: CoachActivity = {
id: "a5",
phase: "answer",
status: "done",
round: 2,
labelKey: "insights.coach.activity.answer",
label: "Writing the answer…",
durationMs: 6_000,
};
const ASKING: CoachActivity = {
id: "a6",
phase: "asking",
status: "done",
round: 1,
labelKey: "insights.coach.activity.asking",
label: "Asking you…",
durationMs: 1_000,
};
const STOPPED: CoachActivity = {
id: "a7",
phase: "stop",
status: "done",
round: 6,
labelKey: "insights.coach.activity.stop.time",
label: "Time limit reached, answering with what it has",
stop: "time",
};
const SETTLED = [
THINKING_DONE,
FETCH_BP,
FETCH_SLEEP,
DIGEST_DONE,
ANSWER_DONE,
];
describe("the running line", () => {
it("names the latest running entry, else the latest one", () => {
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
"a4",
);
expect(currentActivity([THINKING_DONE, FETCH_BP])?.id).toBe("a2");
expect(currentActivity([])).toBeNull();
});
it("shows a reasoning title once one arrives, the catalog label before", () => {
expect(activityLineLabel(THINKING_RUNNING)).toBe("Thinking…");
expect(
activityLineLabel({ ...THINKING_RUNNING, title: "Checking the trend" }),
).toBe("Checking the trend");
// Model text never replaces a fetch's catalog label, and a finished
// entry no longer trails off.
expect(activityLineLabel({ ...FETCH_BP, title: "x" })).toBe(
"Fetching blood pressure, last 90 days",
);
expect(
activityLineLabel({ ...FETCH_BP, status: "running", title: "x" }),
).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}
/>,
),
);
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 sit on the right of the line.
expect(html).toMatch(/data-slot="coach-turn-steps-seconds"[^>]*>1[23]s</);
}
});
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"');
});
it("falls back to the step label on a server that sends only steps", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
);
expect(html).toContain("Checking: Lab results, last 12 months");
});
});
describe("nothing opens by itself", () => {
const states: Array<[string, React.ReactNode]> = [
[
"before any frame",
<CoachTurnActivity key="0" activity={[]} steps={[]} active />,
],
[
"running",
<CoachTurnActivity
key="1"
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
],
[
"asking",
<CoachTurnActivity
key="2"
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
/>,
],
[
"stopped",
<CoachTurnActivity
key="3"
activity={[...SETTLED, STOPPED]}
steps={[BP, SLEEP]}
active={false}
/>,
],
[
"settled",
<CoachTurnActivity
key="4"
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
method={{ entries: [], text: "Blood pressure, 9 readings." }}
dataUsed={<div data-slot="coach-data-used" />}
/>,
],
[
"reloaded (metadata only)",
<CoachTurnActivity
key="5"
activity={JSON.parse(
JSON.stringify(
SETTLED.map(({ title: _t, text: _x, ...meta }) => meta),
),
)}
steps={[BP, SLEEP]}
active={false}
conversationId="c1"
messageId="m1"
/>,
],
[
"legacy steps",
<CoachTurnActivity
key="6"
activity={[]}
steps={[BP, SLEEP]}
active={false}
/>,
],
];
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('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.");
});
}
});
describe("after the answer", () => {
const { t } = translators("en");
it("collapses to one calm summary of lookups and seconds", () => {
const html = render(
<CoachTurnActivity
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
/>,
);
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<',
);
// 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"');
});
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",
);
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} />,
);
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(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
);
});
});
describe("currentStep", () => {
it("names the latest running step, else the latest step", () => {
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
expect(currentStep([])).toBeNull();
});
});
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");
it("names the known areas and drops unknown tokens and general", () => {
expect(
legacyAreaLabels(
["bp", "bloodPressure" as never, "general", "sleep", "bp"],
t,
),
).toEqual(["Blood pressure", "Sleep"]);
});
});
@@ -1,378 +0,0 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
import type { CoachStep } from "@/lib/ai/coach/types";
import {
CoachTurnStepList,
CoachTurnSteps,
CoachTurnStepsPanel,
countSources,
legacyAreaLabels,
currentStep,
describeStep,
nextAnnouncement,
stepLabel,
} from "../turn-steps";
function render(node: React.ReactNode, locale: Locale = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function translators(locale: Locale) {
const { t } = getServerTranslator(locale);
const tCount = (base: string, count: number) =>
t(pluralKey(base, count, locale), { count });
return { t, tCount };
}
const BP: CoachStep = {
id: "s1",
tool: "get_metric_series",
labelKey: "coach.step.readWindow",
label: "Checking: Blood pressure, last 90 days",
domain: "bp",
window: "last90days",
status: "done",
count: 142,
};
const SLEEP: CoachStep = {
id: "s2",
tool: "get_sleep",
labelKey: "coach.step.readWindow",
label: "Checking: Sleep, last 30 days",
domain: "sleep",
window: "last30days",
status: "empty",
reason: "no_data",
};
const LABS_RUNNING: CoachStep = {
id: "s3",
tool: "get_labs",
labelKey: "coach.step.readWindow",
label: "Checking: Lab results, last 12 months",
domain: "labs",
window: "lastYear",
status: "running",
};
describe("describeStep", () => {
const { t, tCount } = translators("en");
it("reads domain · window · count on a found step", () => {
expect(describeStep(BP, t, tCount)).toEqual({
title: "Blood pressure",
meta: ["last 90 days", "142 readings"],
});
});
it("says why nothing was found on an empty step", () => {
expect(describeStep(SLEEP, t, tCount)).toEqual({
title: "Sleep",
meta: ["last 30 days", "no readings"],
});
});
it("shows no count on a miss, even when the record holds rows elsewhere", () => {
const outside: CoachStep = {
...SLEEP,
reason: "outside_window",
count: 40,
};
expect(describeStep(outside, t, tCount).meta).toEqual([
"last 30 days",
"no readings in this window",
]);
});
it("counts metrics on the snapshot step and rows on labs", () => {
const snapshot: CoachStep = {
id: "s1",
tool: "snapshot",
labelKey: "coach.step.snapshot",
label: "Reading your record summary",
domain: "snapshot",
status: "done",
count: 1,
};
expect(describeStep(snapshot, t, tCount)).toEqual({
title: "Record summary",
meta: ["1 metric"],
});
expect(
describeStep({ ...LABS_RUNNING, status: "done", count: 3 }, t, tCount)
.meta,
).toEqual(["last 12 months", "3 rows"]);
});
it("renders in the reader's language from the keys, not the server label", () => {
const de = translators("de");
expect(stepLabel(BP, de.t)).toBe("Prüfe: Blutdruck, letzte 90 Tage");
expect(describeStep(BP, de.t, de.tCount).meta).toEqual([
"letzte 90 Tage",
"142 Messwerte",
]);
});
it("falls back to the server label for a key the bundle lacks", () => {
expect(stepLabel({ ...BP, labelKey: "coach.step.fromTheFuture" }, t)).toBe(
BP.label,
);
});
});
describe("currentStep / nextAnnouncement", () => {
it("names the latest running step, else the latest step", () => {
expect(currentStep([BP, LABS_RUNNING, SLEEP])?.id).toBe("s3");
expect(currentStep([BP, SLEEP])?.id).toBe("s2");
expect(currentStep([])).toBeNull();
});
it("announces only settled steps not yet told, the newest one", () => {
expect(nextAnnouncement([BP, SLEEP, LABS_RUNNING], new Set())?.id).toBe(
"s2",
);
expect(nextAnnouncement([BP, SLEEP], new Set(["s1", "s2"]))).toBeNull();
expect(nextAnnouncement([LABS_RUNNING], new Set())).toBeNull();
});
});
describe("<CoachTurnSteps>", () => {
it("renders nothing without steps", () => {
expect(render(<CoachTurnSteps steps={[]} active />)).not.toContain(
"coach-turn-steps",
);
});
it("while active: the running step in the header, with a stable accessible name", () => {
const html = render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} active />);
expect(html).toContain('data-slot="coach-turn-steps-active"');
expect(html).toContain("Checking: Lab results, last 12 months…");
expect(html).toContain('aria-label="Show what the Coach looked at"');
expect(html).toContain('aria-expanded="false"');
// The shimmer comes from the class that stops under reduced motion.
expect(html).toContain("skeleton-shimmer");
// Collapsed by default.
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
});
it("when done: folds into a count of sources, closed", () => {
const html = render(<CoachTurnSteps steps={[BP, SLEEP]} active={false} />);
expect(html).toContain("Looked at 2 sources");
expect(html).not.toContain("skeleton-shimmer");
expect(html).not.toContain("aria-label=");
expect(html).not.toContain('data-slot="coach-turn-steps-list"');
});
it("counts distinct sources: a second read of the same domain is not a new source", () => {
const bpPrevious: CoachStep = {
...BP,
id: "s4",
period: "previous",
count: 120,
};
const snapshot: CoachStep = {
id: "s5",
tool: "snapshot",
labelKey: "coach.step.snapshot",
label: "Overview",
status: "done",
};
expect(countSources([BP, bpPrevious, SLEEP])).toBe(2);
expect(countSources([BP, snapshot, snapshot])).toBe(2);
const html = render(
<CoachTurnSteps steps={[BP, bpPrevious, SLEEP]} active={false} />,
);
expect(html).toContain("Looked at 2 sources");
});
it("carries a polite status region that starts empty", () => {
const html = render(<CoachTurnSteps steps={[BP]} active={false} />);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
);
});
it("uses theme tokens only, and every motion stops under reduced motion", () => {
const html =
render(<CoachTurnSteps steps={[BP, LABS_RUNNING]} 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}`);
}
}
// The spinner stops under reduced motion (the repo-wide convention).
expect(html).toContain("animate-spin motion-reduce:animate-none");
});
});
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,
"",
);
// Strip tags until nothing changes, so no fragment can survive one pass.
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");
});
it("a reloaded list shows the same rows", () => {
const live = render(<CoachTurnStepList steps={[BP, SLEEP]} active />);
const stored = render(
<CoachTurnStepList
steps={JSON.parse(JSON.stringify([BP, SLEEP]))}
active={false}
/>,
);
const text = (h: string) => h.replace(/<svg[\s\S]*?<\/svg>/g, "");
expect(text(stored)).toBe(text(live));
});
});
describe("the open list: method and the tables the answer read", () => {
const METHOD = {
entries: [],
text: "Blood pressure, last 30 days, 9 readings, daily averages.",
};
it("ends with the method line and the data-used slot, after the steps", () => {
const html = render(
<CoachTurnStepsPanel
steps={[BP]}
active={false}
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used">tables</div>}
/>,
);
const steps = html.indexOf('data-slot="coach-turn-steps-list"');
const method = html.indexOf('data-slot="coach-method-line"');
const dataUsed = html.indexOf('data-slot="coach-data-used"');
expect(steps).toBeGreaterThan(-1);
expect(method).toBeGreaterThan(steps);
expect(dataUsed).toBeGreaterThan(method);
expect(html).toContain(METHOD.text);
});
it("holds the method back while the turn runs", () => {
const html = render(
<CoachTurnStepsPanel
steps={[LABS_RUNNING]}
active
areaLabels={[]}
method={METHOD}
dataUsed={<div data-slot="coach-data-used" />}
/>,
);
expect(html).not.toContain("coach-method-line");
expect(html).not.toContain("coach-data-used");
});
it("lists an older message's areas, without counts", () => {
const html = render(
<CoachTurnStepsPanel
steps={[]}
active={false}
areaLabels={["Blood pressure", "Sleep"]}
/>,
);
expect(html).toContain('data-slot="coach-turn-areas"');
expect((html.match(/data-slot="coach-turn-area"/g) ?? []).length).toBe(2);
expect(html).not.toMatch(/\d+ readings/);
});
it("shows a header for an answer that has only a method", () => {
const html = render(
<CoachTurnSteps steps={[]} active={false} method={METHOD} />,
);
expect(html).toContain("What the Coach looked at");
});
});
describe("legacyAreaLabels", () => {
const { t } = translators("en");
it("names the known areas and drops unknown tokens and general", () => {
expect(
legacyAreaLabels(
["bp", "bloodPressure" as never, "general", "sleep", "bp"],
t,
),
).toEqual(["Blood pressure", "Sleep"]);
});
it("folds an older message into a count of areas", () => {
const html = render(
<CoachTurnSteps steps={[]} active={false} areas={["bp", "sleep"]} />,
);
expect(html).toContain("Looked at 2 areas");
expect(
render(<CoachTurnSteps steps={[]} active={false} areas={["bp"]} />, "de"),
).toContain("1 Bereich angesehen");
});
});
describe("a step row copied out of the list", () => {
it("reads 'domain · window' without the screen reader's comma", () => {
const html = render(
<CoachTurnStepList
steps={[{ ...BP, domain: "weight", count: undefined }]}
active={false}
/>,
"de",
);
// What a selection copies: every text node except the unselectable ones.
// Reads this test's own static markup; not a sanitiser for input.
const copied = html
.split(/<span[^>]*class="[^"]*\bselect-none\b[^"]*"[^>]*>[^<]*<\/span>/)
.join("")
.split(/<[^>]*>/)
.join("")
.replace(/\s+/g, " ")
.trim();
expect(copied).toBe("Gewicht · letzte 90 Tage");
// The comma is still there for a screen reader.
expect(html).toMatch(/class="sr-only select-none">, <\/span>/);
});
});
@@ -55,6 +55,13 @@ function streaming(
results: [],
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -0,0 +1,127 @@
"use client";
/**
* v1.41 — what an answer assumed instead of asking, in one quiet line under
* it: "Assumed: last 30 days. Change".
*
* Meta text, the value in the foreground. "Change" is a text button of the
* same size, offered only under the latest answer and only when the server
* sent alternatives for that assumption (`change_assumption` follow-ups).
* A tap shows them as reply pills right below; a pill sends its label as
* the person's message and the server answers from the alternative, without
* asking again. Nothing opens by itself.
*/
import { useId, useState } from "react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_ASSUMPTION_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachAssumption, CoachFollowUp } from "@/lib/ai/coach/types";
import { SuggestedReplies } from "./suggested-replies";
/** The alternatives the server offers for one assumption. */
export function alternativesFor(
assumption: CoachAssumption,
followUps: readonly CoachFollowUp[],
): CoachFollowUp[] {
return followUps.filter(
(chip) =>
chip.kind === "change_assumption" &&
chip.assumption?.kind === assumption.kind,
);
}
/** The catalog value in the reader's language, else the server's. */
function optionLabel(
t: (key: string) => string,
option: CoachAssumption["value"],
): string {
const local = t(option.labelKey);
return local === option.labelKey ? option.label : local;
}
export interface CoachAssumptionLineProps {
assumptions: CoachAssumption[];
/** The latest answer's `change_assumption` chips; empty elsewhere. */
changes: CoachFollowUp[];
messageId: string | null;
/** True while a turn is in flight. */
disabled: boolean;
onChange?: (followUp: CoachFollowUp, messageId: string) => void;
}
export function CoachAssumptionLine({
assumptions,
changes,
messageId,
disabled,
onChange,
}: CoachAssumptionLineProps) {
const { t } = useTranslations();
const [openKind, setOpenKind] = useState<string | null>(null);
const baseId = useId();
if (assumptions.length === 0) return null;
return (
<div
data-slot="coach-assumptions"
className="flex w-full min-w-0 flex-col gap-2"
>
{assumptions.slice(0, 2).map((assumption) => {
const alternatives =
onChange && messageId && !disabled
? alternativesFor(assumption, changes)
: [];
const open = openKind === assumption.kind && alternatives.length > 0;
const panelId = `${baseId}-${assumption.kind}`;
return (
<div key={assumption.kind} className="flex flex-col gap-2">
<p
data-slot="coach-assumption-line"
data-kind={assumption.kind}
className="text-muted-foreground max-w-full text-xs leading-relaxed"
>
{t(COACH_ASSUMPTION_KEYS.line, {
value: optionLabel(t, assumption.value),
})}
.{" "}
{alternatives.length > 0 ? (
<Button
type="button"
variant="link"
size="sm"
data-slot="coach-assumption-change"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
onClick={() => setOpenKind(open ? null : assumption.kind)}
// Inline in the meta line; the hit area reaches past the text (a
// 44 px target on phones) without making the line any taller.
className="text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5"
>
{t(COACH_ASSUMPTION_KEYS.change)}
</Button>
) : null}
</p>
{open && messageId && onChange ? (
<div id={panelId}>
{SuggestedReplies({
slot: "coach-assumption-alternatives",
messageId,
disabled,
max: 3,
replies: alternatives.map((chip) => ({
id: chip.id,
label: chip.label,
kind: "followUp",
data: { "data-follow-up-id": chip.id },
onSelect: () => onChange(chip, messageId),
})),
})}
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -19,12 +19,22 @@ import { ProseBlocks } from "@/components/insights/prose-blocks";
import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { CoachTurnSteps } from "./turn-steps";
import { CoachResults, countResultsInSection } from "./coach-results";
import { CoachTurnActivity } from "./turn-activity";
import { CoachInterimResults } from "./interim-results";
import {
CoachResults,
countResultsInSection,
liveWithProvenance,
} from "./coach-results";
import { CoachFollowUpChips } from "./follow-up-chips";
import { SuggestedReplies, type SuggestedReply } from "./suggested-replies";
import { CoachMemoryNote } from "./memory-note";
import { CoachAssumptionLine } from "./assumption-line";
import { AssistantMessageActions, UserMessageActions } from "./message-actions";
import type {
CoachActivity,
CoachFollowUp,
CoachMemoryNote as CoachMemoryNoteFrame,
CoachProvenanceMetric,
} from "@/lib/ai/coach/types";
@@ -316,6 +326,30 @@ interface ChatBubbleProps {
/** True while another turn is in flight; the chips step aside. */
followUpsDisabled?: boolean;
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
/**
* v1.41 — the reply pills the latest answer offers instead of its chips:
* a clarifying question's choices, or the two answers to a fact or plan
* proposal. Built by the thread; one array per offer.
*/
replies?: ReplyOffer | null;
/**
* v1.41 — the live trail from the streaming hook. Persisted messages carry
* 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;
}
/** v1.41 — the reply pills one assistant message offers. */
export interface ReplyOffer {
messageId: string;
replies: SuggestedReply[];
}
/** The chips one assistant message offers. */
@@ -380,6 +414,12 @@ export function areChatBubblePropsEqual(
sameFollowUpOffer(prev.followUps, next.followUps) &&
prev.followUpsDisabled === next.followUpsDisabled &&
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)
);
@@ -407,6 +447,12 @@ function ChatBubbleImpl({
followUps,
followUpsDisabled,
onFollowUp,
replies,
activity,
interimRefs,
startedAt,
endedAt,
memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
const { user } = useAuth();
@@ -543,7 +589,9 @@ function ChatBubbleImpl({
// grounded token is present (provider-agnostic — reads the inline token).
// v1.39.4 — the result tables this answer references and the ones it
// only used; the metadata is enough to place and count them.
const resultMetas = results?.length ? results : (metricSource?.results ?? []);
const resultMetas = results?.length
? liveWithProvenance(results, metricSource?.results ?? [])
: (metricSource?.results ?? []);
const shownDomains = new Set(
resultMetas
.filter((meta) => meta.displayed)
@@ -558,6 +606,7 @@ function ChatBubbleImpl({
? selectCoachChartTokens(content, metricSource?.metrics, shownDomains)
: [];
const settled = !inProgress && !errorCode && providerType !== "refusal";
const memoryMeta = memoryNote ?? metricSource?.memoryNote ?? null;
// v1.32.14 — quiet per-message notice: the grounding guard withheld ≥1 figure
// from this reply (each rewritten to the `[…]` elision mark). Shown only on a
@@ -596,15 +645,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)]"
>
{/* What the Coach read on this turn, live from the step frames and
restored from the provenance on reload; an older message lists
the areas it drew on. Opened, it also says how the answer was
worked out and holds the tables the answer read. */}
<CoachTurnSteps
{/* 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. */}
<CoachTurnActivity
activity={activity ?? metricSource?.activity ?? []}
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
startedAt={startedAt}
endedAt={endedAt}
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
conversationId={conversationId}
messageId={messageId}
dataUsed={
dataUsedCount > 0 ? (
<CoachResults
@@ -617,22 +673,22 @@ function ChatBubbleImpl({
) : null
}
/>
{/* v1.19.1 (C3) — the live turn shows the classic typing animation
(three pulsing dots) while it is still thinking with no prose
yet, restoring the writing/typing indicator the maintainer
prefers over the "Denke nach / Nachgedacht" reasoning disclosure.
The dots render inside the prose bubble below; the disclosure is
retired. Persisted bubbles never stream, so history stays settled. */}
{/* The prose bubble. While the live turn is still thinking with no
prose the bubble carries the typing animation; once tokens land
it swaps to the streamed prose without a layout jump. */}
{(content || safeError || inProgress) && (
{/* 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. */}
{(content || safeError) && (
<div
data-slot="coach-answer-bubble"
className={cn(
"border-border/60 bg-muted/40 text-foreground max-w-full",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed break-words whitespace-pre-wrap",
"motion-safe:animate-in motion-safe:fade-in-0 motion-safe:duration-300",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
@@ -644,8 +700,6 @@ function ChatBubbleImpl({
keeps the bubble and the error stays a caption. */}
{content ? (
<StreamedProse content={content} streaming={!!streaming} />
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning">{safeError}</span>
) : (
@@ -705,6 +759,23 @@ function ChatBubbleImpl({
section="displayed"
/>
)}
{/* v1.41 — what the answer assumed instead of asking, and what the
Coach kept from this turn: one quiet line each. */}
{settled && (metricSource?.assumptions?.length ?? 0) > 0 ? (
<CoachAssumptionLine
assumptions={metricSource?.assumptions ?? []}
changes={followUps?.followUps ?? []}
messageId={followUps?.messageId ?? null}
disabled={!!followUpsDisabled}
onChange={onFollowUp}
/>
) : null}
{settled && memoryMeta ? (
<CoachMemoryNote
note={memoryMeta}
liveFact={memoryNote?.fact ?? null}
/>
) : null}
{/* v1.18.6 — a "no provider configured anywhere" turn is a
setup gap, not a transient failure: surface a direct link to
Settings → AI so the Coach guides the user into BYOK / local
@@ -753,10 +824,20 @@ function ChatBubbleImpl({
model={usage?.model ?? model}
/>
)}
{/* The follow-up chips of the latest answer, in its own column. */}
{followUps && onFollowUp ? (
{/* The replies the latest answer offers, in its own column: a
question's choices or a proposal's two answers when it has them,
else its follow-up chips. One pattern for all of them. */}
{replies && replies.replies.length > 0 ? (
<SuggestedReplies
replies={replies.replies}
messageId={replies.messageId}
disabled={!!followUpsDisabled}
/>
) : followUps && onFollowUp ? (
<CoachFollowUpChips
followUps={followUps.followUps}
followUps={followUps.followUps.filter(
(chip) => chip.kind !== "change_assumption",
)}
messageId={followUps.messageId}
disabled={!!followUpsDisabled}
onSelect={onFollowUp}
@@ -1,106 +0,0 @@
"use client";
/**
* v1.39.4 — the choices for a clarifying question, above the composer.
* The question itself is the assistant reply in the thread, so the card
* does not repeat it: a short heading, the choices, and a meta line saying a
* typed answer works too. A tap on a choice sends its label with
* `clarification: { messageId, choiceId }`; a typed answer goes out with
* `clarification: { messageId }`.
*
* Announced politely when it appears, through the live region the
* conversation keeps mounted beside it (a region that arrives with its
* content is never heard), and never takes focus on its own: the person may
* already be typing. A tapped choice hands focus to the composer.
*/
import { MessageCircleQuestionMark } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type {
CoachClarification,
CoachClarificationChoice,
} from "@/lib/ai/coach/types";
import { focusCoachComposer } from "./composer-focus";
export interface CoachClarificationCardProps {
clarification: CoachClarification;
/** The assistant message that asked. */
messageId: string;
/** True while a turn is in flight. */
disabled: boolean;
onChoose: (choice: CoachClarificationChoice) => void;
}
export function CoachClarificationCard({
clarification,
messageId,
disabled,
onChoose,
}: CoachClarificationCardProps) {
const { t } = useTranslations();
// The catalog string in the reader's locale when the bundle has it, the
// server's rendering when it does not.
const choiceLabel = (choice: CoachClarificationChoice) => {
const local = t(choice.labelKey);
return local === choice.labelKey ? choice.label : local;
};
const { choices } = clarification;
const hint =
choices.length === 0
? t(COACH_CLARIFY_UI_KEYS.contextHint)
: clarification.freeText
? t(COACH_CLARIFY_UI_KEYS.freeTextHint)
: null;
return (
<Card
data-slot="coach-clarification-card"
data-message-id={messageId}
aria-label={t(COACH_CLARIFY_UI_KEYS.cardLabel)}
role="region"
className="gap-2 py-3 md:py-4"
>
<div className="flex items-center gap-2 px-4 md:px-6">
<MessageCircleQuestionMark
aria-hidden="true"
className="text-foreground size-4 shrink-0"
/>
<p className="text-sm font-medium">
{t(COACH_CLARIFY_UI_KEYS.cardLabel)}
</p>
</div>
{choices.length > 0 ? (
<div
role="group"
aria-label={t(COACH_CLARIFY_UI_KEYS.choicesLabel)}
className="flex flex-wrap gap-2 px-4 md:px-6"
>
{choices.map((choice) => (
<Button
key={choice.id}
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={disabled}
data-choice-id={choice.id}
onClick={() => {
onChoose(choice);
focusCoachComposer();
}}
>
{choiceLabel(choice)}
</Button>
))}
</div>
) : null}
{hint ? (
<p className="text-muted-foreground px-4 text-xs md:px-6">{hint}</p>
) : null}
</Card>
);
}
@@ -14,14 +14,9 @@ import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { COACH_CLARIFY_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import { randomId } from "@/lib/random-id";
import { apiDelete, apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
import type {
CoachClarificationChoice,
CoachFollowUp,
CoachScope,
} from "@/lib/ai/coach/types";
import type { CoachFollowUp, CoachScope } from "@/lib/ai/coach/types";
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
import type { CoachSeededQuestionDTO } from "@/app/api/insights/coach/seeded-question/route";
import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents";
@@ -51,9 +46,9 @@ import { HistoryRail } from "./history-rail";
import {
MessageThread,
latestClarification,
type CoachReplyIntent,
type InterleavedThreadItem,
} from "./message-thread";
import { CoachClarificationCard } from "./clarification-card";
import { CoachSettingsOverlay } from "./coach-settings-overlay";
import { CoachTopBarTrail } from "./coach-top-bar-trail";
import { ConversationsPanel } from "./conversations-panel";
@@ -662,18 +657,50 @@ export function CoachConversation({
});
}
// v1.39.4 — a choice on the clarification card.
function handleClarificationChoice(choice: CoachClarificationChoice) {
if (send.isStreaming || !openClarification) return;
if (currentConversationId === null) return;
void send.send({
// v1.41 — a reply pill under the latest answer: a clarifying question's
// choice, or an answer to a fact or plan proposal. Its label goes out as
// the person's own message; the request says which offer it answers, and
// the server resolves the rest from what it stored on that message.
function handleReply(intent: CoachReplyIntent) {
if (send.isStreaming || fenced || currentConversationId === null) return;
const sent = send.send({
conversationId: currentConversationId,
message: choice.label,
clarification: {
messageId: openClarification.messageId,
choiceId: choice.id,
},
message: intent.label,
...(intent.kind === "clarification"
? {
clarification: {
messageId: intent.messageId,
choiceId: intent.choice.id,
},
}
: intent.kind === "memory"
? {
memoryDecision: {
messageId: intent.messageId,
proposalId: intent.proposalId,
accept: intent.accept,
},
}
: {
planDecision: {
messageId: intent.messageId,
planId: intent.planId,
accept: intent.accept,
},
}),
});
// A decision changes what the Coach remembers or which plans run; the
// lists elsewhere read it fresh once the turn is through.
if (intent.kind !== "clarification") {
void sent.then(() =>
queryClient.invalidateQueries({
queryKey:
intent.kind === "memory"
? queryKeys.coachFacts()
: queryKeys.coachPlansAll(),
}),
);
}
}
async function handleSubmit(value: string) {
@@ -908,25 +935,6 @@ export function CoachConversation({
{t("insights.coach.attach.indexingHint")}
</p>
) : null}
{/* v1.39.4 — mounted empty before any question, so the question is
announced when it arrives (a live region that mounts with its
content is not heard). Visually hidden, so it takes no room in the
stack; the card below carries the choices. */}
<div
data-slot="coach-clarification-live"
aria-live="polite"
className="sr-only"
>
{openClarification ? t(COACH_CLARIFY_UI_KEYS.cardLabel) : ""}
</div>
{openClarification ? (
<CoachClarificationCard
clarification={openClarification.clarification}
messageId={openClarification.messageId}
disabled={send.isStreaming}
onChoose={handleClarificationChoice}
/>
) : null}
<CoachInput
value={inputValue}
onChange={setInputValue}
@@ -1221,6 +1229,7 @@ export function CoachConversation({
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
onReply={handleReply}
/>
</div>
<div
@@ -1329,6 +1338,7 @@ export function CoachConversation({
interleaved={interleaved}
onRegenerate={handleRegenerate}
onFollowUp={handleFollowUp}
onReply={handleReply}
/>
}
composer={composerStack}
@@ -372,7 +372,7 @@ export function CoachInput({
onKeyDown={handleKeyDown}
placeholder={placeholder ?? t("insights.coach.composerPlaceholder")}
// Read-only rather than disabled while a reply streams: a disabled
// field drops focus, and a follow-up chip or a clarification choice
// field drops focus, and a tapped reply pill
// hands focus here right as the turn starts.
readOnly={disabled}
aria-disabled={disabled || undefined}
@@ -8,7 +8,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { ReasoningEffortField } from "@/components/settings/ai/reasoning-effort-field";
import {
ANTHROPIC_MODEL_PRESETS,
LOCAL_MODEL_PRESETS,
@@ -16,7 +15,6 @@ import {
uiToLegacyProviderEnum,
type ProviderChainData,
type ProviderType,
type ReasoningEffort,
type UserAIProvider,
} from "@/components/settings/ai/shared";
import { apiGet, apiPatch, apiPut } from "@/lib/api/api-fetch";
@@ -29,7 +27,9 @@ import {
/**
* The quick model choice in the Coach's settings overlay: which provider
* answers, which model it uses, and how hard a reasoning model thinks.
* answers and which model it uses. How hard the Coach thinks is the Coach's
* own setting since v1.41 (`coach-reasoning-field.tsx`); the provider's
* reasoning select stays in Settings → AI for everything else.
*
* It is a shortcut onto the same settings Settings → AI writes, never a second
* copy of them. Keys, base URLs, the Codex sign-in and the order of the
@@ -38,7 +38,7 @@ import {
* - Provider: `PUT /api/insights/provider-chain` with the chosen entry moved
* to the front and every other entry in its current order. The entries'
* reasoning settings are not sent, so the server keeps what it stored.
* - Model and reasoning: `PATCH /api/user/ai-provider`, only where a person
* - Model: `PATCH /api/user/ai-provider`, only where a person
* owns the setting (their own key's provider, or their gateway). The
* operator's shared provider and Codex show their state read-only.
* Every write invalidates the AI input keys, so the capability on
@@ -127,29 +127,6 @@ export function modelControlFor(
return { kind: "managed" };
}
/** The reasoning setting the active provider carries, if any. */
export function reasoningFieldFor(
active: ProviderType | null,
userProvider: UserAIProvider | null | undefined,
): {
field: "localReasoningEffort" | "compatReasoningEffort";
value: ReasoningEffort | null;
} | null {
if (active === "local") {
return {
field: "localReasoningEffort",
value: userProvider?.localReasoningEffort ?? null,
};
}
if (active === "openai-compatible") {
return {
field: "compatReasoningEffort",
value: userProvider?.compatReasoningEffort ?? null,
};
}
return null;
}
export function CoachModelPicker() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -212,7 +189,6 @@ export function CoachModelPicker() {
const active = chain?.activeProvider ?? null;
const options = chain ? selectableProviders(chain.configuredChain) : [];
const model = modelControlFor(active, userProvider);
const reasoning = reasoningFieldFor(active, userProvider);
const busy = switchProvider.isPending || patchProvider.isPending;
// The server may answer with `admin-codex`, which the UI's type omits.
const unlisted = activeOutsideOptions(active as string | null, options);
@@ -339,16 +315,6 @@ export function CoachModelPicker() {
</p>
)}
{reasoning ? (
<ReasoningEffortField
id="coach-quick-reasoning"
noColon
value={reasoning.value}
disabled={busy}
onChange={(next) => patchProvider.mutate({ [reasoning.field]: next })}
/>
) : null}
{busy ? (
<p role="status" className="text-muted-foreground text-xs">
{t("insights.coach.frame.saving")}
@@ -0,0 +1,196 @@
"use client";
/**
* v1.41 — how hard the Coach thinks before it answers: one select, Off / Low
* / Medium / High, Medium by default.
*
* The person's own choice lives in `coachPrefsJson.reasoning`. What the
* server resolved from it rides `/api/auth/me` as `coachReasoning`: whether
* the operator allows reasoning at all, the highest level they allow, and
* whether "off" is really off on the active provider. The field renders from
* that and never recomputes it:
* - the operator switched reasoning off: the select is locked and one
* sentence says so;
* - a level above the operator's cap is offered but disabled, with
* "(limited by the admin)";
* - where the provider cannot switch thinking off, "Off" reads "Minimal".
* A server older than v1.41 sends no block; the field then reads as allowed,
* uncapped and really off.
*
* Two homes, one field: the Coach's quick settings (saves on change) and
* Settings → Coach (part of that card's draft and save).
*/
import { useQueryClient } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachReasoning } from "@/hooks/use-ai-capability";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { queryKeys } from "@/lib/query-keys";
import {
DEFAULT_REASONING_LEVEL,
REASONING_LEVELS,
REASONING_LEVEL_LABEL_KEYS,
REASONING_SETTING_KEYS,
isReasoningLevel,
type ReasoningLevel,
} from "@/lib/ai/reasoning/levels";
import type { CoachReasoningState } from "@/lib/ai/reasoning/resolve";
import { coachReasoningLevel } from "@/lib/validations/coach-prefs";
export type { CoachReasoningState };
/**
* The block as the field reads it: the server's answer, or for a server that
* does not publish one (older than v1.41) "allowed, uncapped, really off" at
* the default level.
*/
export function reasoningStateOrDefault(
state: CoachReasoningState | null | undefined,
): CoachReasoningState {
return (
state ?? {
level: DEFAULT_REASONING_LEVEL,
preference: DEFAULT_REASONING_LEVEL,
maxLevel: "high",
available: true,
offIsReal: true,
source: "user",
}
);
}
/** True when the operator switched reasoning off: the field is locked. */
export function reasoningLocked(state: CoachReasoningState): boolean {
return state.maxLevel === "off" || state.source === "admin_off";
}
export interface ReasoningOption {
value: ReasoningLevel;
label: string;
disabled: boolean;
}
/** The four options in order, labelled and capped for this account. */
export function reasoningOptions(
state: CoachReasoningState,
t: (key: string) => string,
): ReasoningOption[] {
const locked = reasoningLocked(state);
const cap = REASONING_LEVELS.indexOf(state.maxLevel);
return REASONING_LEVELS.map((value, index) => {
const base = t(
value === "off" && !state.offIsReal
? REASONING_LEVEL_LABEL_KEYS.minimal
: REASONING_LEVEL_LABEL_KEYS[value],
);
// Above the cap is "(limited by the admin)"; while reasoning is off the
// one sentence under the field says it, not every option.
const capped = !locked && index > cap;
return {
value,
label: capped ? `${base} ${t(REASONING_SETTING_KEYS.capped)}` : base,
disabled: capped,
};
});
}
/**
* The level the select shows: the person's choice, lowered to the cap when
* it is above it (that is what runs), and the resolved level while the
* operator has reasoning off.
*/
export function shownReasoningLevel(
choice: ReasoningLevel,
state: CoachReasoningState,
): ReasoningLevel {
if (reasoningLocked(state)) return state.level;
return REASONING_LEVELS.indexOf(choice) >
REASONING_LEVELS.indexOf(state.maxLevel)
? state.maxLevel
: choice;
}
export interface CoachReasoningSelectProps {
id: string;
value: ReasoningLevel;
state: CoachReasoningState;
/** Locks the select while a write is in flight. */
busy?: boolean;
onChange: (next: ReasoningLevel) => void;
}
/** The field itself, without data: what the tests render. */
export function CoachReasoningSelect({
id,
value,
state,
busy = false,
onChange,
}: CoachReasoningSelectProps) {
const { t } = useTranslations();
const options = reasoningOptions(state, t);
const locked = reasoningLocked(state);
return (
<div data-slot="coach-reasoning-field">
<Label htmlFor={id} noColon>
{t(REASONING_SETTING_KEYS.label)}
</Label>
<NativeSelect
id={id}
data-slot="coach-reasoning-select"
data-locked={locked ? "true" : undefined}
className="mt-1"
value={shownReasoningLevel(value, state)}
disabled={locked || busy}
aria-describedby={`${id}-hint`}
onChange={(e) => {
const next = e.target.value;
if (isReasoningLevel(next) && next !== value) onChange(next);
}}
>
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</NativeSelect>
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
{t(
locked
? REASONING_SETTING_KEYS.disabled
: REASONING_SETTING_KEYS.hint,
)}
</p>
</div>
);
}
/** The quick-settings field: reads the saved choice and saves on change. */
export function CoachReasoningField({ id }: { id: string }) {
const queryClient = useQueryClient();
const reasoning = useCoachReasoning();
const prefs = useCoachPrefs();
const save = useSaveCoachPrefs({
// The resolved block on `/me` follows the saved choice.
onSuccess: () =>
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() }),
});
if (!prefs.data) return null;
const current = prefs.data;
const state = reasoningStateOrDefault(reasoning);
return (
<CoachReasoningSelect
id={id}
value={coachReasoningLevel(current)}
state={state}
busy={save.isPending}
onChange={(reasoning) => save.mutate({ ...current, reasoning })}
/>
);
}
@@ -115,6 +115,32 @@ function useReusedLine(result: CoachResultMeta): string | null {
: t(COACH_RESULT_UI_KEYS.reusedFromUndated);
}
/** `displayed` per ref, from a message's result metadata. */
function displayedByRef(
metas: ReadonlyArray<Pick<CoachResultMeta, "ref" | "displayed">>,
): Map<string, boolean> {
return new Map(metas.map((meta) => [meta.ref, meta.displayed]));
}
/**
* The live tables with `displayed` taken from the provenance where it names
* them: an interim table is sent before the answer decides.
*/
export function liveWithProvenance<
T extends Pick<CoachResultMeta, "ref" | "displayed">,
>(
live: readonly T[],
metas: ReadonlyArray<Pick<CoachResultMeta, "ref" | "displayed">>,
): T[] {
const displayed = displayedByRef(metas);
return live.map((table) => {
const flag = displayed.get(table.ref);
return flag === undefined || flag === table.displayed
? table
: { ...table, displayed: flag };
});
}
/** How many tables sit in a section, from the metadata alone. */
export function countResultsInSection(
metas: ReadonlyArray<Pick<CoachResultMeta, "displayed">>,
@@ -147,8 +173,11 @@ function sectionItems(args: {
const inSection = (displayed: boolean) =>
args.section === "displayed" ? displayed : !displayed;
if (args.live && args.live.length > 0) {
// v1.41 — a table that arrived while the turn still ran learns whether
// the answer points at it from the provenance, which comes later.
const displayed = displayedByRef(args.metas);
return args.live
.filter((table) => inSection(table.displayed))
.filter((table) => inSection(displayed.get(table.ref) ?? table.displayed))
.map((table) => ({ ref: table.ref, table }));
}
if (!args.fetched) return [];
@@ -174,7 +203,7 @@ export function CoachResults({
const { t } = useTranslations();
const hasLive = (live?.length ?? 0) > 0;
const expected = hasLive
? countResultsInSection(live ?? [], section)
? countResultsInSection(liveWithProvenance(live ?? [], metas), section)
: countResultsInSection(metas, section);
const { ref, results, isError, refetch } = useCoachMessageResults({
conversationId,
@@ -236,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
function CoachResultView({
export function CoachResultView({
result,
chartFirst,
}: {
@@ -2,27 +2,63 @@
import { useEffect, useRef } from "react";
import Link from "next/link";
import { Cpu } from "lucide-react";
import { Brain, ChevronRight, Cpu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_MEMORY_LIST_KEYS } from "@/lib/ai/coach/memory/shared";
import { CoachModelPicker } from "./coach-model-picker";
import { CoachReasoningField } from "./coach-reasoning-field";
import { useCoachFacts } from "./use-coach-facts";
import { SourcesRail } from "./sources-rail";
/**
* The content of the Coach's quick settings (see `coach-settings-overlay`):
* the model choice, "What I can see" (the existing sources rail, wired to the
* saved Coach preferences: data areas and how far back the Coach looks) and
* a way on to the full AI settings.
* the model choice and how hard the Coach thinks, a way to what it remembers,
* "What I can see" (the existing sources rail, wired to the saved Coach
* preferences: data areas and how far back the Coach looks) and a way on to
* the full AI settings.
*
* `focusData` lands focus on the "What I can see" section when the body
* mounts, for the `/coach?settings=data` deep link. It runs here rather than
* in the overlay because the body loads on demand and may arrive after the
* popover or sheet has already placed focus.
* popover or sheet has already placed focus. The section draws no focus
* ring: it is where the link lands, not a control, and a ring drawn on every
* deep link reads as a highlight nobody asked for.
*/
export const COACH_SETTINGS_DATA_SECTION_ID = "coach-settings-data";
/** Where the memory list lives: Settings → Coach, its memory card. */
export const COACH_MEMORY_HREF = "/settings/coach#coach-memory";
/** "Memory (8)": a link to the list, with how much it holds. */
function CoachMemoryLink() {
const { t } = useTranslations();
const facts = useCoachFacts();
const count = facts.data?.length ?? 0;
return (
<Link
href={COACH_MEMORY_HREF}
data-slot="coach-settings-memory-link"
className="text-foreground hover:bg-muted focus-visible:ring-ring/50 -mx-2 flex min-h-11 items-center gap-2 rounded-md px-2 text-sm outline-none focus-visible:ring-2 sm:min-h-9"
>
<Brain
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
{t(COACH_MEMORY_LIST_KEYS.link, { count })}
</span>
<ChevronRight
className="text-muted-foreground size-4 shrink-0"
aria-hidden="true"
/>
</Link>
);
}
export function CoachSettingsBody({
focusData = false,
}: {
@@ -53,6 +89,8 @@ export function CoachSettingsBody({
{t("insights.coach.frame.modelSection")}
</h3>
<CoachModelPicker />
<CoachReasoningField id="coach-quick-reasoning" />
<CoachMemoryLink />
</section>
<section
ref={dataRef}
@@ -60,7 +98,7 @@ export function CoachSettingsBody({
data-slot="coach-settings-data"
tabIndex={-1}
aria-label={t("insights.coach.sourcesTitle")}
className="border-border focus-visible:ring-ring/50 border-t pt-4 outline-none focus-visible:ring-2"
className="border-border border-t pt-4 outline-none"
>
<SourcesRail className="h-auto p-0" />
</section>
@@ -1,9 +1,9 @@
/**
* v1.39.4 — the Coach composer's id, and handing focus to it.
*
* A follow-up chip or a clarification choice sends a message and then goes
* away (chips hide while the turn runs, choices disable), which would drop
* keyboard and screen-reader focus to the page. Focus moves to the composer
* A reply pill (a follow-up, a clarifying choice, a memory or plan
* decision) sends a message and then goes away (pills hide while the turn
* runs), which would drop keyboard and screen-reader focus to the page. Focus moves to the composer
* instead: that is where the person continues. The composer stays focusable
* while a reply streams (it is read-only then, not disabled).
*/
@@ -2,24 +2,21 @@
/**
* v1.39.4 — up to three follow-up chips under the latest assistant reply.
* They sit inside the answer's own column, on the bubble's left edge and at
* the answer's text size, so they read as part of the reply rather than as
* thread chrome. A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
* v1.41 — a thin adapter onto `SuggestedReplies`, the one pattern every
* tappable reply in the thread uses (follow-ups, clarifying choices, memory
* and plan decisions).
*
* A tap sends the chip's label as the message with
* `followUp: { messageId, id }`; the server resolves what the chip asks for.
* The label is the server's rendering in the request locale. It is not
* re-rendered from `labelKey` here: a related-metric chip names its metric
* in the label, and the key alone does not carry it. Hidden while a turn is
* in flight, so a chip can never be tapped against a reply that is about to
* stop being the latest. The tapped chip goes away with the rest, so focus
* moves to the composer.
* in the label, and the key alone does not carry it.
*/
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import type { CoachFollowUp } from "@/lib/ai/coach/types";
import { focusCoachComposer } from "./composer-focus";
import { SuggestedReplies } from "./suggested-replies";
/** At most this many chips render, whatever the payload carries. */
const MAX_CHIPS = 3;
@@ -40,36 +37,21 @@ export function CoachFollowUpChips({
onSelect,
}: CoachFollowUpChipsProps) {
const { t } = useTranslations();
const chips = followUps.slice(0, MAX_CHIPS);
if (disabled || chips.length === 0) return null;
return (
<div
role="group"
aria-label={t(COACH_FOLLOW_UP_UI_KEYS.groupLabel)}
data-slot="coach-follow-up-chips"
data-message-id={messageId}
className="flex max-w-full flex-wrap gap-2"
>
{chips.map((chip) => (
<Button
key={chip.id}
type="button"
variant="outline"
size="sm"
// The answer's own scale (text-sm, regular weight); the 44 px tap
// floor below `sm`, 36 px beside a pointer. A long label wraps
// inside the pill instead of pushing past the column at 390 px.
className="h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal sm:min-h-9"
data-follow-up-id={chip.id}
data-follow-up-kind={chip.kind}
onClick={() => {
onSelect(chip, messageId);
focusCoachComposer();
}}
>
{chip.label}
</Button>
))}
</div>
);
return SuggestedReplies({
slot: "coach-follow-up-chips",
label: t(COACH_FOLLOW_UP_UI_KEYS.groupLabel),
messageId,
disabled,
max: MAX_CHIPS,
replies: followUps.map((chip) => ({
id: chip.id,
label: chip.label,
kind: "followUp",
data: {
"data-follow-up-id": chip.id,
"data-follow-up-kind": chip.kind,
},
onSelect: () => onSelect(chip, messageId),
})),
});
}
@@ -0,0 +1,191 @@
"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-ring/50 -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>
);
}
@@ -0,0 +1,96 @@
"use client";
/**
* v1.41 — the one quiet line under an answer that says the Coach kept
* something: "Remembered: you want to reach 75 kg by December. Undo".
*
* Meta text at the meta size, with "Undo" as a text button of the same size.
* Undo forgets the fact (a soft delete) and the line goes. A fact the Coach
* only proposes (a health fact, which is never kept without a tap) has no
* line: the answer ends with the question and the reply pills carry
* "Yes, remember it" / "No".
*
* The live turn has the fact's words from the `memoryNote` frame. A
* persisted message carries only the fact's id, so its words come from the
* memory list; a fact that has since been forgotten or edited away leaves
* no line behind.
*/
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_MEMORY_KEYS } from "@/lib/ai/coach/memory/shared";
import type { CoachMemoryNoteMeta } from "@/lib/ai/coach/types";
import { useCoachFacts, useForgetCoachFact } from "./use-coach-facts";
export interface CoachMemoryNoteLineProps {
fact: string;
pending: boolean;
onUndo: () => void;
}
/** The line itself, without data: what the tests render. */
export function CoachMemoryNoteLine({
fact,
pending,
onUndo,
}: CoachMemoryNoteLineProps) {
const { t } = useTranslations();
return (
<p
data-slot="coach-memory-note"
className="text-muted-foreground max-w-full text-xs leading-relaxed break-words"
>
{t(COACH_MEMORY_KEYS.saved, { fact: fact.replace(/[.\s]+$/, "") })}.{" "}
<Button
type="button"
variant="link"
size="sm"
data-slot="coach-memory-note-undo"
disabled={pending}
aria-busy={pending || undefined}
onClick={onUndo}
// Inline in the meta line; the hit area reaches past the text (a
// 44 px target on phones) without making the line any taller.
className="text-foreground relative h-auto px-1 py-0 text-xs font-normal underline underline-offset-4 after:absolute after:-inset-x-1 after:-inset-y-3.5 after:content-[''] sm:after:-inset-y-1.5"
>
{t(COACH_MEMORY_KEYS.undo)}
</Button>
</p>
);
}
export function CoachMemoryNote({
note,
liveFact,
}: {
/** `metricSource.memoryNote`, or the live frame's metadata. */
note: CoachMemoryNoteMeta;
/** The fact's words from the live frame, when the turn just streamed. */
liveFact?: string | null;
}) {
const { t } = useTranslations();
const [undone, setUndone] = useState(false);
const saved = !note.proposal && !!note.factId;
// The list is read only for a persisted note without its words.
const facts = useCoachFacts({ enabled: saved && !liveFact && !undone });
const forget = useForgetCoachFact({
onSuccess: () => setUndone(true),
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
});
if (!saved || undone || !note.factId) return null;
const fact =
liveFact ?? facts.data?.find((f) => f.id === note.factId)?.text ?? null;
if (!fact) return null;
return (
<CoachMemoryNoteLine
fact={fact}
pending={forget.isPending}
onUndo={() => {
if (note.factId && !forget.isPending) forget.mutate(note.factId);
}}
/>
);
}
@@ -41,7 +41,10 @@ import type { Formatters } from "@/lib/format-locale";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { ABOUT_ME_FIELD_MAX_CHARS } from "@/lib/validations/about-me";
import {
COACH_MEMORY_KEYS,
REMEMBER_FACT_MAX_CHARS,
} from "@/lib/ai/coach/memory/shared";
import {
COACH_ICON_BUTTON,
@@ -271,37 +274,38 @@ function MessageTime({ iso }: { iso: string }) {
}
/**
* Stores the user's message in the self-context (Settings → AI) on one tap,
* so it rides every future system prompt. The server picks the field from
* the text itself. The icon confirms in place and a toast says where it
* went; the button never unmounts, so keyboard focus stays on it.
* Keeps the user's message in the Coach's memory on one tap
* (`POST /api/insights/coach/facts` with the message id; the server reads the
* text and files it). It then shows in Settings → Coach with "from you", and
* reaches every later turn. v1.41 — it used to write the self-context; the
* Coach's memory is now the one place for what the Coach knows. The icon
* confirms in place and a toast says so; the button never unmounts, so
* keyboard focus stays on it.
*/
function RememberMessageButton({ content }: { content: string }) {
function RememberMessageButton({ messageId }: { messageId: string }) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [settled, setSettled] = useState<"adopted" | "duplicate" | null>(null);
const remember = useMutation({
mutationFn: async () =>
apiPost<{ adopted: boolean }>("/api/coach/about-me/adopt", {
answer: content,
apiPost<{ created: boolean }>("/api/insights/coach/facts", {
messageId,
}),
onSuccess: (data) => {
if (data.adopted) {
void queryClient.invalidateQueries({
queryKey: queryKeys.coachAboutMe(),
});
}
setSettled(data.adopted ? "adopted" : "duplicate");
void queryClient.invalidateQueries({
queryKey: queryKeys.coachFacts(),
});
setSettled(data.created ? "adopted" : "duplicate");
// A new entry is a write (success); an existing one is an honest
// non-event (neutral), the same mapping `toastWrittenOutcome` uses.
// Called directly: importing the outcome module here regroups the
// shared chunks and costs ~16 KB gz across the client bundle.
(data.adopted ? toast.success : toast.info)(
(data.created ? toast.success : toast.info)(
t(
data.adopted
? "insights.coach.rememberMessage.done"
: "insights.coach.rememberMessage.duplicate",
data.created
? COACH_MEMORY_KEYS.confirmed
: "insights.coach.rememberMessage.alreadyKept",
),
);
},
@@ -400,8 +404,8 @@ export function UserMessageActions({
messageId,
createdAt,
}: UserMessageActionsProps) {
// Only a persisted message that fits the self-context field can be kept.
const canRemember = !!messageId && content.length <= ABOUT_ME_FIELD_MAX_CHARS;
// Only a persisted message short enough to be one fact can be kept.
const canRemember = !!messageId && content.length <= REMEMBER_FACT_MAX_CHARS;
return (
<div
data-slot="coach-user-actions"
@@ -412,7 +416,9 @@ export function UserMessageActions({
)}
>
<CopyMessageButton content={content} strip={false} />
{canRemember && <RememberMessageButton content={content} />}
{canRemember && messageId && (
<RememberMessageButton messageId={messageId} />
)}
{createdAt && <MessageTime iso={createdAt} />}
</div>
);
@@ -14,8 +14,8 @@ import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { PlanProposalCards } from "./plan-proposal-card";
import { ChatBubble, type FollowUpOffer } from "./chat-bubble";
import { ChatBubble, type FollowUpOffer, type ReplyOffer } from "./chat-bubble";
import type { SuggestedReply } from "./suggested-replies";
import { CoachMessageDatesProvider } from "./coach-results";
import { ScrollToBottomButton } from "./scroll-to-bottom-button";
import { focusCoachComposer } from "./composer-focus";
@@ -32,9 +32,14 @@ import type {
} from "./use-coach";
import type {
CoachClarification,
CoachClarificationChoice,
CoachFollowUp,
CoachMessageDTO,
} from "@/lib/ai/coach/types";
import {
COACH_MEMORY_KEYS,
COACH_PLAN_KEYS,
} from "@/lib/ai/coach/memory/shared";
// v1.28.26 file-size split (pure code motion): the bubble renderer +
// per-message actions live in `chat-bubble.tsx`, the read-aloud stack in
@@ -99,6 +104,159 @@ export interface MessageThreadProps {
* v1.39.4 — a follow-up chip was tapped. Omitted → no chips are shown.
*/
onFollowUp?: (followUp: CoachFollowUp, messageId: string) => void;
/**
* v1.41 — a reply pill was tapped: a clarifying question's choice, or an
* answer to a fact or plan proposal. Omitted → those pills are not shown.
*/
onReply?: (intent: CoachReplyIntent) => void;
}
/** v1.41 — what a tapped reply pill answers, and how. */
export type CoachReplyIntent =
| {
kind: "clarification";
messageId: string;
choice: CoachClarificationChoice;
label: string;
}
| {
kind: "memory";
messageId: string;
proposalId: string;
accept: boolean;
label: string;
}
| {
kind: "plan";
messageId: string;
planId: string;
accept: boolean;
label: string;
};
/**
* v1.41 — the decision the latest answer waits for, if any: a clarifying
* question's choices, a fact proposal or a plan proposal. Like the chips, only
* the latest assistant turn offers one, and none while a turn is in flight.
*/
export interface LatestDecision {
messageId: string;
clarification: CoachClarification | null;
memoryProposalId: string | null;
planId: string | null;
}
export function latestDecision(
messages: CoachMessageDTO[],
streaming: CoachStreamingMessage | undefined,
): LatestDecision | null {
if (streaming?.inProgress) return null;
let found: LatestDecision | null = null;
if (streaming?.messageId) {
const persisted = messages.find((m) => m.id === streaming.messageId);
const note = streaming.memoryNote ?? persisted?.metricSource?.memoryNote;
const plan =
streaming.planProposal ?? persisted?.metricSource?.planProposal;
found = {
messageId: streaming.messageId,
clarification:
streaming.clarification ??
persisted?.metricSource?.clarification ??
null,
memoryProposalId:
note?.proposal && note.proposalId ? note.proposalId : null,
planId: plan?.planId ?? null,
};
const last = messages[messages.length - 1];
// The streamed turn is the latest only until something follows it.
if (persisted && last && last.id !== streaming.messageId) found = null;
} else {
const last = messages[messages.length - 1];
if (last?.role === "assistant") {
const note = last.metricSource?.memoryNote;
found = {
messageId: last.id,
clarification: last.metricSource?.clarification ?? null,
memoryProposalId:
note?.proposal && note.proposalId ? note.proposalId : null,
planId: last.metricSource?.planProposal?.planId ?? null,
};
}
}
if (
!found ||
(!found.clarification && !found.memoryProposalId && !found.planId)
) {
return null;
}
return found;
}
/**
* The pills of a decision, in the order they are offered: a question's
* choices with the assumed one first, then a fact proposal's two answers,
* then a plan proposal's. Labels in the reader's language.
*/
export function decisionReplies(
decision: LatestDecision,
t: (key: string) => string,
onReply: (intent: CoachReplyIntent) => void,
): SuggestedReply[] {
const { messageId } = decision;
const out: SuggestedReply[] = [];
const choices = decision.clarification?.choices ?? [];
const assumed = decision.clarification?.assumption;
const ordered = assumed
? [
...choices.filter((c) => c.id === assumed),
...choices.filter((c) => c.id !== assumed),
]
: choices;
for (const choice of ordered.slice(0, 4)) {
const local = t(choice.labelKey);
const label = local === choice.labelKey ? choice.label : local;
out.push({
id: `clarify-${choice.id}`,
label,
kind: "clarification",
data: { "data-choice-id": choice.id },
onSelect: () =>
onReply({ kind: "clarification", messageId, choice, label }),
});
}
const proposalId = decision.memoryProposalId;
if (proposalId) {
for (const accept of [true, false]) {
const label = t(
accept ? COACH_MEMORY_KEYS.accept : COACH_MEMORY_KEYS.decline,
);
out.push({
id: `memory-${accept ? "accept" : "decline"}`,
label,
kind: "memory",
data: { "data-decision": accept ? "accept" : "decline" },
onSelect: () =>
onReply({ kind: "memory", messageId, proposalId, accept, label }),
});
}
}
const planId = decision.planId;
if (planId) {
for (const accept of [true, false]) {
const label = t(
accept ? COACH_PLAN_KEYS.accept : COACH_PLAN_KEYS.decline,
);
out.push({
id: `plan-${accept ? "accept" : "decline"}`,
label,
kind: "plan",
data: { "data-decision": accept ? "accept" : "decline" },
onSelect: () =>
onReply({ kind: "plan", messageId, planId, accept, label }),
});
}
}
return out;
}
/**
@@ -275,6 +433,7 @@ export function MessageThread({
interleaved,
onRegenerate,
onFollowUp,
onReply,
}: MessageThreadProps) {
const { t } = useTranslations();
const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -354,14 +513,32 @@ export function MessageThread({
: null,
[chipsMessageId, chipsArray],
);
const chipsFor = (messageId: string | null | undefined) =>
onFollowUp && chips && messageId === chips.messageId
// v1.41 — a decision the latest answer waits for replaces its chips with
// the decision's own pills. There is none while a turn streams, so the
// fresh array per render never re-renders a bubble per token.
const decision = onReply ? latestDecision(messages, streaming) : null;
const replyOffer: ReplyOffer | null =
decision && onReply
? {
messageId: decision.messageId,
replies: decisionReplies(decision, t, onReply),
}
: null;
const chipsFor = (messageId: string | null | undefined) => ({
...(onFollowUp && chips && messageId === chips.messageId
? {
followUps: chips,
followUpsDisabled: !!streaming?.inProgress,
onFollowUp,
}
: {};
: {}),
...(replyOffer && messageId === replyOffer.messageId
? {
replies: replyOffer,
followUpsDisabled: !!streaming?.inProgress,
}
: {}),
});
const hasThread =
messages.length > 0 ||
@@ -507,7 +684,13 @@ export function MessageThread({
// assistant reply (the nearest preceding user turn) so the surface
// can resubmit it. Null when there is none → no regenerate action.
let precedingUserContent: string | null = null;
if (m.role === "assistant" && onRegenerate) {
// v1.41 — a reply to a memory or plan tap is answered without a
// model (`decision`); there is nothing to try again.
if (
m.role === "assistant" &&
onRegenerate &&
m.providerType !== "decision"
) {
for (let j = idx - 1; j >= 0; j--) {
if (messages[j].role === "user") {
precedingUserContent = messages[j].content;
@@ -532,6 +715,19 @@ export function MessageThread({
streaming.results.length > 0
? { 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.
{...(m.id === streaming?.messageId
? {
...(streaming.activity.length > 0
? { activity: streaming.activity }
: {}),
startedAt: streaming.startedAt,
endedAt: streaming.endedAt,
memoryNote: streaming.memoryNote,
}
: {})}
{...(m.role === "assistant" ? chipsFor(m.id) : {})}
onRegenerate={
precedingUserContent !== null && onRegenerate
@@ -585,6 +781,11 @@ export function MessageThread({
suggestedAction={streaming.suggestedAction}
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}
errorCode={streaming.errorCode}
@@ -602,14 +803,6 @@ export function MessageThread({
{placement.tail.map((i) => (
<Fragment key={i.key}>{i.node}</Fragment>
))}
{/* Plan-proposal confirm cards for THIS conversation. The extractor
runs after the turn (memory-refresh worker), so the block appears at
the thread tail once the proposal lands rather than under a specific
bubble; the component itself slow-polls and renders nothing while
the conversation has no open proposal. */}
{conversation ? (
<PlanProposalCards conversationId={conversation.id} />
) : null}
</div>
<ScrollToBottomButton visible={!pinned} onClick={scrollToLatest} />
</div>
@@ -1,218 +0,0 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Check, Loader2, Target, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
useCoachPlans,
useCoachPlanMutations,
type CoachPlanDTO,
} from "@/hooks/use-coach-plans";
/**
* v1.27.x — the confirm cards for Coach plan proposals, rendered at the tail
* of the open conversation's thread.
*
* The extractor (memory-refresh worker, runs AFTER the turn) writes a plan as
* `status: "proposed"`; nothing becomes a standing plan until the user
* confirms it here (or on `/coach/plans`). Because the proposal lands
* asynchronously, the block slow-polls the proposed list while the thread is
* mounted (TanStack pauses the interval in background tabs) and only shows
* proposals whose `sourceConversationId` is the OPEN conversation — a
* proposal born in an older thread stays on the management page instead of
* popping into an unrelated chat.
*
* Accept → PATCH `{status:"active"}` (the plan enters the Coach's snapshot
* memory block from the next turn on). Decline → DELETE (soft-delete; the
* proposal never activates and stops counting against the per-user cap).
* A settled card collapses to a one-line confirmation for the session so the
* refetch dropping the row from the proposed list cannot double-prompt.
*/
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
export const PROPOSAL_POLL_MS = 20_000;
/**
* Attempt ceiling for the proposal poll. The extractor runs right after the
* turn and lands its proposal within a couple of minutes; 15 polls (~5 min
* of coverage) catch it with margin, and every settled response counts an
* attempt. Without the cap an open thread polled the proposed list every
* 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval`
* (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and
* the next mount of a fresh conversation restarts the budget.
*/
export const PROPOSAL_POLL_MAX_ATTEMPTS = 15;
/**
* Decide whether the proposal query schedules its next poll. Pure so the
* ceiling is unit-testable: returns the slow interval until the attempt cap
* is reached, then `false` (poll off).
*/
export function nextProposalPollInterval(
dataUpdateCount: number,
): number | false {
if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false;
return PROPOSAL_POLL_MS;
}
type SettledOutcome = "accepted" | "declined";
export function PlanProposalCards({
conversationId,
}: {
conversationId: string;
}) {
const { t } = useTranslations();
const [settled, setSettled] = useState<
Array<{ id: string; outcome: SettledOutcome }>
>([]);
const query = useCoachPlans({
filter: { status: "proposed" },
refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount),
});
// Quiet on error by design: the thread is the Coach's surface, and a failed
// enhancement read must never interrupt the conversation with an error card.
const proposals = (query.data ?? []).filter(
(p) =>
p.sourceConversationId === conversationId &&
p.ifCue !== null &&
p.thenAction !== null &&
!settled.some((s) => s.id === p.id),
);
if (proposals.length === 0 && settled.length === 0) return null;
return (
<div data-slot="coach-plan-proposals" className="flex flex-col gap-2.5">
{proposals.map((plan) => (
<PlanProposalCard
key={plan.id}
plan={plan}
onSettled={(outcome) =>
setSettled((prev) => [...prev, { id: plan.id, outcome }])
}
/>
))}
{settled.map((s) => (
<p
key={s.id}
role="status"
data-slot="coach-plan-proposal-done"
className="text-muted-foreground flex items-center gap-1.5 text-xs"
>
<Check className="text-success size-3.5" aria-hidden="true" />
{t(
s.outcome === "accepted"
? "coach.plans.accepted"
: "coach.plans.declined",
)}
</p>
))}
</div>
);
}
/** One proposal with confirm / decline actions. */
function PlanProposalCard({
plan,
onSettled,
}: {
plan: CoachPlanDTO;
onSettled: (outcome: SettledOutcome) => void;
}) {
const { t } = useTranslations();
const { setStatus, remove } = useCoachPlanMutations();
const busy = setStatus.isPending || remove.isPending;
return (
<div
data-slot="coach-plan-proposal-card"
className={cn(
"border-border/60 bg-muted/40 flex flex-col gap-2.5 rounded-xl border",
"px-3.5 py-3 text-sm",
)}
>
<div className="flex items-start gap-2">
<div
aria-hidden="true"
className="bg-primary/10 mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full"
>
<Target className="text-primary size-3.5" />
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-muted-foreground text-xs font-medium">
{t("coach.plans.proposalTitle")}
</span>
{/* The plan prose is the user's own committed intention — content,
never meta, so it reads in foreground per the text-colour rules. */}
<span className="text-foreground leading-relaxed break-words">
{t("coach.plans.ifThen", {
cue: plan.ifCue ?? "",
action: plan.thenAction ?? "",
})}
</span>
{plan.target ? (
<span className="text-foreground text-xs break-words">
{t("coach.plans.targetPrefix", { target: plan.target })}
</span>
) : null}
<span className="text-muted-foreground text-xs uppercase">
{plan.metric}
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-slot="coach-plan-proposal-accept"
onClick={() =>
setStatus.mutate(
{ id: plan.id, status: "active" },
{
onSuccess: () => onSettled("accepted"),
onError: () => toast.error(t("coach.plans.failed")),
},
)
}
disabled={busy}
className={cn(
"bg-primary/90 text-primary-foreground hover:bg-primary",
"focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5",
"rounded-md px-3 py-1.5 text-xs font-medium outline-none",
"focus-visible:ring-2 disabled:opacity-50",
)}
>
{busy && setStatus.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<Check className="size-3.5" aria-hidden="true" />
)}
{t("coach.plans.accept")}
</button>
<button
type="button"
data-slot="coach-plan-proposal-decline"
onClick={() =>
remove.mutate(plan.id, {
onSuccess: () => onSettled("declined"),
onError: () => toast.error(t("coach.plans.failed")),
})
}
disabled={busy}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex min-h-9 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs outline-none focus-visible:ring-2 disabled:opacity-50"
>
<X className="size-3.5" aria-hidden="true" />
{t("coach.plans.decline")}
</button>
</div>
</div>
);
}
@@ -37,7 +37,7 @@ import type {
CoachResultTable,
} from "@/lib/ai/coach/types";
import { makeBucketLabelFormatters } from "@/lib/charts/bucket-label";
import { niceAxis } from "@/lib/charts/nice-axis";
import { niceAxis, type NiceAxis } from "@/lib/charts/nice-axis";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
@@ -122,9 +122,9 @@ export function CoachResultChart({
if (spec.kind === "histogram") {
return <HistogramChart result={result} spec={spec} label={label} />;
}
// v1.41 — the contract is in place; no table carries this kind yet, and the
// table view stays available until the chart lands.
if (spec.kind === "compare") return null;
if (spec.kind === "compare") {
return <CompareChart result={result} spec={spec} label={label} />;
}
if (spec.kind === "bar" && spec.orientation === "horizontal") {
return (
<CategoryBarChart
@@ -354,14 +354,21 @@ function SeriesLegend({
series,
hidden,
onToggle,
label,
dashed,
}: {
series: Array<{ key: string; label: string }>;
hidden: ReadonlySet<string>;
onToggle: (key: string) => void;
/** The legend's accessible name, when it says more than its buttons. */
label?: string;
/** Series drawn dashed; their swatch is a ring rather than a dot. */
dashed?: ReadonlySet<string>;
}) {
return (
<div
data-slot="coach-result-chart-legend"
{...(label ? { role: "group", "aria-label": label } : {})}
className="flex flex-wrap items-center gap-1.5"
>
{series.map(({ key, label }, index) => {
@@ -385,9 +392,16 @@ function SeriesLegend({
"inline-block size-2 rounded-full",
!shown && "opacity-40",
)}
style={{
backgroundColor: SERIES_COLORS[index % SERIES_COLORS.length],
}}
style={
dashed?.has(key)
? {
border: `1.5px solid ${SERIES_COLORS[index % SERIES_COLORS.length]}`,
}
: {
backgroundColor:
SERIES_COLORS[index % SERIES_COLORS.length],
}
}
/>
{label}
</button>
@@ -397,6 +411,211 @@ function SeriesLegend({
);
}
// ── Compare: two periods overlaid, or two metrics side by side ─────────────
/**
* Two axes whose grid lines meet: the second axis is widened, a step at a
* time on its own nice step, until it has as many ticks as the first. Null
* when either side has no value.
*/
export function alignedAxes(
a: ReadonlyArray<number | null>,
b: ReadonlyArray<number | null>,
): { left: NiceAxis; right: NiceAxis } | null {
const left = niceAxis(a);
if (!left) return null;
const count = left.ticks.length;
const right = niceAxis(b, { maxTicks: count });
if (!right) return null;
const ticks = [...right.ticks];
if (ticks.length >= 2) {
const step = ticks[1] - ticks[0];
const decimals = Math.max(0, (String(step).split(".")[1] ?? "").length);
while (ticks.length < count) {
ticks.push(Number((ticks[ticks.length - 1] + step).toFixed(decimals)));
}
}
return {
left,
right: { domain: [ticks[0], ticks[ticks.length - 1]], ticks },
};
}
/** The rows of a compare chart: the shared x, then each side's value. */
export function comparePoints(
result: CoachResultTable,
spec: Extract<CoachChartSpec, { kind: "compare" }>,
): Array<{ x: string; a: number | null; b: number | null }> {
const index = (key: string) =>
result.columns.findIndex((column) => column.key === key);
const xi = index(spec.x);
const ai = index(spec.a);
const bi = index(spec.b);
return result.rows.map((row) => ({
x: String(row[xi] ?? ""),
a: typeof row[ai] === "number" ? (row[ai] as number) : null,
b: typeof row[bi] === "number" ? (row[bi] as number) : null,
}));
}
function CompareChart({
result,
spec,
label,
}: {
result: CoachResultTable;
spec: Extract<CoachChartSpec, { kind: "compare" }>;
label: string;
}) {
const { t } = useTranslations();
const periodLabel = usePeriodLabel();
const valueFormat = useValueFormat();
const animate = !prefersReducedMotion();
const [hidden, setHidden] = useState<ReadonlySet<string>>(new Set());
const byKey = useMemo(
() => new Map(result.columns.map((column) => [column.key, column])),
[result.columns],
);
const points = useMemo(() => comparePoints(result, spec), [result, spec]);
const isPeriod = byKey.get(spec.x)?.kind === "period";
const tickLabel = (x: string) => (isPeriod ? periodLabel(x) : x);
const twoAxes = spec.mode === "metrics" && spec.axes === 2;
// The earlier period is the dashed line, so the two read apart without
// relying on colour alone.
const dashed = new Set(spec.mode === "periods" ? ["b"] : []);
const series = [
{ key: "a", column: byKey.get(spec.a), color: SERIES_COLORS[0] },
{ key: "b", column: byKey.get(spec.b), color: SERIES_COLORS[1] },
];
const visible = series.filter((s) => !hidden.has(s.key));
const values = (key: "a" | "b") =>
hidden.has(key) ? [] : points.map((point) => point[key]);
const aligned = twoAxes ? alignedAxes(values("a"), values("b")) : null;
const shared = twoAxes ? null : niceAxis([...values("a"), ...values("b")]);
const left = aligned?.left ?? (twoAxes ? niceAxis(values("a")) : shared);
const right = aligned?.right ?? (twoAxes ? niceAxis(values("b")) : null);
const nameOf = (key: string) => {
const column = byKey.get(key === "a" ? spec.a : spec.b);
return column?.label ?? key;
};
const legendLabel =
spec.mode === "periods"
? // The key is spelled out rather than read from `dialog-keys.ts`: this
// file rides the shared chart runtime, and that module would pull the
// Coach's schemas into every chart on every page.
t("insights.coach.chart.compare.periods", {
a: nameOf("a"),
b: nameOf("b"),
})
: undefined;
const tooltip = (props: {
active?: boolean;
payload?: ReadonlyArray<{
payload?: { x: string; a: number | null; b: number | null };
}>;
}) => {
const point = props.payload?.[0]?.payload;
if (!props.active || !point) {
return <RichChartTooltip active={false} rows={[]} />;
}
const rows = visible.flatMap((s) => {
const value = point[s.key as "a" | "b"];
if (typeof value !== "number") return [];
return [
{
name: s.column?.label ?? s.key,
value: valueFormat(value, s.column),
color: s.color,
},
];
});
return <RichChartTooltip active label={tickLabel(point.x)} rows={rows} />;
};
const axisProps = (axis: NiceAxis | null) =>
axis
? { domain: axis.domain, ticks: axis.ticks, interval: 0 as const }
: { domain: ["auto", "auto"] as [string, string] };
return (
<div className="flex flex-col gap-2" data-slot="coach-result-chart-compare">
<SeriesLegend
series={series.map((s) => ({ key: s.key, label: nameOf(s.key) }))}
hidden={hidden}
label={legendLabel}
dashed={dashed}
onToggle={(key) =>
setHidden((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else if (series.length - next.size > 1) next.add(key);
return next;
})
}
/>
<PlotBand height={RESULT_CHART_HEIGHT_PX} label={label}>
<LineChart
accessibilityLayer={false}
data={points}
margin={{ top: 8, right: twoAxes ? 0 : 8, bottom: 0, left: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--border)"
vertical={false}
yAxisId="left"
/>
<XAxis
dataKey="x"
tickFormatter={tickLabel}
tick={TICK}
axisLine={false}
tickLine={false}
minTickGap={24}
/>
<YAxis
yAxisId="left"
tick={TICK}
axisLine={false}
tickLine={false}
width={40}
{...axisProps(left)}
/>
{twoAxes ? (
<YAxis
yAxisId="right"
orientation="right"
tick={TICK}
axisLine={false}
tickLine={false}
width={40}
{...axisProps(right)}
/>
) : null}
<Tooltip content={tooltip} cursor={{ stroke: "var(--border)" }} />
{visible.map((s) => (
<Line
key={s.key}
yAxisId={twoAxes && s.key === "b" ? "right" : "left"}
type="monotone"
dataKey={s.key}
name={nameOf(s.key)}
stroke={s.color}
strokeWidth={2}
strokeDasharray={dashed.has(s.key) ? "5 4" : undefined}
dot={{ r: 2.5, strokeWidth: 0, fill: s.color }}
activeDot={{ r: 4 }}
connectNulls={false}
isAnimationActive={animate}
/>
))}
</LineChart>
</PlotBand>
</div>
);
}
// ── Horizontal category bars ───────────────────────────────────────────────
function CategoryBarChart({
@@ -0,0 +1,98 @@
"use client";
/**
* v1.41 — the one quiet way the Coach offers a reply: pills under the latest
* answer, in the answer's own column, at the answer's text size.
*
* Every reply the person can give by tap uses it: the follow-ups after an
* answer, the choices of a clarifying question (the question itself is the
* answer above, its assumed choice the first pill), "Yes, remember it" /
* "No" under a fact proposal and "Take on this plan" / "Not now" under a
* plan proposal. A tap sends the pill's label as the person's own message,
* so it shows in the thread like anything they typed; the request carries
* which offer it answered, and the server resolves it from what it stored.
*
* Outline pills in the foreground at reduced contrast, never an accent
* colour, never a card. Hidden while a turn runs, so a pill can never answer
* a reply that is about to stop being the latest. The tapped pill goes away
* with the rest, so focus moves to the composer.
*/
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { COACH_SUGGESTED_REPLIES_KEYS } from "@/lib/ai/coach/dialog-keys";
import { focusCoachComposer } from "./composer-focus";
/** At most this many pills render, whatever the offer carries. */
export const MAX_SUGGESTED_REPLIES = 4;
export interface SuggestedReply {
/** Stable within the offer; the React key. */
id: string;
/** What the pill says, and the message a tap sends. */
label: string;
/** What the offer is (`followUp`, `clarification`, `memory`, `plan`). */
kind: string;
/** Extra `data-*` attributes, for tests and the e2e specs. */
data?: Record<`data-${string}`, string>;
onSelect: () => void;
}
export interface SuggestedRepliesProps {
replies: SuggestedReply[];
/** The assistant message that offered them. */
messageId: string;
/** True while a turn is in flight. */
disabled: boolean;
/** At most this many; defaults to {@link MAX_SUGGESTED_REPLIES}. */
max?: number;
/** The group's accessible name; defaults to "Suggested replies". */
label?: string;
/** The group's `data-slot`. */
slot?: string;
}
export function SuggestedReplies({
replies,
messageId,
disabled,
max = MAX_SUGGESTED_REPLIES,
label,
slot = "coach-suggested-replies",
}: SuggestedRepliesProps) {
const { t } = useTranslations();
const shown = replies.slice(0, max);
if (disabled || shown.length === 0) return null;
return (
<div
role="group"
aria-label={label ?? t(COACH_SUGGESTED_REPLIES_KEYS.groupLabel)}
data-slot={slot}
data-message-id={messageId}
className="flex max-w-full flex-wrap gap-2"
>
{shown.map((reply) => (
<Button
key={reply.id}
type="button"
variant="outline"
size="sm"
// The answer's own scale (text-sm, regular weight) at reduced
// contrast; the 44 px tap floor below `sm`, 36 px beside a
// pointer. A long label wraps inside the pill instead of pushing
// past the column at 390 px.
className="border-border text-foreground/80 hover:text-foreground hover:bg-muted h-auto min-h-11 max-w-full rounded-full px-3 py-1.5 text-left text-sm leading-snug font-normal whitespace-normal shadow-none sm:min-h-9"
data-reply-id={reply.id}
data-reply-kind={reply.kind}
{...reply.data}
onClick={() => {
reply.onSelect();
focusCoachComposer();
}}
>
{reply.label}
</Button>
))}
</div>
);
}
@@ -0,0 +1,842 @@
"use client";
/**
* v1.41 — the live trail of a Coach turn, one quiet line at the top of the
* answer.
*
* While the turn runs the line says what the Coach is doing right now, from
* the `activity` frames: "Thinking…", "Fetching blood pressure, last 90
* days…", "Summarising 214 readings…", "Writing the answer…", with the
* seconds so far on the right. A reasoning title replaces the thinking label
* when one arrives. Once the turn ends the same line settles into one calm
* summary: "Thought it through, 3 lookups, 12 s".
*
* Nothing opens by itself. The trail starts closed in every state of a turn
* (running, asking, stopped, failed, settled, reloaded) and only a tap on the
* line opens it; it then stays open until the person closes it, and keeps
* growing while the turn runs. Open, it lists one entry per phase with its
* status, the reasoning text of a round behind its own disclosure (closed
* too), the method line and the tables the answer used without pointing at
* them.
*
* Labels are catalog text the server rendered; titles and texts are screened
* model text the owner alone receives. A persisted message carries only the
* metadata, so its titles and texts are read from `…/trail` the first time
* the person opens the trail. A message saved before v1.41 has no activity
* and falls back to the step list (`metricSource.steps`), and one saved
* before steps existed to the areas it drew on.
*
* Screen readers hear the line through a polite status region, throttled to
* one announcement per `ANNOUNCE_THROTTLE_MS` (the latest wins), and the
* summary once when the turn ends. A reloaded conversation announces nothing.
*/
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Check,
ChevronRight,
Loader2,
Minus,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import {
COACH_STEP_REASON_KEYS,
COACH_STEP_UI_KEYS,
coachDomainLabelKey,
coachGranularityLabelKey,
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
COACH_ACTIVITY_KEYS,
activitySummaryKey,
} from "@/lib/ai/coach/activity/contract";
import type {
CoachActivity,
CoachMethod,
CoachProvenanceMetric,
CoachStep,
CoachTrail,
} from "@/lib/ai/coach/types";
import { CoachMethodLine } from "./method-line";
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
/** "12s": the seconds on the line and beside a finished round. */
const ELAPSED_KEY = "insights.coach.thinkingElapsed";
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type TranslateCount = (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
function tOr(
t: Translate,
key: string,
fallback: string,
params?: Record<string, string | number>,
) {
const value = t(key, params);
return value === key ? fallback : value;
}
// ── Steps (messages saved before v1.41, and the fetch rows) ────────────────
/**
* The step's label in the reader's language: the catalog key rendered with
* the domain and window names, or the server's label when the key is not
* in the bundle.
*/
export function stepLabel(step: CoachStep, t: Translate): string {
return tOr(t, step.labelKey, step.label, {
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
});
}
/**
* One row's text: the domain as the title, then the window, period,
* granularity, and either how much was read or why nothing was.
*/
export function describeStep(
step: CoachStep,
t: Translate,
tCount: TranslateCount,
): { title: string; meta: string[] } {
const title = step.domain
? t(coachDomainLabelKey(step.domain))
: stepLabel(step, t);
const meta: string[] = [];
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
if (step.period && step.period !== "current") {
meta.push(t(coachPeriodLabelKey(step.period)));
}
if (step.granularity)
meta.push(t(coachGranularityLabelKey(step.granularity)));
if (step.status === "done" && step.count !== undefined) {
const base =
step.tool === "snapshot"
? "coach.step.metrics"
: step.tool === "get_labs"
? "coach.step.rows"
: "coach.step.readings";
meta.push(tCount(base, step.count));
} else if (step.status !== "running" && step.reason) {
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
}
return { title, meta };
}
/**
* How many sources the turn looked at: one per domain (a step without one,
* the snapshot, counts by its tool). A second read of the same domain, for
* the period before or a retry, is not a new source.
*/
export function countSources(steps: readonly CoachStep[]): number {
return new Set(steps.map((step) => step.domain ?? step.tool)).size;
}
/**
* The step a legacy running line names: the latest one still running, else
* the latest one overall (between two rounds nothing is running).
*/
export function currentStep(steps: CoachStep[]): CoachStep | null {
for (let i = steps.length - 1; i >= 0; i -= 1) {
if (steps[i].status === "running") return steps[i];
}
return steps.at(-1) ?? null;
}
/**
* The areas an older message drew on, in the order its provenance lists
* them, by their display names. A token without a name in the bundle is
* dropped, and so is `general`, which names no area of the record.
*/
export function legacyAreaLabels(
areas: readonly CoachProvenanceMetric[] | undefined,
t: Translate,
): string[] {
const out: string[] = [];
for (const area of areas ?? []) {
if (area === "general") continue;
const key = `insights.coach.metric.${area}`;
const label = t(key);
if (label === key || out.includes(label)) continue;
out.push(label);
}
return out;
}
// ── Activity ───────────────────────────────────────────────────────────────
/**
* The entry the running line names: the latest one still running, else the
* latest one overall. Null when the turn has sent no activity yet.
*/
export function currentActivity(
activity: readonly CoachActivity[],
): CoachActivity | null {
for (let i = activity.length - 1; i >= 0; i -= 1) {
if (activity[i].status === "running") return activity[i];
}
return activity.at(-1) ?? null;
}
/**
* What an entry is called on the line: a screened reasoning title for a
* thinking round once one has arrived, else the server's catalog label.
*/
export function activityLineLabel(entry: CoachActivity): string {
if (
(entry.phase === "thinking" || entry.phase === "checkpoint") &&
entry.title
) {
return entry.title;
}
// A finished entry no longer trails off: "Writing the answer", not
// "Writing the answer…".
return entry.status === "running"
? entry.label
: entry.label.replace(/(?:…|\.\.\.)\s*$/u, "");
}
/** The lookups a turn made: one per fetch. */
export function countLookups(activity: readonly CoachActivity[]): number {
return activity.filter((entry) => entry.phase === "fetch").length;
}
/**
* The seconds a turn took, from its trail: the phases of a round run one
* after the other, except its fetches, which run side by side (the longest
* counts). Null when the trail carries no durations.
*/
export function activitySeconds(
activity: readonly CoachActivity[],
): number | null {
const fetchByRound = new Map<number, number>();
let sequential = 0;
let any = false;
for (const entry of activity) {
if (entry.durationMs === undefined) continue;
any = true;
if (entry.phase === "fetch") {
fetchByRound.set(
entry.round,
Math.max(fetchByRound.get(entry.round) ?? 0, entry.durationMs),
);
} else {
sequential += entry.durationMs;
}
}
if (!any) return null;
let total = sequential;
for (const ms of fetchByRound.values()) total += ms;
return Math.max(1, Math.round(total / 1000));
}
/** Whole seconds between two instants, at least one. */
export function elapsedSeconds(from: number, to: number): number {
return Math.max(1, Math.round((to - from) / 1000));
}
/**
* The settled line: "Thought it through, N lookups, S s". The seconds come
* from the trail, else from the clock of the turn that just streamed. Null
* when neither is known (a message saved before v1.41).
*/
export function activitySummary(args: {
activity: readonly CoachActivity[];
startedAt?: number | null;
endedAt?: number | null;
t: Translate;
locale: Locale;
}): string | null {
const { activity, startedAt, endedAt, t, locale } = args;
if (activity.length === 0) return null;
const seconds =
activitySeconds(activity) ??
(startedAt && endedAt ? elapsedSeconds(startedAt, endedAt) : null);
if (seconds === null) return null;
const lookups = countLookups(activity);
return t(activitySummaryKey(lookups, locale), { lookups, seconds });
}
/** A tick per second while `running`; the current time otherwise. */
function useNow(running: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!running) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [running]);
return now;
}
/**
* The status region's text: the running line as it changes, throttled, and
* the summary once when the turn ends. Nothing is announced for a trail that
* was already settled when it mounted.
*/
function useLineAnnouncement(text: string, active: boolean): string {
const [mountedActive] = useState(active);
const lastAt = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const said = useRef<string | null>(null);
const [announcement, setAnnouncement] = useState("");
useEffect(() => {
// A reloaded, settled trail stays quiet.
if (!mountedActive) return;
if (!text || text === said.current) return;
said.current = text;
const wait = Math.max(
0,
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
timer.current = null;
lastAt.current = Date.now();
setAnnouncement(text);
}, wait);
}, [text, mountedActive]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
return announcement;
}
/** The owner-only titles and texts of a persisted message's trail. */
function useTrailText(args: {
conversationId: string | null | undefined;
messageId: string | null | undefined;
enabled: boolean;
}): CoachTrail | null {
const { conversationId, messageId, enabled } = args;
const query = useQuery({
queryKey: queryKeys.coachMessageTrail(
conversationId ?? "",
messageId ?? "",
),
queryFn: async (): Promise<CoachTrail | null> => {
const data = await apiGet<
{ trail?: CoachTrail | null } | CoachTrail | null
>(
`/api/insights/chat/${encodeURIComponent(conversationId ?? "")}/messages/${encodeURIComponent(messageId ?? "")}/trail`,
);
if (!data) return null;
if ("entries" in data) return data;
return data.trail ?? null;
},
enabled: enabled && !!conversationId && !!messageId,
// The trail is the stored snapshot of a finished turn; it never changes.
staleTime: Infinity,
retry: false,
});
return query.data ?? null;
}
// ── Rendering ──────────────────────────────────────────────────────────────
type EntryStatus = CoachActivity["status"];
function StatusIcon({
status,
active,
}: {
status: EntryStatus;
active: boolean;
}) {
const className = "mt-0.5 size-3 shrink-0";
if (status === "running" && active) {
return (
<Loader2
aria-hidden="true"
className={cn(
className,
"text-muted-foreground animate-spin motion-reduce:animate-none",
)}
/>
);
}
if (status === "done" || status === "running") {
return (
<Check
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
if (status === "failed") {
return (
<TriangleAlert
aria-hidden="true"
className={cn(className, "text-warning")}
/>
);
}
return (
<Minus
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
/** The rows of a legacy step list: one per step, in the order they started. */
export function CoachTurnStepList({
id,
steps,
active,
}: {
id?: string;
steps: CoachStep[];
active: boolean;
}) {
const { t, tCount } = useTranslations();
return (
<ol
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
return (
<li
key={step.id}
data-slot="coach-turn-step"
data-status={step.status}
className="flex items-start gap-1.5 leading-relaxed"
>
<StatusIcon status={step.status} active={active} />
<span className="text-muted-foreground min-w-0">
<span className="text-foreground">{title}</span>
<StepMeta parts={meta} />
</span>
</li>
);
})}
</ol>
);
}
function StepMeta({ parts }: { parts: string[] }) {
return (
<>
{parts.map((part, i) => (
<span key={i}>
<span aria-hidden="true"> · </span>
{/* The comma is for a screen reader only. `select-none` keeps it
out of a copied row. */}
<span className="sr-only select-none">, </span>
{part}
</span>
))}
</>
);
}
/**
* One trail entry. A fetch backed by a step reads like a step row (domain,
* window, count); everything else shows its label and, once finished, how
* long it took. A thinking round with reasoning text, and the memory entry
* with the things it recalled, open their detail on their own tap.
*/
function ActivityEntry({
entry,
step,
active,
detail,
}: {
entry: CoachActivity;
step: CoachStep | undefined;
active: boolean;
detail: { text?: string; recalled?: string[] } | null;
}) {
const { t, tCount } = useTranslations();
const detailId = useId();
const [open, setOpen] = useState(false);
let title: string;
let meta: string[] = [];
if (entry.phase === "fetch" && step) {
({ title, meta } = describeStep(step, t, tCount));
} else {
title = activityLineLabel(entry);
if (entry.status !== "running" && entry.durationMs !== undefined) {
meta = [
t(ELAPSED_KEY, {
seconds: Math.max(1, Math.round(entry.durationMs / 1000)),
}),
];
}
}
const hasDetail =
!!detail?.text ||
(detail?.recalled !== undefined && detail.recalled.length > 0);
return (
<li
data-slot="coach-turn-step"
data-phase={entry.phase}
data-status={entry.status}
className="relative flex items-start gap-1.5 leading-relaxed"
>
<StatusIcon status={entry.status} active={active} />
<div className="flex min-w-0 flex-col gap-1">
{hasDetail ? (
<button
type="button"
data-slot="coach-turn-step-detail-toggle"
aria-expanded={open}
aria-controls={open ? detailId : undefined}
onClick={() => setOpen(!open)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex w-fit max-w-full items-start gap-1 rounded text-left outline-none focus-visible:ring-2"
>
<span className="min-w-0">
<span className="text-foreground">{title}</span>
<StepMeta parts={meta} />
</span>
<ChevronRight
aria-hidden="true"
className={cn(
"mt-0.5 size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
</button>
) : (
<span className="text-muted-foreground min-w-0">
<span className="text-foreground">{title}</span>
<StepMeta parts={meta} />
</span>
)}
{hasDetail && open ? (
<div
id={detailId}
data-slot="coach-turn-step-detail"
className="text-foreground flex flex-col gap-1"
>
{detail?.text ? (
<p className="whitespace-pre-wrap">{detail.text}</p>
) : null}
{detail?.recalled?.length ? (
<ul className="flex flex-col gap-0.5">
{detail.recalled.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
</li>
);
}
/**
* The open trail: the entries (or a legacy message's steps or areas), then
* the method line, then the tables the answer used. One left rule holds
* them together.
*/
function CoachTurnActivityPanel({
id,
activity,
steps,
active,
areaLabels,
trail,
method,
dataUsed,
}: {
id: string;
activity: CoachActivity[];
steps: CoachStep[];
active: boolean;
areaLabels: string[];
trail: CoachTrail | null;
method?: CoachMethod | null;
dataUsed?: ReactNode;
}) {
const { t } = useTranslations();
const stepsById = new Map(steps.map((step) => [step.id, step]));
const textById = new Map(
(trail?.entries ?? []).map((entry) => [entry.id, entry]),
);
return (
<div
id={id}
data-slot="coach-turn-steps-panel"
className="border-border mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
>
{activity.length > 0 ? (
<ol
data-slot="coach-turn-activity-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{activity.map((entry) => {
const stored = textById.get(entry.id);
const merged: CoachActivity = {
...entry,
title: entry.title ?? stored?.title,
text: entry.text ?? stored?.text,
};
const detail =
entry.phase === "memory"
? { recalled: trail?.recalled }
: merged.text
? { text: merged.text }
: null;
return (
<ActivityEntry
key={entry.id}
entry={merged}
step={entry.stepRef ? stepsById.get(entry.stepRef) : undefined}
active={active}
detail={detail}
/>
);
})}
</ol>
) : steps.length > 0 ? (
<CoachTurnStepList steps={steps} active={active} />
) : areaLabels.length > 0 ? (
<ul
data-slot="coach-turn-areas"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{areaLabels.map((label) => (
<li
key={label}
data-slot="coach-turn-area"
className="text-foreground flex items-start gap-1.5 leading-relaxed"
>
<Check
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3 shrink-0"
/>
<span className="min-w-0">{label}</span>
</li>
))}
</ul>
) : null}
{!active && <CoachMethodLine method={method ?? null} />}
{!active && dataUsed}
</div>
);
}
export interface CoachTurnActivityProps {
/** The live trail, or `metricSource.activity` of a persisted message. */
activity: CoachActivity[];
/** The steps the fetch entries point at; a legacy message's whole list. */
steps: CoachStep[];
/** True while the turn is still running. */
active: boolean;
/** When the live turn started and ended on this device (epoch ms). */
startedAt?: number | null;
endedAt?: number | null;
/**
* The provenance metrics, for a message saved before steps existed. Read
* only when there is neither activity nor steps.
*/
areas?: readonly CoachProvenanceMetric[];
/** How the answer was worked out; shown at the end of the open trail. */
method?: CoachMethod | null;
/**
* The tables the answer read without pointing at them, shown last in the
* open trail. Pass it only when there is something to show.
*/
dataUsed?: ReactNode;
/** The persisted message, for reading its stored titles and texts. */
conversationId?: string | null;
messageId?: string | null;
}
export function CoachTurnActivity({
activity,
steps,
active,
startedAt,
endedAt,
areas,
method,
dataUsed,
conversationId,
messageId,
}: CoachTurnActivityProps) {
const { t, tCount, locale } = useTranslations();
const panelId = useId();
// Closed in every state; only the person's tap opens it, and it stays as
// they left it (also across the swap to the persisted copy, which keeps
// this component).
const [open, setOpen] = useState(false);
const now = useNow(active);
// Only a persisted message whose live entries carry no text asks the
// server for it, and only once the trail is open.
const needsStoredText =
!active && activity.length > 0 && !activity.some((a) => a.title || a.text);
const trail = useTrailText({
conversationId,
messageId,
enabled: open && needsStoredText,
});
const areaLabels =
activity.length === 0 && steps.length === 0
? legacyAreaLabels(areas, t)
: [];
const hasMethod = !active && !!method?.text;
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
let line: string;
if (active) {
const entry = currentActivity(activity);
const step = entry ? null : currentStep(steps);
line = entry
? activityLineLabel(entry)
: step
? stepLabel(step, t)
: t(COACH_ACTIVITY_KEYS.thinking);
} else {
line =
activitySummary({ activity, startedAt, endedAt, t, locale }) ??
(steps.length > 0
? tCount("coach.step.headerDone", countSources(steps))
: areaLabels.length > 0
? tCount("insights.coach.answer.areasDone", areaLabels.length)
: t(COACH_STEP_UI_KEYS.listLabel));
}
const announcement = useLineAnnouncement(line, active);
// A settled message with nothing to show has no line at all.
if (
!active &&
activity.length === 0 &&
steps.length === 0 &&
areaLabels.length === 0 &&
!hasMethod &&
!hasDataUsed
) {
return null;
}
const seconds =
active && startedAt
? elapsedSeconds(startedAt, Math.max(now, startedAt))
: null;
return (
<div
data-slot="coach-turn-steps"
data-state={active ? "running" : "done"}
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
>
<button
type="button"
data-slot="coach-turn-steps-toggle"
aria-expanded={open}
aria-controls={open ? panelId : undefined}
// While the turn runs the visible text changes with every phase; the
// status region announces it, so the button keeps a stable name.
{...(active
? {
"aria-label": t(
open
? COACH_STEP_UI_KEYS.toggleHide
: COACH_STEP_UI_KEYS.toggleShow,
),
}
: {})}
onClick={() => setOpen(!open)}
className={cn(
// A 44 px target on phones whose extra height reaches into the gap
// around it, so the line still sits close to the answer; beside a
// pointer the line is as tall as the avatar it sits next to.
"text-muted-foreground hover:text-foreground -my-1.5 flex min-h-11 w-full max-w-full min-w-0 items-center gap-1.5 sm:my-0 sm:min-h-8",
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
)}
>
{active ? (
<Loader2
aria-hidden="true"
data-slot="coach-turn-steps-spinner"
className="size-3 shrink-0 animate-spin motion-reduce:animate-none"
/>
) : (
<ChevronRight
aria-hidden="true"
className={cn(
"size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
)}
{active ? (
<span
aria-hidden="true"
data-slot="coach-turn-steps-active"
className="min-w-0 truncate"
>
{line}
</span>
) : (
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
{line}
</span>
)}
{seconds !== null ? (
<span
aria-hidden="true"
data-slot="coach-turn-steps-seconds"
className="ml-auto shrink-0 pl-2 tabular-nums"
>
{t(ELAPSED_KEY, { seconds })}
</span>
) : null}
</button>
{open && (
<CoachTurnActivityPanel
id={panelId}
activity={activity}
steps={steps}
active={active}
areaLabels={areaLabels}
trail={trail}
method={method}
dataUsed={hasDataUsed ? dataUsed : null}
/>
)}
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</span>
</div>
);
}
@@ -1,498 +0,0 @@
"use client";
/**
* v1.39.4 — what the Coach read on a turn: a compact list of steps above
* the answer, live while the turn runs (from the `step` frames) and
* restored from `metricSource.steps` on reload.
*
* While the turn runs the header names the step in progress ("Reading
* Blood pressure, last 90 days…") with a soft shimmer; once it ends the
* header folds into "Looked at 3 sources" and the list closes. The list
* itself is one row per step: domain, window, and a count or the reason
* nothing was found. Everything shown comes from catalog keys and the
* server's enums and counts; a row never shows a value, a tool argument or
* model text.
*
* The open list also says how the answer was worked out (the method line)
* and holds the tables the answer read without pointing at them. An older
* message saved before steps existed folds into "Looked at 3 areas" and
* lists the areas from its provenance, without counts; a metric token the
* bundle has no name for is left out rather than shown as a raw key.
*
* Screen readers hear each completed step once, through a polite status
* region, at most one announcement per `ANNOUNCE_THROTTLE_MS` (the latest
* wins). A reloaded conversation announces nothing.
*/
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import {
Check,
ChevronRight,
Loader2,
Minus,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import {
COACH_STEP_REASON_KEYS,
COACH_STEP_UI_KEYS,
coachDomainLabelKey,
coachGranularityLabelKey,
coachPeriodLabelKey,
coachWindowLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type {
CoachMethod,
CoachProvenanceMetric,
CoachStep,
} from "@/lib/ai/coach/types";
import { CoachMethodLine } from "./method-line";
export interface CoachTurnStepsProps {
steps: CoachStep[];
/** True while the turn is still running. */
active: boolean;
}
export interface CoachTurnStepsDetailProps extends CoachTurnStepsProps {
/**
* The provenance metrics, for a message saved before steps existed. Read
* only when `steps` is empty.
*/
areas?: readonly CoachProvenanceMetric[];
/** How the answer was worked out; shown at the end of the open list. */
method?: CoachMethod | null;
/**
* The tables the answer read without pointing at them, shown last in the
* open list. Pass it only when there is something to show.
*/
dataUsed?: ReactNode;
}
/** The shortest gap between two screen-reader announcements. */
export const ANNOUNCE_THROTTLE_MS = 1500;
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
type TranslateCount = (
baseKey: string,
count: number,
params?: Record<string, string | number>,
) => string;
/** `t(key)`, or `fallback` when the bundle does not carry the key. */
function tOr(
t: Translate,
key: string,
fallback: string,
params?: Record<string, string | number>,
) {
const value = t(key, params);
return value === key ? fallback : value;
}
/**
* The step's label in the reader's language: the catalog key rendered with
* the domain and window names, or the server's label when the key is not
* in the bundle.
*/
export function stepLabel(step: CoachStep, t: Translate): string {
return tOr(t, step.labelKey, step.label, {
...(step.domain ? { domain: t(coachDomainLabelKey(step.domain)) } : {}),
...(step.window ? { window: t(coachWindowLabelKey(step.window)) } : {}),
});
}
/**
* One row's text: the domain as the title, then the window, period,
* granularity, and either how much was read or why nothing was.
*/
export function describeStep(
step: CoachStep,
t: Translate,
tCount: TranslateCount,
): { title: string; meta: string[] } {
const title = step.domain
? t(coachDomainLabelKey(step.domain))
: stepLabel(step, t);
const meta: string[] = [];
if (step.window) meta.push(t(coachWindowLabelKey(step.window)));
if (step.period && step.period !== "current") {
meta.push(t(coachPeriodLabelKey(step.period)));
}
if (step.granularity)
meta.push(t(coachGranularityLabelKey(step.granularity)));
if (step.status === "done" && step.count !== undefined) {
const base =
step.tool === "snapshot"
? "coach.step.metrics"
: step.tool === "get_labs"
? "coach.step.rows"
: "coach.step.readings";
meta.push(tCount(base, step.count));
} else if (step.status !== "running" && step.reason) {
meta.push(t(COACH_STEP_REASON_KEYS[step.reason]));
}
return { title, meta };
}
function rowText(step: CoachStep, t: Translate, tCount: TranslateCount) {
const { title, meta } = describeStep(step, t, tCount);
return [title, ...meta].join(" · ");
}
/**
* How many sources the turn looked at: one per domain (a step without one,
* the snapshot, counts by its tool). A second read of the same domain, for
* the period before or a retry, is not a new source.
*/
export function countSources(steps: readonly CoachStep[]): number {
return new Set(steps.map((step) => step.domain ?? step.tool)).size;
}
/**
* The step the active header names: the latest one still running, else the
* latest one overall (between two rounds nothing is running).
*/
export function currentStep(steps: CoachStep[]): CoachStep | null {
for (let i = steps.length - 1; i >= 0; i -= 1) {
if (steps[i].status === "running") return steps[i];
}
return steps.at(-1) ?? null;
}
/**
* The newest settled step the reader has not been told about yet, given the
* ids already announced (or present when the list mounted). Null when there
* is nothing new.
*/
export function nextAnnouncement(
steps: CoachStep[],
told: ReadonlySet<string>,
): CoachStep | null {
let latest: CoachStep | null = null;
for (const step of steps) {
if (step.status !== "running" && !told.has(step.id)) latest = step;
}
return latest;
}
/**
* The status region's text: each newly settled step, throttled. Steps that
* were already settled when the list mounted are never announced, so a
* reloaded conversation stays quiet.
*/
function useStepAnnouncement(
steps: CoachStep[],
describe: (step: CoachStep) => string,
): string {
// Mutable on purpose: the ids the reader has been told about, seeded with
// whatever was already settled at mount.
const [told] = useState(
() => new Set(steps.filter((s) => s.status !== "running").map((s) => s.id)),
);
const lastAt = useRef(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [text, setText] = useState("");
useEffect(() => {
const next = nextAnnouncement(steps, told);
if (!next) return;
for (const step of steps) {
if (step.status !== "running") told.add(step.id);
}
const nextText = describe(next);
const wait = Math.max(
0,
lastAt.current + ANNOUNCE_THROTTLE_MS - Date.now(),
);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
timer.current = null;
lastAt.current = Date.now();
setText(nextText);
}, wait);
}, [steps, describe, told]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
return text;
}
function StepIcon({ step, active }: { step: CoachStep; active: boolean }) {
const className = "mt-0.5 size-3 shrink-0";
if (step.status === "running" && active) {
return (
<Loader2
aria-hidden="true"
className={cn(
className,
"text-muted-foreground animate-spin motion-reduce:animate-none",
)}
/>
);
}
if (step.status === "done") {
return (
<Check
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
if (step.status === "failed") {
return (
<TriangleAlert
aria-hidden="true"
className={cn(className, "text-warning")}
/>
);
}
return (
<Minus
aria-hidden="true"
className={cn(className, "text-muted-foreground")}
/>
);
}
/**
* The areas an older message drew on, in the order its provenance lists
* them, by their display names. A token without a name in the bundle is
* dropped, and so is `general`, which names no area of the record.
*/
export function legacyAreaLabels(
areas: readonly CoachProvenanceMetric[] | undefined,
t: Translate,
): string[] {
const out: string[] = [];
for (const area of areas ?? []) {
if (area === "general") continue;
const key = `insights.coach.metric.${area}`;
const label = t(key);
if (label === key || out.includes(label)) continue;
out.push(label);
}
return out;
}
/** The rows under the header: one per step, in the order they started. */
export function CoachTurnStepList({
id,
steps,
active,
}: CoachTurnStepsProps & { id?: string }) {
const { t, tCount } = useTranslations();
return (
<ol
id={id}
data-slot="coach-turn-steps-list"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{steps.map((step) => {
const { title, meta } = describeStep(step, t, tCount);
return (
<li
key={step.id}
data-slot="coach-turn-step"
data-status={step.status}
className="flex items-start gap-1.5 leading-relaxed"
>
<StepIcon step={step} active={active} />
<span className="text-muted-foreground min-w-0">
<span className="text-foreground/80">{title}</span>
{meta.map((part, i) => (
<span key={i}>
<span aria-hidden="true"> · </span>
{/* The comma is for a screen reader only. `select-none`
keeps it out of a copied row, which otherwise read
"Weight · ,all time". */}
<span className="sr-only select-none">, </span>
{part}
</span>
))}
</span>
</li>
);
})}
</ol>
);
}
/**
* The open list: the steps (or an older message's areas), then the method
* line, then the tables the answer read. One left rule holds them together.
*/
export function CoachTurnStepsPanel({
id,
steps,
active,
areaLabels,
method,
dataUsed,
}: CoachTurnStepsProps & {
id?: string;
areaLabels: string[];
method?: CoachMethod | null;
dataUsed?: ReactNode;
}) {
const { t } = useTranslations();
return (
<div
id={id}
data-slot="coach-turn-steps-panel"
className="border-border/60 mt-1.5 ml-1.5 flex min-w-0 flex-col gap-2 border-l pl-2.5"
>
{steps.length > 0 ? (
<CoachTurnStepList steps={steps} active={active} />
) : areaLabels.length > 0 ? (
<ul
data-slot="coach-turn-areas"
aria-label={t(COACH_STEP_UI_KEYS.listLabel)}
className="flex flex-col gap-1"
>
{areaLabels.map((label) => (
<li
key={label}
data-slot="coach-turn-area"
className="text-foreground/80 flex items-start gap-1.5 leading-relaxed"
>
<Check
aria-hidden="true"
className="text-muted-foreground mt-0.5 size-3 shrink-0"
/>
<span className="min-w-0">{label}</span>
</li>
))}
</ul>
) : null}
{!active && <CoachMethodLine method={method ?? null} />}
{!active && dataUsed}
</div>
);
}
export function CoachTurnSteps({
steps,
active,
areas,
method,
dataUsed,
}: CoachTurnStepsDetailProps) {
const { t, tCount } = useTranslations();
const listId = useId();
// Closed by default; the reader's toggle holds until the turn ends, and
// the list folds away again when it does.
const [openOverride, setOpenOverride] = useState<boolean | null>(null);
const [wasActive, setWasActive] = useState(active);
if (wasActive !== active) {
setWasActive(active);
setOpenOverride(null);
}
const announcement = useStepAnnouncement(steps, (step) =>
t(COACH_STEP_UI_KEYS.announceDone, { label: rowText(step, t, tCount) }),
);
const areaLabels = steps.length === 0 ? legacyAreaLabels(areas, t) : [];
const hasMethod = !active && !!method?.text;
const hasDataUsed = !active && dataUsed != null && dataUsed !== false;
if (
steps.length === 0 &&
areaLabels.length === 0 &&
!hasMethod &&
!hasDataUsed
) {
return null;
}
const open = openOverride ?? false;
const current = active ? currentStep(steps) : null;
const header = current
? null
: steps.length > 0
? tCount("coach.step.headerDone", countSources(steps))
: areaLabels.length > 0
? tCount("insights.coach.answer.areasDone", areaLabels.length)
: t(COACH_STEP_UI_KEYS.listLabel);
return (
<div
data-slot="coach-turn-steps"
className="flex w-full max-w-full min-w-0 flex-col self-stretch text-xs"
>
<button
type="button"
data-slot="coach-turn-steps-toggle"
aria-expanded={open}
aria-controls={open ? listId : undefined}
// While the turn runs the visible text changes with every step; the
// status region below announces completions, so the button keeps a
// stable name instead of re-announcing each label.
{...(current
? {
"aria-label": t(
open
? COACH_STEP_UI_KEYS.toggleHide
: COACH_STEP_UI_KEYS.toggleShow,
),
}
: {})}
onClick={() => setOpenOverride(!open)}
className={cn(
"text-muted-foreground hover:text-foreground flex w-fit max-w-full items-center gap-1.5",
"focus-visible:ring-ring/50 rounded text-left leading-relaxed outline-none focus-visible:ring-2",
)}
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
{current ? (
<span
aria-hidden="true"
data-slot="coach-turn-steps-active"
className="skeleton-shimmer min-w-0 truncate rounded"
>
{t(COACH_STEP_UI_KEYS.headerActive, {
label: stepLabel(current, t),
})}
</span>
) : (
<span data-slot="coach-turn-steps-done" className="min-w-0 truncate">
{header}
</span>
)}
</button>
{open && (
<CoachTurnStepsPanel
id={listId}
steps={steps}
active={active}
areaLabels={areaLabels}
method={method}
dataUsed={hasDataUsed ? dataUsed : null}
/>
)}
<span
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{announcement}
</span>
</div>
);
}
@@ -0,0 +1,89 @@
"use client";
/**
* v1.41 — what the Coach remembers, as `GET /api/insights/coach/facts` lists
* it: the one read behind the memory list in Settings → Coach, the memory
* link's count in the Coach's quick settings and the memory note under an
* answer. One query function for the one key, so the surfaces share a cache
* entry and never poison it with two shapes.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { CoachMemoryCategory } from "@/lib/ai/coach/types";
import type { CoachFactSource } from "@/lib/ai/coach/memory/shared";
export interface CoachFactDTO {
id: string;
/** A v1.41 category, or an older extraction's. */
category: CoachMemoryCategory;
text: string;
confidence: number;
/** Absent on a server older than v1.41. */
source?: CoachFactSource;
sourceConversationId?: string | null;
sourceMessageId?: string | null;
lastUsedAt?: string | null;
createdAt: string;
updatedAt?: string;
}
export async function fetchCoachFacts(): Promise<CoachFactDTO[]> {
const data = await apiGet<{ facts?: CoachFactDTO[] } | undefined>(
"/api/insights/coach/facts",
);
return data?.facts ?? [];
}
export function useCoachFacts(opts: { enabled?: boolean } = {}) {
return useQuery({
queryKey: queryKeys.coachFacts(),
queryFn: fetchCoachFacts,
enabled: opts.enabled ?? true,
});
}
/** Forget one fact (soft delete); the list refetches. */
export function useForgetCoachFact(
opts: {
onSuccess?: () => void;
onError?: () => void;
} = {},
) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async (id: string) => {
await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
return id;
},
onSuccess: () => {
opts.onSuccess?.();
void queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
},
onError: () => opts.onError?.(),
});
}
/** Rewrite one fact's wording; the category and the source stay. */
export function useEditCoachFact(
opts: {
onSuccess?: () => void;
onError?: () => void;
} = {},
) {
const queryClient = useQueryClient();
return useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async (input: { id: string; fact: string }) =>
apiPatch(`/api/insights/coach/facts/${encodeURIComponent(input.id)}`, {
fact: input.fact,
}),
onSuccess: () => {
opts.onSuccess?.();
void queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
},
onError: () => opts.onError?.(),
});
}
@@ -11,15 +11,19 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { toast } from "sonner";
import type {
CoachActivity,
CoachClarification,
CoachConversationAttachmentDTO,
CoachConversationDetailDTO,
CoachConversationsPage,
CoachFollowUp,
CoachMemoryNote,
CoachPlanProposal,
CoachProvenance,
CoachResultTable,
CoachScope,
CoachStep,
CoachStop,
CoachStreamEvent,
CoachSuggestion,
CoachUsage,
@@ -558,6 +562,31 @@ export interface CoachStreamingMessage {
followUps: CoachFollowUp[];
/** v1.39.4 — the choices from the additive `clarification` frame. */
clarification: CoachClarification | null;
/**
* v1.41 — the live trail from the additive `activity` frames, upserted by
* id in first-seen order. Persisted messages carry the metadata on
* `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. */
@@ -640,6 +669,13 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
results: [],
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -655,6 +691,47 @@ export function upsertStep(steps: CoachStep[], step: CoachStep): CoachStep[] {
return next;
}
/** v1.41 — insert a trail entry, or replace the one with the same id. */
export function upsertActivity(
activity: CoachActivity[],
entry: CoachActivity,
): CoachActivity[] {
const at = activity.findIndex((a) => a.id === entry.id);
if (at === -1) return [...activity, entry];
const next = [...activity];
// A later frame may leave out what an earlier one carried (the title on a
// `done` frame that only adds the duration); keep it.
next[at] = { ...next[at], ...entry };
return next;
}
/**
* v1.41 — insert a table, or replace the one with the same ref. An interim
* table and the final frame for it share a ref.
*/
export function upsertResult(
results: CoachResultTable[],
result: CoachResultTable,
): CoachResultTable[] {
const at = results.findIndex((r) => r.ref === result.ref);
if (at === -1) return [...results, result];
const next = [...results];
next[at] = result;
return next;
}
/**
* v1.41 — the stream's state after `done`: a turn blocked after its interim
* tables went out takes them down again.
*/
export function withoutWithheldResults(
results: CoachResultTable[],
interimRefs: readonly string[],
): CoachResultTable[] {
const withheld = new Set(interimRefs);
return results.filter((r) => !withheld.has(r.ref));
}
export interface SendCoachMessageParams {
conversationId?: string;
message: string;
@@ -709,6 +786,13 @@ export interface SendCoachMessageParams {
* tapped (absent when the person typed their own answer). Tool route only.
*/
clarification?: { messageId: string; choiceId?: string };
/**
* v1.41 — the person answered a fact proposal with a reply pill. The
* server reads the fact from what it stored on that message.
*/
memoryDecision?: { messageId: string; proposalId: string; accept: boolean };
/** v1.41 — the person answered a plan proposal with a reply pill. */
planDecision?: { messageId: string; planId: string; accept: boolean };
}
/**
@@ -758,6 +842,8 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
workoutId: params.workoutId,
followUp: params.followUp,
clarification: params.clarification,
memoryDecision: params.memoryDecision,
planDecision: params.planDecision,
}),
};
}
@@ -902,9 +988,11 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: params.message,
conversationId: params.conversationId ?? null,
});
const startedAt = Date.now();
setStreaming({
...EMPTY_STREAMING,
inProgress: true,
startedAt,
});
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
@@ -1005,9 +1093,15 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
let collectedSuggestion: CoachSuggestion | null = null;
let collectedSuggestedAction: CoachSuggestedAction | null = null;
let collectedSteps: CoachStep[] = [];
const collectedResults: CoachResultTable[] = [];
let collectedResults: CoachResultTable[] = [];
let collectedFollowUps: CoachFollowUp[] = [];
let collectedClarification: CoachClarification | null = null;
let collectedActivity: CoachActivity[] = [];
const interimRefs: string[] = [];
let collectedMemoryNote: CoachMemoryNote | null = null;
let collectedPlanProposal: CoachPlanProposal | null = null;
let collectedStop: CoachStop | null = null;
let withheldResults = false;
let lastError: string | null = null;
let messageId: string | null = null;
let collectedUsage: CoachUsage | null = null;
@@ -1061,13 +1155,39 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}
case "result": {
const result = evt.result;
collectedResults.push(result);
collectedResults = upsertResult(collectedResults, result);
if (evt.interim && !interimRefs.includes(result.ref)) {
interimRefs.push(result.ref);
}
const refs = [...interimRefs];
setStreaming((prev) => ({
...prev,
results: [...prev.results, result],
results: upsertResult(prev.results, result),
interimRefs: refs,
}));
break;
}
case "activity": {
// v1.41 — the live trail, upserted by entry id.
const entry = evt.activity;
collectedActivity = upsertActivity(collectedActivity, entry);
setStreaming((prev) => ({
...prev,
activity: upsertActivity(prev.activity, entry),
}));
break;
}
case "memoryNote":
collectedMemoryNote = evt.note;
setStreaming((prev) => ({ ...prev, memoryNote: evt.note }));
break;
case "planProposal":
collectedPlanProposal = evt.proposal;
setStreaming((prev) => ({
...prev,
planProposal: evt.proposal,
}));
break;
case "followUps":
collectedFollowUps = evt.followUps;
setStreaming((prev) => ({
@@ -1093,6 +1213,10 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
messageId = evt.messageId;
// v1.18.9 — additive per-turn usage envelope.
collectedUsage = evt.usage ?? null;
// v1.41 — why the answer was forced, and whether the
// interim tables must come down again.
collectedStop = evt.stop ?? null;
withheldResults = evt.withheldResults === true;
break;
case "error":
lastError = evt.code;
@@ -1107,6 +1231,8 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
resolvedConversationId = evt.conversationId;
messageId = evt.messageId;
collectedUsage = evt.usage ?? null;
collectedStop = evt.stop ?? null;
withheldResults = evt.withheldResults === true;
} else if (evt.type === "error") {
lastError = evt.code;
}
@@ -1117,6 +1243,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
}
}
if (withheldResults) {
collectedResults = withoutWithheldResults(
collectedResults,
interimRefs,
);
}
setStreaming({
content: collectedContent,
metricSource: collectedProvenance,
@@ -1126,6 +1258,13 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
results: collectedResults,
followUps: collectedFollowUps,
clarification: collectedClarification,
activity: collectedActivity,
interimRefs: withheldResults ? [] : interimRefs,
memoryNote: collectedMemoryNote,
planProposal: collectedPlanProposal,
stop: collectedStop,
startedAt,
endedAt: Date.now(),
inProgress: false,
messageId,
errorCode: lastError,
@@ -15,6 +15,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: coachState.available ? null : "no_provider",
onDeviceAllowed: false,
}),
// v1.41 — the thinking depth reads the resolved block; none here.
useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
@@ -25,10 +25,17 @@ import { CoachMemorySection } from "../coach-memory-section";
interface SeedFact {
id: string;
category: "preference" | "condition" | "goal" | "constraint" | "context";
category:
| "preference"
| "condition"
| "goal"
| "constraint"
| "context"
| "medication";
text: string;
confidence: number;
createdAt: string;
source?: "user" | "coach" | "extracted" | "pattern";
}
const NOW = "2026-06-04T12:00:00.000Z";
@@ -152,4 +159,45 @@ describe("Settings — CoachMemorySection", () => {
expect(withFacts).toContain("rolling summary");
expect(empty).toContain("rolling summary");
});
it("says where each fact came from, and offers to edit it", () => {
const html = render([
{ ...buildFacts()[0], source: "user" },
{ ...buildFacts()[1], source: "coach" },
{ ...buildFacts()[2], source: "extracted" },
{
id: "fact-med-1",
category: "medication",
text: "Takes ramipril in the morning",
confidence: 1,
createdAt: NOW,
source: "user",
},
]);
expect(
html.match(/data-slot="settings-coach-memory-source"/g),
).toHaveLength(4);
expect(html).toContain("from you");
expect(html).toContain("from the Coach");
expect(html.match(/data-slot="settings-coach-memory-edit"/g)).toHaveLength(
4,
);
expect(html).toMatch(
/data-slot="settings-coach-memory-edit"[^>]*aria-label="Edit"/,
);
// Medications get their own group.
expect(html).toContain(
'data-testid="settings-coach-memory-group-medication"',
);
expect(html).toContain("Medications");
// The quick settings land here.
expect(html).toMatch(/<section[^>]*id="coach-memory"/);
});
it("lists goals first", () => {
const html = render(buildFacts());
expect(html.indexOf("group-goal")).toBeLessThan(
html.indexOf("group-preference"),
);
});
});
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: false,
}),
// v1.41 — the thinking depth reads the resolved block; none here.
useCoachReasoning: () => null,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
@@ -36,6 +36,8 @@ vi.mock("@/hooks/use-ai-capability", () => ({
reason: null,
onDeviceAllowed: true,
}),
// v1.41 — the thinking depth reads the resolved block; none here.
useCoachReasoning: () => null,
}));
const authState: {
@@ -4,6 +4,8 @@
* Reasoning select (#1126) for the Local and OpenAI-compatible forms.
* Same Label + NativeSelect + hint layout as the model field above it.
* The empty value is Default: nothing is sent and the model decides.
* v1.41 — the Coach reads its own thinking depth from the Coach settings;
* a line under the hint says so.
* ──────────────────────────────────────────────────────────────── */
import { Label } from "@/components/ui/label";
@@ -56,6 +58,14 @@ export function ReasoningEffortField({
<p id={`${id}-hint`} className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.reasoning.hint")}
</p>
{/* v1.41 — the Coach has its own thinking depth; this select is the
default for everything else the provider does. */}
<p
data-slot="reasoning-effort-coach-note"
className="text-muted-foreground mt-1 text-xs"
>
{t("settings.ai.reasoning.coachNote")}
</p>
</div>
);
}
+198 -73
View File
@@ -16,6 +16,12 @@
* every read/write routes its key through `queryKeys.coachFacts()` so
* a forget invalidates the list.
*
* v1.41 — the Coach's one memory. Each entry says where it came from
* ("from you" for what the person asked it to keep, "from the Coach" for what
* it kept or worked out itself), and can be edited in place
* (`PATCH /api/insights/coach/facts/{id}`) as well as forgotten. The Coach's
* quick settings link here (`#coach-memory`).
*
* v1.39 — never gated on the Coach. What the Coach stored is the person's
* own record: it stays readable and deletable while the Coach is off for any
* reason (the operator's switch, Hide Coach, no provider, no consent). The
@@ -24,9 +30,9 @@
* shows this card; while that page is not reachable, Settings → AI shows it
* through `<StoredCoachMemory>` below, whenever rows exist.
*/
import { useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Brain, Loader2, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Brain, Loader2, Pencil, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -50,42 +56,192 @@ import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { toast } from "sonner";
import { apiDelete, apiGet } from "@/lib/api/api-fetch";
import { apiDelete } from "@/lib/api/api-fetch";
import { Textarea } from "@/components/ui/textarea";
import {
COACH_MEMORY_LIST_KEYS,
REMEMBER_FACT_MAX_CHARS,
} from "@/lib/ai/coach/memory/shared";
import {
useCoachFacts,
useEditCoachFact,
useForgetCoachFact,
type CoachFactDTO,
} from "@/components/insights/coach-panel/use-coach-facts";
import { CoachConversationsMemoryCard } from "@/components/settings/coach-conversations-memory-card";
import { CoachRemindersSection } from "@/components/settings/coach-reminders-section";
import { CoachPlansMemoryCard } from "@/components/settings/coach-plans-memory-card";
/** Closed enum mirrored from the server `CoachFact.category` column. */
/**
* The categories in the order the list shows them: goals first, then how the
* person likes things, then health, then the rest.
*/
const FACT_CATEGORIES = [
"goal",
"preference",
"condition",
"goal",
"medication",
"constraint",
"context",
] as const;
type FactCategory = (typeof FACT_CATEGORIES)[number];
interface CoachFact {
id: string;
category: FactCategory;
text: string;
confidence: number;
createdAt: string;
}
const CATEGORY_LABEL_KEY: Record<FactCategory, string> = {
goal: "settings.ai.coachMemory.categoryGoal",
preference: "settings.ai.coachMemory.categoryPreference",
condition: "settings.ai.coachMemory.categoryCondition",
goal: "settings.ai.coachMemory.categoryGoal",
medication: COACH_MEMORY_LIST_KEYS.categoryMedication,
constraint: "settings.ai.coachMemory.categoryConstraint",
context: "settings.ai.coachMemory.categoryContext",
};
async function fetchFacts(): Promise<CoachFact[]> {
const data = await apiGet<{ facts?: CoachFact[] } | undefined>(
"/api/insights/coach/facts",
/** "from you" for what the person asked to keep; everything else the Coach. */
export function factSourceKey(
fact: Pick<CoachFactDTO, "source">,
): string | null {
if (!fact.source) return null;
return fact.source === "user"
? COACH_MEMORY_LIST_KEYS.sourceUser
: COACH_MEMORY_LIST_KEYS.sourceCoach;
}
/** One entry, read or being edited. */
function FactRow({
fact,
isAuthenticated,
forgetting,
onForget,
}: {
fact: CoachFactDTO;
isAuthenticated: boolean;
forgetting: boolean;
onForget: () => void;
}) {
const { t } = useTranslations();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(fact.text);
const edit = useEditCoachFact({
// The edited words in place are the confirmation.
onSuccess: () => setEditing(false),
onError: () => toast.error(t("admin.settingsSaveError")),
});
const sourceKey = factSourceKey(fact);
const trimmed = draft.trim();
const inputId = `coach-memory-edit-${fact.id}`;
return (
<li
data-testid="settings-coach-memory-fact"
data-source={fact.source}
className="border-border bg-background flex flex-col gap-2 rounded-lg border p-3"
>
{editing ? (
<form
className="flex flex-col gap-2"
onSubmit={(e) => {
e.preventDefault();
if (trimmed && trimmed !== fact.text) {
edit.mutate({ id: fact.id, fact: trimmed });
} else {
setEditing(false);
}
}}
>
<label htmlFor={inputId} className="sr-only">
{t(COACH_MEMORY_LIST_KEYS.edit)}
</label>
<Textarea
id={inputId}
data-slot="settings-coach-memory-edit-input"
value={draft}
maxLength={REMEMBER_FACT_MAX_CHARS}
rows={2}
onChange={(e) => setDraft(e.target.value)}
className="text-sm"
/>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="min-h-11 sm:min-h-9"
onClick={() => {
setDraft(fact.text);
setEditing(false);
}}
>
{t("settings.ai.coachMemory.cancel")}
</Button>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
data-slot="settings-coach-memory-edit-save"
disabled={!trimmed || edit.isPending}
>
{edit.isPending ? (
<Loader2
className="size-3.5 animate-spin motion-reduce:animate-none"
aria-hidden
/>
) : null}
{t("settings.ai.saveCta")}
</Button>
</div>
</form>
) : (
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm break-words">{fact.text}</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.learnedPrefix", {
when: formatDateOrRelative(fact.createdAt, t),
})}
{sourceKey ? (
<span data-slot="settings-coach-memory-source">
<span aria-hidden="true"> · </span>
<span className="sr-only">, </span>
{t(sourceKey)}
</span>
) : null}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
data-slot="settings-coach-memory-edit"
aria-label={t(COACH_MEMORY_LIST_KEYS.edit)}
title={t(COACH_MEMORY_LIST_KEYS.edit)}
disabled={!isAuthenticated}
className="size-11 sm:size-9"
onClick={() => {
setDraft(fact.text);
setEditing(true);
}}
>
<Pencil className="size-4" aria-hidden />
</Button>
<ConfirmButton
slot="settings-coach-memory-forget"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.coachMemory.forget")}
title={t("settings.ai.coachMemory.forgetTitle")}
body={t("settings.ai.coachMemory.forgetBody")}
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
disabled={!isAuthenticated}
pending={forgetting}
onConfirm={onForget}
/>
</div>
</div>
)}
</li>
);
return data?.facts ?? [];
}
export function CoachMemorySection({
@@ -99,25 +255,11 @@ export function CoachMemorySection({
const { t } = useTranslations();
const queryClient = useQueryClient();
const query = useQuery({
queryKey: queryKeys.coachFacts(),
queryFn: fetchFacts,
enabled: isAuthenticated,
});
const query = useCoachFacts({ enabled: isAuthenticated });
const forgetOne = useMutation({
mutationKey: queryKeys.coachFacts(),
mutationFn: async (id: string) => {
await apiDelete(`/api/insights/coach/facts/${encodeURIComponent(id)}`);
return id;
},
onSuccess: () => {
toast.success(t("settings.ai.coachMemory.forgotToast"));
queryClient.invalidateQueries({ queryKey: queryKeys.coachFacts() });
},
onError: () => {
toast.error(t("settings.ai.coachMemory.forgotError"));
},
const forgetOne = useForgetCoachFact({
onSuccess: () => toast.success(t("settings.ai.coachMemory.forgotToast")),
onError: () => toast.error(t("settings.ai.coachMemory.forgotError")),
});
const forgetAll = useMutation({
@@ -144,9 +286,16 @@ export function CoachMemorySection({
// Group by category in the canonical category order so the panel
// reads the same way every render regardless of insertion order.
const grouped = useMemo(() => {
// A category the list does not know (an older extraction's) reads as
// context rather than vanishing.
const known = new Set<string>(FACT_CATEGORIES);
return FACT_CATEGORIES.map((category) => ({
category,
items: facts.filter((f) => f.category === category),
items: facts.filter((f) =>
known.has(f.category)
? f.category === category
: category === "context",
),
})).filter((g) => g.items.length > 0);
}, [facts]);
@@ -156,6 +305,8 @@ export function CoachMemorySection({
return (
<SettingsCard
as="section"
id="coach-memory"
className="scroll-mt-28"
aria-labelledby="settings-ai-coach-memory-title"
data-testid="settings-coach-memory-card"
>
@@ -193,42 +344,16 @@ export function CoachMemorySection({
</h3>
<ul className="space-y-2">
{group.items.map((fact) => (
<li
<FactRow
key={fact.id}
data-testid="settings-coach-memory-fact"
className="border-border bg-background flex items-start justify-between gap-3 rounded-lg border p-3"
>
<div className="min-w-0">
<p className="text-sm break-words">{fact.text}</p>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.ai.coachMemory.learnedPrefix", {
when: formatDateOrRelative(fact.createdAt, t),
})}
</p>
</div>
<ConfirmButton
slot="settings-coach-memory-forget"
variant="ghost"
size="sm"
className="min-h-11 sm:min-h-9"
ariaLabel={t("settings.ai.coachMemory.forgetAria")}
icon={<Trash2 className="size-4" aria-hidden />}
label={t("settings.ai.coachMemory.forget")}
title={t("settings.ai.coachMemory.forgetTitle")}
body={t("settings.ai.coachMemory.forgetBody")}
confirmLabel={t("settings.ai.coachMemory.forgetConfirm")}
disabled={!isAuthenticated}
// v1.11.2 — per-id pending: only the row being
// deleted disables/spins. `forgetOne.variables` holds
// the id passed to the in-flight `mutate()`, so a
// single shared mutation no longer greys out every
// other row's forget button during one delete.
pending={
forgetOne.isPending && forgetOne.variables === fact.id
}
onConfirm={() => forgetOne.mutate(fact.id)}
/>
</li>
fact={fact}
isAuthenticated={isAuthenticated}
// Per-id pending: only the row being deleted spins.
forgetting={
forgetOne.isPending && forgetOne.variables === fact.id
}
onForget={() => forgetOne.mutate(fact.id)}
/>
))}
</ul>
</div>
@@ -39,13 +39,18 @@ import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
import { useCoachPrefs, useSaveCoachPrefs } from "@/hooks/use-coach-prefs";
import { useAiCapability } from "@/hooks/use-ai-capability";
import {
CoachReasoningSelect,
reasoningStateOrDefault,
} from "@/components/insights/coach-panel/coach-reasoning-field";
import { useAiCapability, useCoachReasoning } from "@/hooks/use-ai-capability";
import { COACH_FOLLOW_UP_UI_KEYS } from "@/lib/ai/coach/dialog-keys";
import { HISTORY_REACH_DAYS, lookbackText } from "@/lib/ai/coach/history-reach";
import {
DEFAULT_COACH_CLUSTERS,
DEFAULT_COACH_PREFS,
coachDataClusterEnum,
coachReasoningLevel,
type CoachExcludeMetric,
type CoachPrefs,
type CoachTone,
@@ -106,6 +111,7 @@ export interface CoachPrefsSectionProps {
export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
const { t } = useTranslations();
const coach = useAiCapability("coach");
const reasoningState = reasoningStateOrDefault(useCoachReasoning());
const { data: persisted } = useCoachPrefs({ enabled: isAuthenticated });
@@ -252,6 +258,17 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
{/* v1.41 — how hard the Coach thinks; the same field sits in the
Coach's quick settings. */}
<CoachReasoningSelect
id="coach-prefs-reasoning"
value={coachReasoningLevel(draft)}
state={reasoningState}
onChange={(reasoning) =>
setDraft((prev) => ({ ...prev, reasoning }))
}
/>
{/* Data clusters + analysis window live on ONE owner: "What I
can see" behind the Coach's settings gear, which
`/coach?settings=data` opens directly. This card used to render
+1 -16
View File
@@ -14,12 +14,7 @@
* prose editor by design: the PATCH contract never accepts the encrypted
* free-text fields.
*/
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
@@ -67,22 +62,12 @@ async function fetchPlans(filter?: PlanFilter): Promise<CoachPlanDTO[]> {
export function useCoachPlans(opts?: {
filter?: PlanFilter;
enabled?: boolean;
/**
* Optional poll interval (ms), or TanStack's interval callback so a caller
* can bound its poll (the chat thread caps attempts — see
* `nextProposalPollInterval` in `plan-proposal-card.tsx`). Proposals land
* asynchronously (the memory-refresh worker runs after the turn); the
* management page reads once. TanStack pauses the interval in background
* tabs by default, so the poll never runs unwatched.
*/
refetchInterval?: UseQueryOptions<CoachPlanDTO[]>["refetchInterval"];
}) {
const filter = opts?.filter;
return useQuery({
queryKey: queryKeys.coachPlans(filterKey(filter)),
queryFn: () => fetchPlans(filter),
enabled: opts?.enabled ?? true,
refetchInterval: opts?.refetchInterval,
});
}
+14
View File
@@ -62,6 +62,20 @@ export const coachKeys = {
messageId,
"results",
] as const,
/**
* v1.41 — the owner-only model text of one assistant message's trail
* (`GET /api/insights/chat/{id}/messages/{messageId}/trail`), read when
* the person opens the trail. Nested under the conversation key like the
* tables.
*/
coachMessageTrail: (conversationId: string, messageId: string) =>
[
"coachConversation",
conversationId,
"messages",
messageId,
"trail",
] as const,
/**
* v1.21.2 (A3) — today's most notable derived signal, resolved into the
* Coach hero's pre-seeded relevance opener
@@ -28,7 +28,7 @@ import {
import { UNBOUNDED_REACH } from "@/lib/ai/coach/history-reach";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { toStep } from "@/lib/ai/coach/turn/steps";
import { describeStep } from "@/components/insights/coach-panel/turn-steps";
import { describeStep } from "@/components/insights/coach-panel/turn-activity";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { pluralKey } from "@/lib/i18n/plural";
import type { MeasurementType } from "@/generated/prisma/client";