Rebuild the Coach thinking row as one calm line

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

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

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

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

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

No files matched your search

@@ -159,6 +159,18 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
expect(res.status).toBe(403);
});
it("refuses the Coach chat with a code a client can name", async () => {
// The chat POST is not on the allowlist. The refusal carries a stable
// code, so the Coach says "not in the demo" instead of reading the 403
// as a provider outage.
const res = proxy(makeRequest("/api/insights/chat", "POST"));
expect(res.status).toBe(403);
const body = (await res.json()) as {
meta?: { demo?: boolean; errorCode?: string };
};
expect(body.meta).toEqual({ demo: true, errorCode: "demo.readOnly" });
});
it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => {
// The allowlist pins method per path; admitting the widgets PUT
// must not open DELETE (which wipes the user's dashboard layout).
+54
View File
@@ -1032,6 +1032,60 @@
}
}
/* ── Live status text ────────────────────────────────────────────────
* `text-shimmer`: a band of foreground light sweeps through muted text, for a
* line that says something is still in progress (the Coach's thinking row).
* The gradient is clipped to the glyphs; the band travels left to right once
* every two seconds. Under reduced motion the text is plain muted text.
*
* `waiting-dot`: one of three trailing dots that fade in turn (the caller
* staggers them by 0.2 s). Under reduced motion they stand still at full
* opacity, which the global backstop below would not give them: it pins the
* last frame, and the last frame is dim. */
@keyframes text-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0% 0;
}
}
@utility text-shimmer {
background-image: linear-gradient(
90deg,
var(--muted-foreground) 40%,
var(--foreground) 50%,
var(--muted-foreground) 60%
);
background-size: 250% 100%;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: text-shimmer 2s linear infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
background-image: none;
color: var(--muted-foreground);
}
}
@keyframes waiting-dot {
0%,
100% {
opacity: 0.2;
}
50% {
opacity: 1;
}
}
@utility waiting-dot {
animation: waiting-dot 1.4s ease-in-out infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
opacity: 1;
}
}
/* ── Global reduced-motion backstop ──────────────────────────────────
* Defensive catch-all so any animation/transition we forget to gate
* per-component still collapses to near-instant when the user has asked
@@ -1,143 +0,0 @@
/**
* v1.41 — the tables a turn has read so far, one quiet line each, before the
* first token; taken down when the turn is blocked.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import type { CoachResultTable } from "@/lib/ai/coach/types";
vi.mock("../coach-results", () => ({ CoachResultView: () => null }));
import {
CoachInterimResults,
interimLabel,
interimPreviews,
interimReadingCount,
sparklinePoints,
} from "../interim-results";
import {
upsertActivity,
upsertResult,
withoutWithheldResults,
} from "../use-coach";
function bp(ref: string): CoachResultTable {
return {
ref,
source: {
tool: "get_metric_table",
domain: "bp",
window: "last30days",
period: "current",
granularity: "day",
},
shape: "timeSeries",
titleKey: "coach.result.title.byDay",
title: "Blood pressure by day",
rowCount: 3,
chartKind: "line",
displayed: false,
columns: [
{ key: "day", kind: "period", labelKey: "k", label: "Day" },
{ key: "systolic", kind: "number", labelKey: "k", label: "Systolic" },
{ key: "readings", kind: "count", labelKey: "k", label: "Readings" },
],
rows: [
["2026-09-01", 128, 2],
["2026-09-02", 124, 3],
["2026-09-03", 131, 1],
],
truncated: false,
chart: { kind: "line", x: "day", series: ["systolic"] },
};
}
function render(node: React.ReactNode) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("interim previews", () => {
it("one line per interim table: title, window, readings, a sparkline", () => {
const html = render(
<CoachInterimResults
results={[bp("r1"), bp("r2")]}
interimRefs={["r1"]}
/>,
);
expect(html.match(/data-slot="coach-interim-result"/g)).toHaveLength(1);
expect(html).toContain("Blood pressure by day, last 30 days, 6 readings");
expect(html).toContain('stroke="var(--chart-1)"');
// Closed until tapped, and the arrival fades in only with motion allowed.
expect(html).toContain('aria-expanded="false"');
expect(html).toContain("motion-safe:animate-in");
});
it("shows at most three, and nothing without interim tables", () => {
const four = ["r1", "r2", "r3", "r4"];
expect(interimPreviews(four.map(bp), four)).toHaveLength(3);
expect(
render(<CoachInterimResults results={[bp("r1")]} interimRefs={[]} />),
).toBe("");
});
it("counts readings from the count column, else rows", () => {
expect(interimReadingCount(bp("r1"))).toBe(6);
const noCount = { ...bp("r1"), columns: bp("r1").columns.slice(0, 2) };
expect(interimReadingCount(noCount)).toBe(3);
// The title is the server's, already in the request locale.
expect(interimLabel(bp("r1"), getServerTranslator("de").t, "de")).toBe(
"Blood pressure by day, letzte 30 Tage, 6 Werte",
);
});
it("draws the sparkline inside its box", () => {
const points = sparklinePoints([1, 3, 2], 48, 16).split(" ");
expect(points).toHaveLength(3);
for (const point of points) {
const [x, y] = point.split(",").map(Number);
expect(x).toBeGreaterThanOrEqual(0);
expect(x).toBeLessThanOrEqual(48);
expect(y).toBeGreaterThanOrEqual(0);
expect(y).toBeLessThanOrEqual(16);
}
});
});
describe("the stream's bookkeeping", () => {
it("replaces an interim table with its final frame by ref", () => {
const final = { ...bp("r1"), displayed: true };
const next = upsertResult([bp("r1"), bp("r2")], final);
expect(next).toHaveLength(2);
expect(next[0].displayed).toBe(true);
});
it("takes the interim tables down when the turn was blocked", () => {
expect(
withoutWithheldResults([bp("r1"), bp("r2"), bp("r3")], ["r1", "r3"]).map(
(r) => r.ref,
),
).toEqual(["r2"]);
});
it("keeps an entry's title when a later frame leaves it out", () => {
const first = {
id: "a1",
phase: "thinking" as const,
status: "running" as const,
round: 1,
labelKey: "k",
label: "Thinking…",
title: "Comparing months",
};
const done = { ...first, status: "done" as const, durationMs: 900 };
delete (done as { title?: string }).title;
const [entry] = upsertActivity([first], done);
expect(entry.title).toBe("Comparing months");
expect(entry.status).toBe("done");
});
});
@@ -101,12 +101,9 @@ function streaming(
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -255,10 +252,10 @@ describe("<MessageThread>", () => {
);
});
it("folds an older message's provenance into the areas header, never a raw key", () => {
it("folds an older message's provenance into the thought-process row, never a raw key", () => {
const html = render(<MessageThread conversation={baseConversation} />);
expect(html).toContain('data-slot="coach-turn-steps-done"');
expect(html).toContain("Looked at 1 area");
expect(html).toContain("Thought process · 1 step");
expect(html).not.toContain('data-slot="coach-source-chips"');
expect(html).not.toContain("insights.coach.metric.");
expect(html).not.toContain("n=26");
@@ -481,16 +478,16 @@ describe("<MessageThread>", () => {
expect(html).not.toContain('data-slot="coach-evidence"');
expect(html).not.toContain("What I&#x27;m looking at");
expect(html).not.toContain("138 mmHg");
// The areas the answer drew on stay reachable from the header.
expect(html).toContain("Looked at 1 area");
// The areas the answer drew on stay reachable from the row.
expect(html).toContain("Thought process · 1 step");
});
it("names the areas header in German under 'de'", () => {
it("names the thought-process row in German under 'de'", () => {
const html = render(
<MessageThread conversation={baseConversation} />,
"de",
);
expect(html).toContain("1 Bereich angesehen");
expect(html).toContain("Denkprozess · 1 Schritt");
expect(html).not.toContain("Worauf bezieht sich das?");
});
@@ -797,6 +794,12 @@ describe("errorCodeToI18nKey", () => {
// v1.4.43 QoL (M6) — `coach.network` no longer collapses to the
// generic provider copy; the user needs the actionable offline hint.
it("maps the demo's refusal to its own honest line, not the outage copy", () => {
expect(errorCodeToI18nKey("demo.readOnly")).toBe(
"insights.coach.errorDemo",
);
});
it("maps coach.network to the dedicated offline key", () => {
expect(errorCodeToI18nKey("coach.network")).toBe(
"insights.coach.errorNetwork",
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -8,18 +10,18 @@ import { pluralKey } from "@/lib/i18n/plural";
import type { Locale } from "@/lib/i18n/config";
import type { CoachActivity, CoachStep } from "@/lib/ai/coach/types";
import { Brain, ChartScatter, Database, Lightbulb, Table2 } from "lucide-react";
import {
CoachTurnActivity,
CoachTurnStepList,
activityLineLabel,
activitySeconds,
activitySummary,
countLookups,
countSources,
currentActivity,
currentStep,
describeStep,
inProgressLabel,
legacyAreaLabels,
openAfter,
stepIcon,
stepLabel,
} from "../turn-activity";
@@ -218,7 +220,133 @@ const SETTLED = [
ANSWER_DONE,
];
describe("the running line", () => {
/** The row's element (button or plain row) and everything inside it. */
function rowHtml(html: string): string {
const match = html.match(
/<(button|div)[^>]*data-slot="coach-turn-steps-(?:toggle|row)"[\s\S]*?<\/\1>/,
);
return match?.[0] ?? "";
}
/** The visible text of a fragment, tags removed. */
function textOf(html: string): string {
let text = html.replace(/<span class="sr-only select-none">, <\/span>/g, "");
for (let prev = ""; prev !== text;) {
prev = text;
text = text.replace(/<[^>]*>/g, "");
}
return text;
}
describe("one row while the Coach works", () => {
it("walks thinking → fetching → summarising on one shimmering row", () => {
const frames = [
[THINKING_RUNNING],
[
{ ...THINKING_RUNNING, status: "done" as const },
{ ...FETCH_BP, status: "running" as const },
],
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
];
const htmls = frames.map((activity) =>
render(<CoachTurnActivity activity={activity} steps={[]} active />),
);
expect(htmls[0]).toContain('data-text="Thinking…"');
expect(htmls[1]).toContain(
'data-text="Fetching blood pressure, last 90 days…"',
);
expect(htmls[2]).toContain('data-text="Summarising 214 readings…"');
for (const html of htmls) {
// Exactly one row, and nothing under it while closed.
expect(
html.match(/data-slot="coach-turn-steps-(toggle|row)"/g),
).toHaveLength(1);
expect(html).toContain('data-state="running"');
expect(html).not.toContain("coach-interim");
expect(html).not.toContain('<svg viewBox="0 0 48 16"');
// The words shimmer; the trailing dots fade on their own.
expect(html).toMatch(/<span class="text-shimmer">[^<]+<\/span>/);
expect(html).toContain('data-slot="waiting-dots"');
expect(html.match(/class="waiting-dot /g)).toHaveLength(3);
expect(html).toContain("[animation-delay:0.2s]");
expect(html).toContain("[animation-delay:0.4s]");
// No clock while it runs, in any form.
expect(html).not.toMatch(/\d+\s?s</);
expect(html).not.toContain("coach-turn-steps-seconds");
expect(html).not.toContain("tabular-nums");
}
});
it("reads brain, text, chevron, in the answer's text size and the muted tone", () => {
const html = render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
);
const row = rowHtml(html);
expect(row.startsWith("<button")).toBe(true);
const order = [
row.indexOf("lucide-brain"),
row.indexOf('data-slot="coach-turn-steps-active"'),
row.indexOf('data-slot="coach-turn-steps-chevron"'),
];
expect(order.every((at) => at > -1)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
expect(row).toContain("lucide-chevron-down");
expect(row).toMatch(/class="[^"]*\bw-fit\b/);
expect(row).toMatch(/class="[^"]*\btext-muted-foreground\b/);
expect(row).toMatch(/class="[^"]*\bhover:text-foreground\b/);
expect(row).toContain("focus-visible:ring-input-focus");
expect(row).not.toMatch(/ring-ring|ring-primary|text-primary/);
expect(html).toMatch(
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-sm\b/,
);
expect(html).not.toMatch(
/data-slot="coach-turn-steps"[^>]*class="[^"]*\btext-xs\b/,
);
// A stable name while the text keeps changing.
expect(row).toContain('aria-label="Show what the Coach looked at"');
expect(row).toContain('aria-expanded="false"');
});
it("says Thinking… before the first frame, with no chevron and nothing to open", () => {
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
expect(html).toContain('data-text="Thinking…"');
expect(html).toContain("text-shimmer");
expect(html).not.toContain("coach-turn-steps-chevron");
expect(html).not.toContain("aria-expanded");
expect(rowHtml(html).startsWith("<div")).toBe(true);
});
it("names a reasoning title as still going, and a step-only server's step", () => {
expect(
render(
<CoachTurnActivity
activity={[{ ...THINKING_RUNNING, title: "Checking the trend" }]}
steps={[]}
active
/>,
),
).toContain('data-text="Checking the trend…"');
expect(
render(
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
),
).toContain('data-text="Checking: Lab results, last 12 months…"');
});
it("reads in German", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[]} active />,
"de",
);
expect(html).toContain('data-text="Denkt nach…"');
});
});
describe("the labels", () => {
it("names the latest running entry, else the latest one", () => {
expect(currentActivity([THINKING_DONE, FETCH_BP, DIGEST_RUNNING])?.id).toBe(
"a4",
@@ -242,59 +370,134 @@ describe("the running line", () => {
).toBe("Fetching blood pressure, last 90 days…");
});
it("walks thinking → fetching → summarising, one quiet line", () => {
const frames = [
[THINKING_RUNNING],
[
{ ...THINKING_RUNNING, status: "done" as const },
{ ...FETCH_BP, status: "running" as const },
],
[THINKING_DONE, FETCH_BP, DIGEST_RUNNING],
];
const lines = frames.map((activity) =>
render(
<CoachTurnActivity
activity={activity}
steps={[]}
active
startedAt={Date.now() - 12_000}
/>,
),
it("ends a running line in exactly one ellipsis", () => {
expect(inProgressLabel("Writing the answer")).toBe("Writing the answer…");
expect(inProgressLabel("Writing the answer…")).toBe("Writing the answer…");
expect(inProgressLabel("Writing the answer...")).toBe(
"Writing the answer…",
);
expect(lines[0]).toContain(">Thinking…<");
expect(lines[1]).toContain(">Fetching blood pressure, last 90 days…<");
expect(lines[2]).toContain(">Summarising 214 readings…<");
for (const html of lines) {
expect(html.match(/data-slot="coach-turn-steps-toggle"/g)).toHaveLength(
1,
);
expect(html).toContain('data-state="running"');
expect(html).toContain('aria-label="Show what the Coach looked at"');
// The seconds so far follow the text directly, in the line's own
// muted tone, with the summary's spacing ("12 s"), never pushed to
// the far edge of the column.
expect(html).toMatch(
/data-slot="coach-turn-steps-active"[^>]*>[^<]*<\/span><span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>1[23]\u00a0s</,
);
const seconds = html.match(
/<span[^>]*data-slot="coach-turn-steps-seconds"[^>]*>/,
)?.[0];
expect(seconds).toContain("tabular-nums");
expect(seconds).not.toMatch(/\bml-auto\b|text-foreground/);
});
it("picks an icon by the kind of work", () => {
expect(stepIcon("thinking", undefined)).toBe(Lightbulb);
expect(stepIcon("fetch", "get_metric_series")).toBe(Database);
expect(stepIcon("fetch", "get_metric_table")).toBe(Table2);
expect(stepIcon("fetch", "get_correlations")).toBe(ChartScatter);
expect(stepIcon(null, "get_sleep")).toBe(Database);
// The brain belongs to the row, never to a step.
for (const phase of [
"thinking",
"memory",
"fetch",
"digest",
"checkpoint",
"remember",
"plan",
"asking",
"stop",
"answer",
] as const) {
expect(stepIcon(phase, undefined)).not.toBe(Brain);
}
});
});
it("says Thinking… before the first frame arrives", () => {
const html = render(<CoachTurnActivity activity={[]} steps={[]} active />);
expect(html).toContain(">Thinking…<");
expect(html).toContain('aria-expanded="false"');
describe("after the answer", () => {
it("settles into the thought process and its step count, without shimmer", () => {
const html = render(
<CoachTurnActivity
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
/>,
);
expect(html).toContain('data-state="done"');
expect(html).toMatch(
/data-slot="coach-turn-steps-done"[^>]*>Thought process · 5 steps</,
);
expect(html).not.toContain("text-shimmer");
expect(html).not.toContain("waiting-dot");
expect(html).toContain('data-slot="coach-turn-steps-chevron"');
expect(html).not.toMatch(/\d+\s?s</);
// The settled row names itself by its text.
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
});
it("falls back to the step label on a server that sends only steps", () => {
it("counts in the reader's language, with proper plurals", () => {
const de = render(
<CoachTurnActivity activity={[ANSWER_DONE]} steps={[]} active={false} />,
"de",
);
expect(de).toContain("Denkprozess · 1 Schritt<");
const pl = render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, ANSWER_DONE]}
steps={[]}
active={false}
/>,
"pl",
);
expect(pl).toContain("Tok myślenia · 3 kroki<");
});
it("an older message counts its steps, or the areas it drew on", () => {
expect(
render(
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
),
).toContain("Thought process · 2 steps<");
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp"]}
/>,
),
).toContain("Thought process · 1 step<");
});
it("says an answer is needed while a question waits, without shimmer", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[BP, LABS_RUNNING]} active />,
<CoachTurnActivity
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
);
expect(html).toContain('data-state="awaiting"');
expect(html).toMatch(
/data-slot="coach-turn-steps-done"[^>]*>Answer needed</,
);
expect(html).not.toContain("text-shimmer");
expect(
render(
<CoachTurnActivity
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
"de",
),
).toContain(">Antwort nötig<");
});
it("renders nothing for a settled message with nothing to show", () => {
expect(
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
).not.toContain("coach-turn-steps");
});
it("carries a polite status region that starts empty", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
);
expect(html).toContain("Checking: Lab results, last 12 months");
});
});
@@ -320,6 +523,7 @@ describe("nothing opens by itself", () => {
activity={[THINKING_DONE, ASKING]}
steps={[]}
active={false}
awaitingAnswer
/>,
],
[
@@ -370,109 +574,78 @@ describe("nothing opens by itself", () => {
for (const [name, node] of states) {
it(`is closed when ${name}`, () => {
const html = render(node);
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain('aria-expanded="true"');
expect(html).not.toContain('rotate-180"');
expect(html).not.toContain('data-slot="coach-turn-steps-panel"');
expect(html).not.toContain('data-slot="coach-turn-step-detail"');
expect(html).not.toContain("The second half of the month runs higher.");
});
}
it("a row opened while the turn ran closes when it ends, and nothing opens it", () => {
// Opened during the run, the turn ends: closed.
expect(openAfter({ open: true, wasActive: true, active: false })).toBe(
false,
);
// Closed stays closed through every move.
for (const [wasActive, active] of [
[true, false],
[false, true],
[true, true],
[false, false],
] as const) {
expect(openAfter({ open: false, wasActive, active })).toBe(false);
}
// A tap on a settled row is the person's, and stays.
expect(openAfter({ open: true, wasActive: false, active: false })).toBe(
true,
);
});
});
describe("after the answer", () => {
const { t } = translators("en");
it("collapses to one calm summary of lookups and seconds", () => {
describe("tokens and motion", () => {
it("uses theme tokens only, and every motion is guarded or turned off by reduced motion", () => {
const html = render(
<CoachTurnActivity
activity={SETTLED}
steps={[BP, SLEEP]}
active={false}
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
);
expect(html).toContain('data-state="done"');
expect(html).toContain(
'data-slot="coach-turn-steps-done" class="min-w-0 truncate">Thought it through, 2 lookups, 12 s<',
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
expect(html).not.toMatch(/text-muted-foreground\/\d/);
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
m[1].split(/\s+/),
);
// No spinner, no running seconds, no stable-name override once settled.
expect(html).not.toContain("coach-turn-steps-spinner");
expect(html).not.toContain("coach-turn-steps-seconds");
expect(html).not.toContain('aria-label="Show what the Coach looked at"');
for (const cls of classes) {
if (cls.startsWith("transition") || cls.startsWith("animate-")) {
throw new Error(`unguarded motion: ${cls}`);
}
}
// A 44 px tap target on phones.
expect(html).toContain("min-h-11");
});
it("counts fetches as lookups and runs a round's fetches side by side", () => {
expect(countLookups(SETTLED)).toBe(2);
// 4 s thinking + max(0.9, 1.4) s fetching + 0.6 s digest + 6 s answer.
expect(activitySeconds(SETTLED)).toBe(12);
expect(activitySeconds([THINKING_RUNNING])).toBeNull();
});
it("uses the live turn's clock when the trail has no durations", () => {
expect(
activitySummary({
activity: [{ ...ANSWER_DONE, durationMs: undefined }],
startedAt: 1_000,
endedAt: 19_000,
t,
locale: "en",
}),
).toBe("Thought it through, 18 s");
});
it("reads in German", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
"de",
it("the shimmer and the dots stop under reduced motion, in the stylesheet", () => {
const css = readFileSync(
join(__dirname, "../../../../app/globals.css"),
"utf8",
);
expect(html).toContain("Nachgedacht, 2 Abfragen, 12 s");
});
it("an older message still folds into its sources", () => {
const html = render(
<CoachTurnActivity activity={[]} steps={[BP, SLEEP]} active={false} />,
const shimmer =
css.match(/@utility text-shimmer \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(shimmer).toContain("background-clip: text");
expect(shimmer).toContain("background-size: 250% 100%");
expect(shimmer).toContain("animation: text-shimmer 2s linear infinite");
expect(shimmer).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*background-image: none;\s*color: var\(--muted-foreground\);/,
);
expect(html).toContain("Looked at 2 sources");
expect(
countSources([BP, { ...BP, id: "s9", period: "previous" }, SLEEP]),
).toBe(2);
});
it("an answer saved before steps folds into its areas", () => {
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp", "sleep"]}
/>,
),
).toContain("Looked at 2 areas");
expect(
render(
<CoachTurnActivity
activity={[]}
steps={[]}
active={false}
areas={["bp"]}
/>,
"de",
),
).toContain("1 Bereich angesehen");
});
it("renders nothing for a settled message with nothing to show", () => {
expect(
render(<CoachTurnActivity activity={[]} steps={[]} active={false} />),
).not.toContain("coach-turn-steps");
});
it("carries a polite status region that starts empty", () => {
const html = render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
expect(css).toMatch(
/@keyframes text-shimmer \{\s*from \{\s*background-position: 100% 0;\s*\}\s*to \{\s*background-position: 0% 0;/,
);
expect(html).toMatch(
/<span role="status" aria-live="polite" aria-atomic="true" class="sr-only"><\/span>/,
const dot = css.match(/@utility waiting-dot \{[\s\S]*?\n\}/)?.[0] ?? "";
expect(dot).toContain("animation: waiting-dot 1.4s ease-in-out infinite");
expect(dot).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*animation: none;\s*opacity: 1;/,
);
});
});
@@ -485,72 +658,6 @@ describe("currentStep", () => {
});
});
describe("tokens and motion", () => {
it("uses theme tokens only, and every motion stops under reduced motion", () => {
const html =
render(
<CoachTurnActivity
activity={[THINKING_DONE, FETCH_BP, DIGEST_RUNNING]}
steps={[BP]}
active
/>,
) +
render(
<CoachTurnStepList
steps={[
BP,
SLEEP,
LABS_RUNNING,
{
...SLEEP,
id: "s4",
status: "failed",
reason: "retrieval_failed",
},
]}
active
/>,
);
expect(html).not.toMatch(/#[0-9a-f]{3,8}\b|dracula-|\[#|oklch\(/i);
expect(html).not.toMatch(/text-muted-foreground\/\d/);
const classes = [...html.matchAll(/class="([^"]*)"/g)].flatMap((m) =>
m[1].split(/\s+/),
);
for (const cls of classes) {
if (cls.startsWith("transition")) {
throw new Error(`unguarded transition: ${cls}`);
}
}
expect(html).toContain("animate-spin motion-reduce:animate-none");
// A 44 px tap target on phones.
expect(html).toContain("min-h-11");
});
});
describe("<CoachTurnStepList>", () => {
it("one row per step: domain · window · count or reason, with its status", () => {
const html = render(
<CoachTurnStepList steps={[BP, SLEEP, LABS_RUNNING]} active />,
);
expect(html).toContain('aria-label="What the Coach looked at"');
expect(html.match(/data-slot="coach-turn-step"/g)).toHaveLength(3);
expect(html).toContain('data-status="done"');
expect(html).toContain('data-status="empty"');
expect(html).toContain('data-status="running"');
let text = html.replace(
/<span class="sr-only select-none">, <\/span>/g,
"",
);
for (let prev = ""; prev !== text;) {
prev = text;
text = text.replace(/<[^>]*>/g, "");
}
expect(text).toContain("Blood pressure · last 90 days · 142 readings");
expect(text).toContain("Sleep · last 30 days · no readings");
expect(text).toContain("Lab results · last 12 months");
});
});
describe("legacyAreaLabels", () => {
const { t } = translators("en");
@@ -563,3 +670,22 @@ describe("legacyAreaLabels", () => {
).toEqual(["Blood pressure", "Sleep"]);
});
});
describe("the visible row text", () => {
it("is the status alone", () => {
expect(
textOf(
rowHtml(
render(
<CoachTurnActivity activity={SETTLED} steps={[]} active={false} />,
),
),
),
).toBe("Thought process · 5 steps");
expect(
textOf(
rowHtml(render(<CoachTurnActivity activity={[]} steps={[]} active />)),
),
).toBe("Thinking...");
});
});
@@ -56,12 +56,9 @@ function streaming(
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { useTranslations } from "@/lib/i18n/context";
import { useAuth } from "@/hooks/use-auth";
import { DEMO_READ_ONLY_CODE } from "@/lib/demo-refusal";
import {
parseChartTokens,
tokenToMetric,
@@ -20,7 +21,6 @@ import { ReminderSuggestionCard } from "./reminder-suggestion-card";
import { SuggestedActionCard } from "./suggested-action-card";
import { StreamedProse } from "./streamed-prose";
import { CoachTurnActivity } from "./turn-activity";
import { CoachInterimResults } from "./interim-results";
import {
CoachResults,
countResultsInSection,
@@ -100,6 +100,10 @@ export function errorCodeToI18nKey(code: string): string {
return "insights.coach.errorNoProvider";
case "module.disabled":
return "insights.coach.errorCoachOff";
// A demo deploy refuses every write, the chat POST included. That is
// the demo's nature, not an outage, and the line says so.
case DEMO_READ_ONLY_CODE:
return "insights.coach.errorDemo";
default:
// The operator switched the Coach (or all AI) off on this server.
if (code.startsWith("assistant.disabled.")) {
@@ -337,11 +341,6 @@ interface ChatBubbleProps {
* its metadata on `metricSource.activity`; the bubble falls back to that.
*/
activity?: CoachActivity[];
/** v1.41 — the refs of the tables that arrived while the turn ran. */
interimRefs?: string[];
/** v1.41 — when the live turn started and ended on this device. */
startedAt?: number | null;
endedAt?: number | null;
/** v1.41 — the live `memoryNote` frame, with the fact's words. */
memoryNote?: CoachMemoryNoteFrame | null;
}
@@ -416,9 +415,6 @@ export function areChatBubblePropsEqual(
prev.onFollowUp === next.onFollowUp &&
prev.replies === next.replies &&
prev.activity === next.activity &&
prev.interimRefs === next.interimRefs &&
prev.startedAt === next.startedAt &&
prev.endedAt === next.endedAt &&
prev.memoryNote === next.memoryNote &&
// onRegenerate is a per-render closure — compare only whether it is present.
(prev.onRegenerate === undefined) === (next.onRegenerate === undefined)
@@ -449,9 +445,6 @@ function ChatBubbleImpl({
onFollowUp,
replies,
activity,
interimRefs,
startedAt,
endedAt,
memoryNote,
}: ChatBubbleProps) {
const { t } = useTranslations();
@@ -645,18 +638,22 @@ function ChatBubbleImpl({
data-slot="coach-answer-column"
className="flex w-full max-w-full min-w-0 flex-col items-start gap-2 sm:max-w-[calc(80%-2.625rem)]"
>
{/* v1.41 — what the Coach is doing, one quiet line at the top of the
answer: live from the activity frames while the turn runs, one
calm summary once it is done. Nothing opens by itself; a tap on
the line opens the trail, which also says how the answer was
worked out and holds the tables the answer read. A message saved
before v1.41 falls back to its steps, or the areas it drew on. */}
{/* v1.41 — what the Coach is doing, one quiet row at the top of the
answer and the only thing in the column until the answer starts:
live from the activity frames while the turn runs, "Thought
process · N steps" once it is done, "Answer needed" while a
question waits. Nothing opens by itself; a tap on the row opens
the steps, which also say how the answer was worked out and hold
the tables the answer read. A message saved before v1.41 falls
back to its steps, or the areas it drew on. */}
<CoachTurnActivity
activity={activity ?? metricSource?.activity ?? []}
steps={steps ?? metricSource?.steps ?? []}
active={!!inProgress}
startedAt={startedAt}
endedAt={endedAt}
awaitingAnswer={
!inProgress &&
!!replies?.replies.some((reply) => reply.kind === "clarification")
}
areas={metricSource?.metrics}
method={inProgress || errorCode ? null : method}
conversationId={conversationId}
@@ -673,11 +670,6 @@ function ChatBubbleImpl({
) : null
}
/>
{/* v1.41 — the tables read so far, one line each, while the turn
still runs. They give way to the answer's own charts. */}
{inProgress && results && interimRefs && interimRefs.length > 0 ? (
<CoachInterimResults results={results} interimRefs={interimRefs} />
) : null}
{/* The prose bubble, once there is prose or an error. While the turn
runs without prose the status line above says what is happening;
the bubble arrives with the first token. */}
@@ -124,7 +124,7 @@ function displayedByRef(
/**
* The live tables with `displayed` taken from the provenance where it names
* them: an interim table is sent before the answer decides.
* them: a table read while the turn ran is sent before the answer decides.
*/
export function liveWithProvenance<
T extends Pick<CoachResultMeta, "ref" | "displayed">,
@@ -265,7 +265,7 @@ export function CoachResults({
type ResultView = "chart" | "table";
export function CoachResultView({
function CoachResultView({
result,
chartFirst,
}: {
@@ -1,191 +0,0 @@
"use client";
/**
* v1.41 — the tables a turn has read so far, while it still runs.
*
* Under the status line, one quiet line per table as soon as the call that
* read it settles: its title, window and how many readings it holds, with a
* small sparkline in the first chart colour and no axes. At most three. A
* tap opens the full chart (or table) right there; nothing opens by itself.
* Once the answer settles the previews go: the tables the answer points at
* become its charts, the rest sit under "Data used" in the closed trail.
*/
import { useId, useState } from "react";
import { ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import type { Locale } from "@/lib/i18n/config";
import {
coachWindowLabelKey,
interimLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import type { CoachResultTable } from "@/lib/ai/coach/types";
import { CoachResultView } from "./coach-results";
/** At most this many previews show, whatever the turn has read. */
export const MAX_INTERIM_PREVIEWS = 3;
type Translate = (
key: string,
params?: Record<string, string | number>,
) => string;
/** The interim tables to preview, in the order they arrived. */
export function interimPreviews(
results: readonly CoachResultTable[],
interimRefs: readonly string[],
): CoachResultTable[] {
const refs = new Set(interimRefs);
return results
.filter((result) => refs.has(result.ref))
.slice(0, MAX_INTERIM_PREVIEWS);
}
/**
* How many readings a table holds: the sum of its count column when it has
* one (readings per day or week), else its rows.
*/
export function interimReadingCount(result: CoachResultTable): number {
const countIndex = result.columns.findIndex((c) => c.kind === "count");
if (countIndex === -1) return result.rowCount;
let sum = 0;
for (const row of result.rows) {
const cell = row[countIndex];
if (typeof cell === "number") sum += cell;
}
return sum > 0 ? sum : result.rowCount;
}
/** "Blood pressure by day, last 30 days, 214 readings". */
export function interimLabel(
result: CoachResultTable,
t: Translate,
locale: Locale,
): string {
const count = interimReadingCount(result);
return t(interimLabelKey(count, locale), {
title: result.title,
window: t(coachWindowLabelKey(result.source.window)),
count,
});
}
/** The values the sparkline draws: the first charted series, in row order. */
export function sparklineValues(result: CoachResultTable): number[] {
const spec = result.chart;
const key =
spec?.kind === "line" || spec?.kind === "bar"
? spec.series[0]
: spec?.kind === "compare"
? spec.a
: result.columns.find((c) => c.kind === "number")?.key;
const index = result.columns.findIndex((c) => c.key === key);
if (index === -1) return [];
return result.rows
.map((row) => row[index])
.filter((cell): cell is number => typeof cell === "number");
}
/** An SVG polyline path through the values, inside `width × height`. */
export function sparklinePoints(
values: readonly number[],
width: number,
height: number,
): string {
if (values.length === 0) return "";
const min = Math.min(...values);
const max = Math.max(...values);
const span = max - min || 1;
const step = values.length > 1 ? width / (values.length - 1) : 0;
return values
.map((value, i) => {
const x = values.length > 1 ? i * step : width / 2;
const y = height - 1 - ((value - min) / span) * (height - 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
function Sparkline({ values }: { values: number[] }) {
if (values.length < 2) return null;
return (
<svg
aria-hidden="true"
data-slot="coach-interim-sparkline"
viewBox="0 0 48 16"
className="h-4 w-12 shrink-0 overflow-visible"
preserveAspectRatio="none"
>
<polyline
points={sparklinePoints(values, 48, 16)}
fill="none"
stroke="var(--chart-1)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}
function InterimPreview({ result }: { result: CoachResultTable }) {
const { t, locale } = useTranslations();
const detailId = useId();
const [open, setOpen] = useState(false);
return (
<li
data-slot="coach-interim-result"
data-ref={result.ref}
className="motion-safe:animate-in motion-safe:fade-in-0 motion-safe:slide-in-from-top-1 flex min-w-0 flex-col gap-2 motion-safe:duration-300"
>
<button
type="button"
aria-expanded={open}
aria-controls={open ? detailId : undefined}
onClick={() => setOpen(!open)}
className="text-muted-foreground hover:text-foreground focus-visible:ring-input-focus -my-1 flex min-h-11 w-full min-w-0 items-center gap-1.5 rounded text-left text-xs outline-none focus-visible:ring-2 sm:my-0 sm:min-h-6"
>
<ChevronRight
aria-hidden="true"
className={cn(
"size-3 shrink-0 motion-safe:transition-transform",
open && "rotate-90",
)}
/>
<span className="min-w-0 truncate">
{interimLabel(result, t, locale)}
</span>
<Sparkline values={sparklineValues(result)} />
</button>
{open ? (
<div id={detailId} className="w-full min-w-0">
<CoachResultView result={result} chartFirst />
</div>
) : null}
</li>
);
}
export function CoachInterimResults({
results,
interimRefs,
}: {
results: readonly CoachResultTable[];
interimRefs: readonly string[];
}) {
const previews = interimPreviews(results, interimRefs);
if (previews.length === 0) return null;
return (
<ul
data-slot="coach-interim-results"
className="flex w-full min-w-0 flex-col gap-0.5 self-stretch"
>
{previews.map((result) => (
<InterimPreview key={result.ref} result={result} />
))}
</ul>
);
}
@@ -716,15 +716,13 @@ export function MessageThread({
? { results: streaming.results }
: {})}
// v1.41 — and its live trail (with the titles and texts
// the persisted copy only has behind `…/trail`), its clock
// and the words of the fact it kept.
// the persisted copy only has behind `…/trail`) and the
// words of the fact it kept.
{...(m.id === streaming?.messageId
? {
...(streaming.activity.length > 0
? { activity: streaming.activity }
: {}),
startedAt: streaming.startedAt,
endedAt: streaming.endedAt,
memoryNote: streaming.memoryNote,
}
: {})}
@@ -782,9 +780,6 @@ export function MessageThread({
steps={streaming.steps}
results={streaming.results}
activity={streaming.activity}
interimRefs={streaming.interimRefs}
startedAt={streaming.startedAt}
endedAt={streaming.endedAt}
memoryNote={streaming.memoryNote}
providerType={streaming.inProgress ? "streaming" : null}
inProgress={streaming.inProgress}
File diff suppressed because it is too large. Load diff
@@ -568,25 +568,12 @@ export interface CoachStreamingMessage {
* `metricSource.activity` and the model text behind `…/trail`.
*/
activity: CoachActivity[];
/**
* v1.41 — the refs of the tables that arrived as `interim` while the turn
* still ran. They show as one-line previews until the answer settles, and
* are taken down again when `done` says `withheldResults`.
*/
interimRefs: string[];
/** v1.41 — the fact this turn saved or proposes (`memoryNote` frame). */
memoryNote: CoachMemoryNote | null;
/** v1.41 — the plan this turn proposes (`planProposal` frame). */
planProposal: CoachPlanProposal | null;
/** v1.41 — why the answer was forced, when it was (`done.stop`). */
stop: CoachStop | null;
/**
* v1.41 — when the turn started on this device (epoch ms), for the live
* seconds on the status line. Null when no turn has run.
*/
startedAt: number | null;
/** v1.41 — when the stream closed (epoch ms); null while it runs. */
endedAt: number | null;
/** True until the `done` frame closes the stream. */
inProgress: boolean;
/** Final messageId once `done` lands; null otherwise. */
@@ -670,12 +657,9 @@ const EMPTY_STREAMING: CoachStreamingMessage = {
followUps: [],
clarification: null,
activity: [],
interimRefs: [],
memoryNote: null,
planProposal: null,
stop: null,
startedAt: null,
endedAt: null,
inProgress: false,
messageId: null,
errorCode: null,
@@ -988,12 +972,7 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
content: params.message,
conversationId: params.conversationId ?? null,
});
const startedAt = Date.now();
setStreaming({
...EMPTY_STREAMING,
inProgress: true,
startedAt,
});
setStreaming({ ...EMPTY_STREAMING, inProgress: true });
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
// user gets the offline-specific copy immediately rather than
@@ -1159,11 +1138,12 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
if (evt.interim && !interimRefs.includes(result.ref)) {
interimRefs.push(result.ref);
}
const refs = [...interimRefs];
// An interim table is kept quietly: nothing renders it
// until the answer's own results do, and `done` may still
// take it down again (`withheldResults`).
setStreaming((prev) => ({
...prev,
results: upsertResult(prev.results, result),
interimRefs: refs,
}));
break;
}
@@ -1259,12 +1239,9 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
followUps: collectedFollowUps,
clarification: collectedClarification,
activity: collectedActivity,
interimRefs: withheldResults ? [] : interimRefs,
memoryNote: collectedMemoryNote,
planProposal: collectedPlanProposal,
stop: collectedStop,
startedAt,
endedAt: Date.now(),
inProgress: false,
messageId,
errorCode: lastError,
+50
View File
@@ -0,0 +1,50 @@
import { cn } from "@/lib/utils";
/** A trailing ellipsis, three dots or the single character. */
const TRAILING_ELLIPSIS = /(?:\.\.\.|…)\s*$/u;
/** The stagger of the three dots, one class each (no inline style). */
const DOT_DELAYS = [
"[animation-delay:0s]",
"[animation-delay:0.2s]",
"[animation-delay:0.4s]",
] as const;
/**
* Text that is still waiting on something: a trailing "..." or "…" becomes
* three dots that fade in turn (`waiting-dot` in `globals.css`), so the line
* reads as alive without a spinner beside it. Text without a trailing
* ellipsis renders as it is. Under reduced motion the dots stand still.
*
* `textClassName` styles the words alone, never the dots: a text effect
* clipped to the glyphs (`text-shimmer`) does not reliably reach a child
* that animates its own opacity, so the dots keep the surrounding colour.
*
* The dots are ordinary full stops, so the text reads and copies as
* "Thinking...". A surface that announces its status elsewhere hides this
* text from assistive technology itself.
*/
export function WaitingText({
text,
textClassName,
}: {
text: string;
textClassName?: string;
}) {
const match = TRAILING_ELLIPSIS.exec(text);
const words = match ? text.slice(0, match.index) : text;
return (
<>
<span className={textClassName}>{words}</span>
{match ? (
<span data-slot="waiting-dots">
{DOT_DELAYS.map((delay) => (
<span key={delay} className={cn("waiting-dot", delay)}>
.
</span>
))}
</span>
) : null}
</>
);
}
@@ -26,13 +26,12 @@ import {
activityDigestDoneKey,
activityDigestKey,
activityMemoryKey,
activitySummaryKey,
activityThoughtProcessKey,
} from "@/lib/ai/coach/activity/contract";
import {
COACH_ASSUMPTION_KEYS,
COACH_CHART_COMPARE_KEYS,
COACH_SUGGESTED_REPLIES_KEYS,
interimLabelKey,
} from "@/lib/ai/coach/dialog-keys";
import {
COACH_MEMORY_KEYS,
@@ -91,8 +90,7 @@ function catalogKeys(): Set<string> {
keys.add(activityDigestDoneKey(count, locale));
keys.add(activityAreasKey(count, locale));
keys.add(activityMemoryKey(count, locale));
keys.add(activitySummaryKey(count, locale));
keys.add(interimLabelKey(count, locale));
keys.add(activityThoughtProcessKey(count, locale));
}
}
return keys;
@@ -104,7 +102,6 @@ const OWNED = [
"insights.coach.plan",
"insights.coach.assumption",
"insights.coach.suggestedReplies",
"insights.coach.interim",
"insights.coach.chart",
"insights.coach.reasoning",
"settings.coach.memory",
@@ -122,7 +119,7 @@ describe("v1.41 contract keys", () => {
// Every key laid down for the release but the `change_assumption` chip
// label, which the dialog catalog's own test covers, plus the two
// declined-proposal replies and the admin reasoning description.
expect(keys.size).toBe(60);
expect(keys.size).toBe(57);
});
it.each(locales)("every catalog key resolves in %s", (locale) => {
@@ -47,7 +47,6 @@ import {
methodAveragesKey,
methodReadingsKey,
methodTotalsKey,
stepHeaderDoneKey,
stepMetricsKey,
stepReadingsKey,
stepRowsKey,
@@ -112,7 +111,6 @@ function catalogKeys(): Set<string> {
}
for (const locale of locales) {
for (const count of COUNTS) {
keys.add(stepHeaderDoneKey(count, locale));
keys.add(stepReadingsKey(count, locale));
keys.add(stepRowsKey(count, locale));
keys.add(stepMetricsKey(count, locale));
@@ -4,7 +4,7 @@ import {
ACTIVITY_TEXT_MAX_CHARS,
ACTIVITY_TITLE_MAX_CHARS,
TRAIL_MAX_BYTES,
activitySummaryKey,
activityThoughtProcessKey,
createNoopActivityRecorder,
} from "../contract";
@@ -38,15 +38,15 @@ describe("activity contract", () => {
);
});
it("says nothing about lookups on a turn that made none", () => {
expect(activitySummaryKey(0, "en")).toBe(
"insights.coach.activity.summaryNoLookups",
it("counts the steps of the settled line by plural tier", () => {
expect(activityThoughtProcessKey(1, "en")).toBe(
"insights.coach.activity.thoughtProcessOne",
);
expect(activitySummaryKey(1, "en")).toBe(
"insights.coach.activity.summaryOne",
expect(activityThoughtProcessKey(0, "en")).toBe(
"insights.coach.activity.thoughtProcessOther",
);
expect(activitySummaryKey(3, "pl")).toBe(
"insights.coach.activity.summaryFew",
expect(activityThoughtProcessKey(3, "pl")).toBe(
"insights.coach.activity.thoughtProcessFew",
);
});
});
+8 -8
View File
@@ -100,6 +100,8 @@ export const COACH_ACTIVITY_KEYS = {
plan: "insights.coach.activity.plan",
asking: "insights.coach.activity.asking",
answer: "insights.coach.activity.answer",
/** The settled line of a turn that waits for the person's choice. */
awaitingAnswer: "insights.coach.activity.awaitingAnswer",
} as const;
/** The `stop` entry's label, by reason. */
@@ -132,12 +134,10 @@ export function activityMemoryKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.activity.memory", count, locale);
}
/**
* The one-line summary once the turn is done: "Thought it through, N lookups,
* S s", or the seconds alone on a turn that looked nothing up.
*/
export function activitySummaryKey(lookups: number, locale: Locale): string {
return lookups === 0
? "insights.coach.activity.summaryNoLookups"
: pluralKey("insights.coach.activity.summary", lookups, locale);
/** The settled line once the turn is done: "Thought process · N steps". */
export function activityThoughtProcessKey(
count: number,
locale: Locale,
): string {
return pluralKey("insights.coach.activity.thoughtProcess", count, locale);
}
+1 -11
View File
@@ -46,11 +46,6 @@ export const COACH_STEP_UI_KEYS = {
announceDone: "coach.step.announceDone",
} as const;
/** "Looked at N sources", by plural tier. */
export function stepHeaderDoneKey(count: number, locale: Locale): string {
return pluralKey("coach.step.headerDone", count, locale);
}
/** "N readings" on a step row, by plural tier. */
export function stepReadingsKey(count: number, locale: Locale): string {
return pluralKey("coach.step.readings", count, locale);
@@ -256,7 +251,7 @@ export function clarifyWindowLabelKey(window: CoachScopeWindow): string {
return `coach.clarify.window.${window}`;
}
// ── Replies, assumptions, interim tables (v1.41) ──────────────────────────
// ── Replies, assumptions (v1.41) ──────────────────────────────────────
/** The group of reply pills under the latest answer. */
export const COACH_SUGGESTED_REPLIES_KEYS = {
@@ -269,11 +264,6 @@ export const COACH_ASSUMPTION_KEYS = {
change: "insights.coach.assumption.change",
} as const;
/** The one-line preview of a table read while the turn still runs. */
export function interimLabelKey(count: number, locale: Locale): string {
return pluralKey("insights.coach.interim.label", count, locale);
}
/** The legend of a chart comparing two periods ("{a} vs {b}"). */
export const COACH_CHART_COMPARE_KEYS = {
periods: "insights.coach.chart.compare.periods",
+15
View File
@@ -0,0 +1,15 @@
/**
* The refusal a demo deploy answers a write with (`DEMO_MODE=true`): the
* proxy's mutation block and the routes that bypass the proxy send the same
* body, a 403 whose `meta.errorCode` names it, so a client can tell "the demo
* does not do this" from an outage and say so.
*
* Client-safe: no server import.
*/
export const DEMO_READ_ONLY_CODE = "demo.readOnly";
export const DEMO_REFUSAL_BODY = {
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true, errorCode: DEMO_READ_ONLY_CODE },
} as const;
+2 -8
View File
@@ -28,6 +28,7 @@
* nothing else under `/api`.
*/
import { shouldRunWeb } from "@/lib/process-type";
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
export const PROXY_BYPASS_ROUTES: readonly string[] = [
// Streamed archive uploads, up to 1.5 GB.
@@ -115,14 +116,7 @@ export function bypassRouteRefusal(method: string): Response | null {
const m = method.toUpperCase();
const isMutation = m !== "GET" && m !== "HEAD" && m !== "OPTIONS";
if (process.env.DEMO_MODE === "true" && isMutation) {
return Response.json(
{
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true },
},
{ status: 403 },
);
return Response.json(DEMO_REFUSAL_BODY, { status: 403 });
}
return null;
}
+1
View File
@@ -118,6 +118,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
"disable-coach": ["disable-coach.body.invalid_json"],
demo: ["demo.readOnly"],
documents: [
"documents.chat.conversationNotFound",
"documents.chat.invalid",
+2 -8
View File
@@ -6,6 +6,7 @@ import {
RETIRED_ROUTE_STATUS,
} from "@/lib/http/retired-routes";
import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes";
import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal";
/**
* Paths that do NOT require a session cookie (public pages + external webhooks).
@@ -361,14 +362,7 @@ export function proxy(request: NextRequest) {
)
) {
return applyBaselineSecurityHeaders(
NextResponse.json(
{
data: null,
error: "Demo mode: modifications are disabled",
meta: { demo: true },
},
{ status: 403 },
),
NextResponse.json(DEMO_REFUSAL_BODY, { status: 403 }),
);
}
}